16 Commits

Author SHA1 Message Date
CleverWild
694c569c08 feat(integrity): introduce sealed provenance markers for Verified
Some checks failed
ci/woodpecker/pr/server-audit Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
2026-04-15 19:45:59 +02:00
CleverWild
bec82e036e feat(integrity): derive-like macro VerifiedFields that allows to inherit Verified<T> type's provenance to all fields of T
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-11 03:53:25 +02:00
CleverWild
763058b014 feat(server): unify integrity API and propagate verified IDs through auth/EVM flows
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline was successful
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-07 21:12:36 +02:00
hdbg
1497884ce6 fix(server::bootsrapper): token compare is now constant-time
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 18:33:47 +02:00
hdbg
b3464cf8a6 tests(server::client::auth): integrity envelope insertion for valid paths
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 18:24:13 +02:00
hdbg
46d1318b6f feat(server): add integrity verification for client keys 2026-04-06 18:13:11 +02:00
9c80d51d45 Merge pull request 'fix(server): replaced postcard-based integrity fingerprint with custom trait providing order-independent hashing' (#77) from push-opwuyuwxknyo into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #77
2026-04-06 15:42:47 +00:00
hdbg
33456a644d tests(server): property-based testing for ordering independency for hash
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-06 17:40:41 +02:00
hdbg
5bc0c42cc7 fix(server): replaced postcard-based integrity fingerprint with custom trait providing order-independent hashing 2026-04-06 16:25:32 +02:00
hdbg
f6b62ab884 fix(server): added chain_id check and covered check_shared_constraints with unit tests
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 12:57:18 +02:00
hdbg
2dd5a3f32f tests(server): initial cargo-mutants
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
2026-04-06 12:03:56 +02:00
hdbg
1aca9d4007 fix(server): simplify hash function for debug profile 2026-04-05 22:50:28 +02:00
5ee1b49c43 Merge pull request 'feat(server): integrity envelope engine for EVM grants with HMAC verification' (#51) from integrity-envelope into main
Some checks failed
ci/woodpecker/push/server-audit Pipeline was successful
ci/woodpecker/push/server-lint Pipeline failed
ci/woodpecker/push/server-vet Pipeline failed
ci/woodpecker/push/server-test Pipeline was successful
Reviewed-on: #51
2026-04-05 16:26:51 +00:00
hdbg
00745bb381 tests(server): fixed for new integrity checks
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline was successful
2026-04-05 14:49:02 +02:00
hdbg
b122aa464c refactor(server): rework envelopes and integrity check
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-vet Pipeline failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
2026-04-05 14:17:00 +02:00
hdbg
9fab945a00 fix(server): remove stale mentions of miette
Some checks failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed
2026-04-05 10:45:24 +02:00
48 changed files with 2928 additions and 1456 deletions

View File

@@ -48,6 +48,10 @@ backend = "cargo:cargo-features-manager"
version = "1.46.3"
backend = "cargo:cargo-insta"
[[tools."cargo:cargo-mutants"]]
version = "27.0.0"
backend = "cargo:cargo-mutants"
[[tools."cargo:cargo-nextest"]]
version = "0.9.126"
backend = "cargo:cargo-nextest"
@@ -111,30 +115,37 @@ backend = "core:python"
[tools.python."platforms.linux-arm64"]
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-arm64-musl"]
checksum = "sha256:53700338695e402a1a1fe22be4a41fbdacc70e22bb308a48eca8ed67cb7992be"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64"]
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.linux-x64-musl"]
checksum = "sha256:d7a9f970914bb4c88756fe3bdcc186d4feb90e9500e54f1db47dae4dc9687e39"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-unknown-linux-gnu-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-arm64"]
checksum = "sha256:c43aecde4a663aebff99b9b83da0efec506479f1c3f98331442f33d2c43501f9"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-aarch64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.macos-x64"]
checksum = "sha256:9ab41dbc2f100a2a45d1833b9c11165f51051c558b5213eda9a9731d5948a0c0"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-apple-darwin-install_only_stripped.tar.gz"
provenance = "github-attestations"
[tools.python."platforms.windows-x64"]
checksum = "sha256:bbe19034b35b0267176a7442575ae7dc6343480fd4d35598cb7700173d431e09"
url = "https://github.com/astral-sh/python-build-standalone/releases/download/20260324/cpython-3.14.3+20260324-x86_64-pc-windows-msvc-install_only_stripped.tar.gz"
provenance = "github-attestations"
[[tools.rust]]
version = "1.93.0"

View File

@@ -12,6 +12,7 @@ protoc = "29.6"
python = "3.14.3"
ast-grep = "0.42.0"
"cargo:cargo-edit" = "0.13.9"
"cargo:cargo-mutants" = "27.0.0"
[tasks.codegen]
sources = ['protobufs/*.proto', 'protobufs/**/*.proto']

View File

@@ -36,6 +36,10 @@ message GasLimitExceededViolation {
}
message EvalViolation {
message ChainIdMismatch {
uint64 expected = 1;
uint64 actual = 2;
}
oneof kind {
bytes invalid_target = 1; // 20-byte Ethereum address
GasLimitExceededViolation gas_limit_exceeded = 2;
@@ -43,6 +47,8 @@ message EvalViolation {
google.protobuf.Empty volumetric_limit_exceeded = 4;
google.protobuf.Empty invalid_time = 5;
google.protobuf.Empty invalid_transaction_type = 6;
ChainIdMismatch chain_id_mismatch = 7;
}
}

View File

@@ -0,0 +1 @@
test_tool = "nextest"

2
server/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
mutants.out/
mutants.out.old/

47
server/Cargo.lock generated
View File

@@ -742,20 +742,27 @@ dependencies = [
"insta",
"k256",
"kameo",
"macro_rules_attribute",
"memsafe",
"mutants",
"paste",
"pem",
"proptest",
"prost",
"prost-types",
"rand 0.10.0",
"rcgen",
"restructed",
"rsa",
"rstest",
"rustls",
"secrecy",
"serde_with",
"sha2 0.10.9",
"smlang",
"spki",
"strum 0.28.0",
"subtle",
"test-log",
"thiserror 2.0.18",
"tokio",
@@ -1956,6 +1963,7 @@ version = "3.0.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890"
dependencies = [
"serde",
"signature 3.0.0-rc.10",
]
@@ -1968,6 +1976,7 @@ dependencies = [
"curve25519-dalek 5.0.0-pre.6",
"ed25519",
"rand_core 0.10.0",
"serde",
"sha2 0.11.0-rc.5",
"subtle",
"zeroize",
@@ -2053,7 +2062,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3050,6 +3059,22 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "macro_rules_attribute"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65049d7923698040cd0b1ddcced9b0eb14dd22c5f86ae59c3740eab64a676520"
dependencies = [
"macro_rules_attribute-proc_macro",
"paste",
]
[[package]]
name = "macro_rules_attribute-proc_macro"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "670fdfda89751bc4a84ac13eaa63e205cf0fd22b4c9a5fbfa085b63c1f1d3a30"
[[package]]
name = "matchers"
version = "0.2.0"
@@ -3172,6 +3197,12 @@ version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084"
[[package]]
name = "mutants"
version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "add0ac067452ff1aca8c5002111bd6b1c895baee6e45fcbc44e0193aea17be56"
[[package]]
name = "nom"
version = "7.1.3"
@@ -3188,7 +3219,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -3213,6 +3244,7 @@ dependencies = [
"num-iter",
"num-traits",
"rand 0.8.5",
"serde",
"smallvec",
"zeroize",
]
@@ -3635,9 +3667,9 @@ dependencies = [
[[package]]
name = "proptest"
version = "1.10.0"
version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37566cb3fdacef14c0737f9546df7cfeadbfbc9fef10991038bf5015d0c80532"
checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744"
dependencies = [
"bit-set",
"bit-vec",
@@ -4152,6 +4184,7 @@ dependencies = [
"pkcs1",
"pkcs8",
"rand_core 0.6.4",
"serde",
"sha2 0.10.9",
"signature 2.2.0",
"spki",
@@ -4287,7 +4320,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]
@@ -4703,7 +4736,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@@ -4897,7 +4930,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix",
"windows-sys 0.52.0",
"windows-sys 0.61.2",
]
[[package]]

View File

@@ -44,3 +44,4 @@ sha2 = "0.10"
spki = "0.7"
prost = "0.14.3"
miette = { version = "7.6.0", features = ["fancy", "serde"] }
mutants = "0.0.4"

View File

@@ -59,6 +59,10 @@ pub struct ArbiterEvmWallet {
}
impl ArbiterEvmWallet {
#[expect(
dead_code,
reason = "constructor may be used in future extensions, e.g. to support wallet listing"
)]
pub(crate) fn new(transport: Arc<Mutex<ClientTransport>>, address: Address) -> Self {
Self {
transport,

View File

@@ -61,10 +61,6 @@ pub mod proto {
pub mod evm {
tonic::include_proto!("arbiter.evm");
}
pub mod integrity {
tonic::include_proto!("arbiter.integrity");
}
}
#[derive(Debug, Clone, PartialEq, Eq)]

View File

@@ -17,6 +17,7 @@ diesel-async = { version = "0.8.0", features = [
"tokio",
] }
ed25519-dalek.workspace = true
ed25519-dalek.features = ["serde"]
arbiter-proto.path = "../arbiter-proto"
tracing.workspace = true
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
@@ -46,7 +47,9 @@ restructed = "0.2.2"
strum = { version = "0.28.0", features = ["derive"] }
pem = "3.0.6"
k256.workspace = true
k256.features = ["serde"]
rsa.workspace = true
rsa.features = ["serde"]
sha2.workspace = true
hmac = "0.12"
spki.workspace = true
@@ -55,7 +58,14 @@ prost-types.workspace = true
prost.workspace = true
arbiter-tokens-registry.path = "../arbiter-tokens-registry"
anyhow = "1.0.102"
serde_with = "3.18.0"
mutants.workspace = true
subtle = "2.6.1"
macro_rules_attribute = "0.2.2"
paste = "1.0.15"
[dev-dependencies]
insta = "1.46.3"
proptest = "1.11.0"
rstest.workspace = true
test-log = { version = "0.2", default-features = false, features = ["trace"] }

View File

@@ -47,7 +47,6 @@ create table if not exists useragent_client (
id integer not null primary key,
nonce integer not null default(1), -- used for auth challenge
public_key blob not null,
pubkey_integrity_tag blob,
key_type integer not null default(1), -- 1=Ed25519, 2=ECDSA(secp256k1)
created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now'))

View File

@@ -4,6 +4,7 @@ use diesel_async::RunQueryDsl;
use kameo::{Actor, messages};
use rand::{RngExt, distr::Alphanumeric, make_rng, rngs::StdRng};
use subtle::ConstantTimeEq as _;
use thiserror::Error;
use crate::db::{self, DatabasePool, schema};
@@ -44,14 +45,14 @@ pub struct Bootstrapper {
impl Bootstrapper {
pub async fn new(db: &DatabasePool) -> Result<Self, Error> {
let row_count: i64 = {
let mut conn = db.get().await?;
let row_count: i64 = schema::useragent_client::table
schema::useragent_client::table
.count()
.get_result(&mut conn)
.await?;
drop(conn);
.await?
};
let token = if row_count == 0 {
let token = generate_token().await?;
@@ -69,7 +70,13 @@ impl Bootstrapper {
#[message]
pub fn is_correct_token(&self, token: String) -> bool {
match &self.token {
Some(expected) => *expected == token,
Some(expected) => {
let expected_bytes = expected.as_bytes();
let token_bytes = token.as_bytes();
let choice = expected_bytes.ct_eq(token_bytes);
bool::from(choice)
}
None => false,
}
}

View File

@@ -9,14 +9,16 @@ use diesel::{
};
use diesel_async::RunQueryDsl as _;
use ed25519_dalek::{Signature, VerifyingKey};
use kameo::error::SendError;
use kameo::{actor::ActorRef, error::SendError};
use tracing::error;
use crate::{
actors::{
client::{ClientConnection, ClientProfile},
client::{ClientConnection, ClientCredentials, ClientProfile},
flow_coordinator::{self, RequestClientApproval},
keyholder::KeyHolder,
},
crypto::integrity::{self, Verified, verified::VerifiedFieldsAccessor},
db::{
self,
models::{ProgramClientMetadata, SqliteTimestamp},
@@ -30,6 +32,8 @@ pub enum Error {
DatabasePoolUnavailable,
#[error("Database operation failed")]
DatabaseOperationFailed,
#[error("Integrity check failed")]
IntegrityCheckFailed,
#[error("Invalid challenge solution")]
InvalidChallengeSolution,
#[error("Client approval request failed")]
@@ -38,6 +42,13 @@ pub enum Error {
Transport,
}
impl From<diesel::result::Error> for Error {
fn from(e: diesel::result::Error) -> Self {
error!(?e, "Database error");
Self::DatabaseOperationFailed
}
}
#[derive(thiserror::Error, Debug, Clone, PartialEq, Eq)]
pub enum ApproveError {
#[error("Internal error")]
@@ -65,17 +76,36 @@ pub enum Outbound {
AuthSuccess,
}
pub struct ClientInfo {
pub id: i32,
pub current_nonce: i32,
}
/// Atomically reads and increments the nonce for a known client.
/// Returns the current nonce and client ID for a registered client.
/// Returns `None` if the pubkey is not registered.
async fn get_client_and_nonce(
async fn get_current_nonce_and_id(
db: &db::DatabasePool,
pubkey: &VerifyingKey,
) -> Result<Option<ClientInfo>, Error> {
) -> Result<Option<(i32, i32)>, Error> {
let pubkey_bytes = pubkey.as_bytes().to_vec();
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
program_client::table
.filter(program_client::public_key.eq(&pubkey_bytes))
.select((program_client::id, program_client::nonce))
.first::<(i32, i32)>(&mut conn)
.await
.optional()
.map_err(|e| {
error!(error = ?e, "Database error");
Error::DatabaseOperationFailed
})
}
/// Atomically increments the nonce and re-signs the integrity envelope.
/// Returns the new nonce, which is used as the challenge nonce.
async fn create_nonce(
db: &db::DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &VerifyingKey,
) -> Result<i32, Error> {
let pubkey_bytes = pubkey.as_bytes().to_vec();
let mut conn = db.get().await.map_err(|e| {
@@ -84,34 +114,35 @@ async fn get_client_and_nonce(
})?;
conn.exclusive_transaction(|conn| {
let keyholder = keyholder.clone();
Box::pin(async move {
let Some((client_id, current_nonce)) = program_client::table
let (id, new_nonce): (i32, i32) = update(program_client::table)
.filter(program_client::public_key.eq(&pubkey_bytes))
.select((program_client::id, program_client::nonce))
.first::<(i32, i32)>(conn)
.await
.optional()?
else {
return Result::<_, diesel::result::Error>::Ok(None);
};
update(program_client::table)
.filter(program_client::public_key.eq(&pubkey_bytes))
.set(program_client::nonce.eq(current_nonce + 1))
.execute(conn)
.set(program_client::nonce.eq(program_client::nonce + 1))
.returning((program_client::id, program_client::nonce))
.get_result(conn)
.await?;
Ok(Some(ClientInfo {
id: client_id,
current_nonce,
}))
integrity::sign_entity(
conn,
&keyholder,
&ClientCredentials {
pubkey: *pubkey,
nonce: new_nonce,
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity sign failed after nonce update");
Error::DatabaseOperationFailed
})?
.drop_verification_provenance();
Ok(new_nonce)
})
})
.await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::DatabaseOperationFailed
})
}
async fn approve_new_client(
@@ -139,15 +170,23 @@ async fn approve_new_client(
async fn insert_client(
db: &db::DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &VerifyingKey,
metadata: &ClientMetadata,
) -> Result<i32, Error> {
) -> Result<Verified<i32>, Error> {
use crate::db::schema::{client_metadata, program_client};
let metadata = metadata.clone();
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
conn.exclusive_transaction(|conn| {
let keyholder = keyholder.clone();
Box::pin(async move {
const NONCE_START: i32 = 1;
let metadata_id = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&metadata.name),
@@ -155,29 +194,40 @@ async fn insert_client(
client_metadata::version.eq(&metadata.version),
))
.returning(client_metadata::id)
.get_result::<i32>(&mut conn)
.await
.map_err(|e| {
error!(error = ?e, "Failed to insert client metadata");
Error::DatabaseOperationFailed
})?;
.get_result::<i32>(conn)
.await?;
let client_id = insert_into(program_client::table)
.values((
program_client::public_key.eq(pubkey.as_bytes().to_vec()),
program_client::metadata_id.eq(metadata_id),
program_client::nonce.eq(1), // pre-incremented; challenge uses 0
program_client::nonce.eq(NONCE_START),
))
.on_conflict_do_nothing()
.returning(program_client::id)
.get_result::<i32>(&mut conn)
.get_result::<i32>(conn)
.await?;
let verified_id = integrity::sign_entity(
conn,
&keyholder,
&ClientCredentials {
pubkey: *pubkey,
nonce: NONCE_START,
},
client_id,
)
.await
.map_err(|e| {
error!(error = ?e, "Failed to insert client metadata");
error!(error = ?e, "Failed to sign integrity tag for new client key");
Error::DatabaseOperationFailed
})?;
})?
.unqualify_origin();
Ok(client_id)
Ok(verified_id)
})
})
.await
}
async fn sync_client_metadata(
@@ -287,7 +337,10 @@ where
Ok(())
}
pub async fn authenticate<T>(props: &mut ClientConnection, transport: &mut T) -> Result<i32, Error>
pub async fn authenticate<T>(
props: &mut ClientConnection,
transport: &mut T,
) -> Result<Verified<i32>, Error>
where
T: Bi<Inbound, Result<Outbound, Error>> + Send + ?Sized,
{
@@ -295,8 +348,29 @@ where
return Err(Error::Transport);
};
let info = match get_client_and_nonce(&props.db, &pubkey).await? {
Some(nonce) => nonce,
// fixme! triage needed: probable regretion since in match->Some get_current_nonce_and_id called only once instead of twice
let client_id = match get_current_nonce_and_id(&props.db, &pubkey).await? {
Some((nonce, id)) => {
let mut db_conn = props.db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::DatabasePoolUnavailable
})?;
integrity::verify_entity(
&mut db_conn,
&props.actors.key_holder,
ClientCredentials { pubkey, nonce },
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity verification failed");
Error::IntegrityCheckFailed
})?
.inherit()
.entity_id
.unqualify_origin()
}
None => {
approve_new_client(
&props.actors,
@@ -306,16 +380,13 @@ where
},
)
.await?;
let client_id = insert_client(&props.db, &pubkey, &metadata).await?;
ClientInfo {
id: client_id,
current_nonce: 0,
}
insert_client(&props.db, &props.actors.key_holder, &pubkey, &metadata).await?
}
};
sync_client_metadata(&props.db, info.id, &metadata).await?;
challenge_client(transport, pubkey, info.current_nonce).await?;
sync_client_metadata(&props.db, *client_id, &metadata).await?;
let challenge_nonce = create_nonce(&props.db, &props.actors.key_holder, &pubkey).await?;
challenge_client(transport, pubkey, challenge_nonce).await?;
transport
.send(Ok(Outbound::AuthSuccess))
@@ -325,5 +396,5 @@ where
Error::Transport
})?;
Ok(info.id)
Ok(client_id)
}

View File

@@ -4,6 +4,7 @@ use tracing::{error, info};
use crate::{
actors::{GlobalActors, client::session::ClientSession},
crypto::integrity::{Integrable, hashing::Hashable},
db,
};
@@ -13,6 +14,22 @@ pub struct ClientProfile {
pub metadata: ClientMetadata,
}
pub struct ClientCredentials {
pub pubkey: ed25519_dalek::VerifyingKey,
pub nonce: i32,
}
impl Integrable for ClientCredentials {
const KIND: &'static str = "client_credentials";
}
impl Hashable for ClientCredentials {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
hasher.update(self.pubkey.as_bytes());
self.nonce.hash(hasher);
}
}
pub struct ClientConnection {
pub(crate) db: db::DatabasePool,
pub(crate) actors: GlobalActors,

View File

@@ -5,23 +5,25 @@ use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature};
use crate::{
actors::{
GlobalActors,
client::ClientConnection,
evm::{ClientSignTransaction, SignTransactionError},
flow_coordinator::RegisterClient,
keyholder::KeyHolderState,
},
db,
crypto::integrity::Verified,
evm::VetError,
};
#[cfg(test)]
use crate::{actors::GlobalActors, db};
pub struct ClientSession {
props: ClientConnection,
client_id: i32,
client_id: Verified<i32>,
}
impl ClientSession {
pub(crate) fn new(props: ClientConnection, client_id: i32) -> Self {
pub(crate) fn new(props: ClientConnection, client_id: Verified<i32>) -> Self {
Self { props, client_id }
}
}
@@ -54,7 +56,7 @@ impl ClientSession {
.actors
.evm
.ask(ClientSignTransaction {
client_id: self.client_id,
client_id: *self.client_id,
wallet_address,
transaction,
})
@@ -92,11 +94,12 @@ impl Actor for ClientSession {
}
impl ClientSession {
#[cfg(test)]
pub fn new_test(db: db::DatabasePool, actors: GlobalActors) -> Self {
let props = ClientConnection::new(db, actors);
Self {
props,
client_id: 0,
client_id: Verified::new_unchecked(0),
}
}
}

View File

@@ -7,30 +7,56 @@ use kameo::{Actor, actor::ActorRef, messages};
use rand::{SeedableRng, rng, rngs::StdRng};
use crate::{
actors::keyholder::{CreateNew, Decrypt, GetState, KeyHolder, KeyHolderState},
actors::keyholder::{CreateNew, Decrypt, KeyHolder},
crypto::integrity::{self, Integrable, Verified, hashing::Hashable},
db::{
DatabaseError, DatabasePool,
models::{self, SqliteTimestamp},
models::{self},
schema,
},
evm::{
self, RunKind,
self, ListError, RunKind,
policies::{
FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
},
},
integrity,
safe_cell::{SafeCell, SafeCellHandle as _},
};
pub use crate::evm::safe_signer;
/// Hashable structure for wallet integrity protection.
/// Binds the encrypted private key to the wallet address using HMAC.
pub struct EvmWalletIntegrity {
pub address: Vec<u8>, // 20-byte Ethereum address
pub aead_encrypted_id: i32, // Reference to encrypted key material
}
impl Hashable for EvmWalletIntegrity {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
hasher.update(&self.address);
hasher.update(self.aead_encrypted_id.to_be_bytes());
}
}
impl Integrable for EvmWalletIntegrity {
const KIND: &'static str = "evm_wallet";
}
#[derive(Debug, thiserror::Error)]
pub enum SignTransactionError {
#[error("Wallet not found")]
WalletNotFound,
#[error("Wallet integrity check failed")]
WalletIntegrityCheckFailed,
#[error(
"Decrypted key does not correspond to wallet address (CRITICAL: possible key substitution attack)"
)]
KeyAddressMismatch,
#[error("Database error: {0}")]
Database(#[from] DatabaseError),
@@ -45,6 +71,9 @@ pub enum SignTransactionError {
#[error("Policy error: {0}")]
Vet(#[from] evm::VetError),
#[error("Integrity error: {0}")]
Integrity(#[from] integrity::Error),
}
#[derive(Debug, thiserror::Error)]
@@ -58,9 +87,8 @@ pub enum Error {
#[error("Database error: {0}")]
Database(#[from] DatabaseError),
#[error("Vault is sealed")]
#[diagnostic(code(arbiter::evm::vault_sealed))]
VaultSealed,
#[error("Integrity violation: {0}")]
Integrity(#[from] integrity::Error),
}
#[derive(Actor)]
@@ -84,26 +112,12 @@ impl EvmActor {
engine,
}
}
async fn ensure_unsealed(&self) -> Result<(), Error> {
let state = self
.keyholder
.ask(GetState)
.await
.map_err(|_| Error::KeyholderSend)?;
if state != KeyHolderState::Unsealed {
return Err(Error::VaultSealed);
}
Ok(())
}
}
#[messages]
impl EvmActor {
#[message]
pub async fn generate(&mut self) -> Result<(i32, Address), Error> {
pub async fn generate(&mut self) -> Result<(Verified<i32>, Address), Error> {
let (mut key_cell, address) = safe_signer::generate(&mut self.rng);
let plaintext = key_cell.read_inline(|reader| SafeCell::new(reader.to_vec()));
@@ -115,7 +129,7 @@ impl EvmActor {
.map_err(|_| Error::KeyholderSend)?;
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let wallet_id = insert_into(schema::evm_wallet::table)
let wallet_id: i32 = insert_into(schema::evm_wallet::table)
.values(&models::NewEvmWallet {
address: address.as_slice().to_vec(),
aead_encrypted_id: aead_id,
@@ -125,7 +139,17 @@ impl EvmActor {
.await
.map_err(DatabaseError::from)?;
Ok((wallet_id, address))
// Sign integrity envelope to bind encrypted key to wallet address
let wallet_integrity = EvmWalletIntegrity {
address: address.as_slice().to_vec(),
aead_encrypted_id: aead_id,
};
let verified_wallet_id =
integrity::sign_entity(&mut conn, &self.keyholder, &wallet_integrity, wallet_id)
.await?
.unqualify_origin();
Ok((verified_wallet_id, address))
}
#[message]
@@ -151,63 +175,59 @@ impl EvmActor {
&mut self,
basic: SharedGrantSettings,
grant: SpecificGrant,
) -> Result<i32, Error> {
self.ensure_unsealed().await?;
) -> Result<integrity::Verified<i32>, Error> {
match grant {
SpecificGrant::EtherTransfer(settings) => {
self.engine
.create_grant::<EtherTransfer>(FullGrant {
basic,
SpecificGrant::EtherTransfer(settings) => self
.engine
.create_grant::<EtherTransfer>(CombinedSettings {
shared: basic,
specific: settings,
})
.await
.map_err(Error::from)
}
SpecificGrant::TokenTransfer(settings) => {
self.engine
.create_grant::<TokenTransfer>(FullGrant {
basic,
.map_err(Error::from),
SpecificGrant::TokenTransfer(settings) => self
.engine
.create_grant::<TokenTransfer>(CombinedSettings {
shared: basic,
specific: settings,
})
.await
.map_err(Error::from)
}
.map_err(Error::from),
}
}
#[message]
pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> {
self.ensure_unsealed().await?;
pub async fn useragent_delete_grant(&mut self, _grant_id: i32) -> Result<(), Error> {
// let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
// let keyholder = self.keyholder.clone();
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let keyholder = self.keyholder.clone();
// diesel_async::AsyncConnection::transaction(&mut conn, |conn| {
// Box::pin(async move {
// diesel::update(schema::evm_basic_grant::table)
// .filter(schema::evm_basic_grant::id.eq(grant_id))
// .set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
// .execute(conn)
// .await?;
diesel_async::AsyncConnection::transaction(&mut conn, |conn| {
Box::pin(async move {
diesel::update(schema::evm_basic_grant::table)
.filter(schema::evm_basic_grant::id.eq(grant_id))
.set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now()))
.execute(conn)
.await?;
// let signed = integrity::evm::load_signed_grant_by_basic_id(conn, grant_id).await?;
let signed = integrity::evm::load_signed_grant_by_basic_id(conn, grant_id).await?;
integrity::sign_entity(conn, &keyholder, &signed)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
// diesel::result::QueryResult::Ok(())
// })
// })
// .await
// .map_err(DatabaseError::from)?;
diesel::result::QueryResult::Ok(())
})
})
.await
.map_err(DatabaseError::from)?;
Ok(())
// Ok(())
todo!()
}
#[message]
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
Ok(self.engine.list_all_grants().await?)
match self.engine.list_all_grants().await {
Ok(grants) => Ok(grants),
Err(ListError::Database(db_err)) => Err(Error::Database(db_err)),
Err(ListError::Integrity(integrity_err)) => Err(Error::Integrity(integrity_err)),
}
}
#[message]
@@ -226,9 +246,23 @@ impl EvmActor {
.optional()
.map_err(DatabaseError::from)?
.ok_or(SignTransactionError::WalletNotFound)?;
// Verify wallet integrity envelope
let wallet = integrity::verify_entity(
&mut conn,
&self.keyholder,
EvmWalletIntegrity {
address: wallet.address.clone(),
aead_encrypted_id: wallet.aead_encrypted_id,
},
wallet.id,
)
.await
.map_err(|_| SignTransactionError::WalletIntegrityCheckFailed)?;
let wallet_access = schema::evm_wallet_access::table
.select(models::EvmWalletAccess::as_select())
.filter(schema::evm_wallet_access::wallet_id.eq(wallet.id))
.filter(schema::evm_wallet_access::wallet_id.eq(wallet.entity_id))
.filter(schema::evm_wallet_access::client_id.eq(client_id))
.first(&mut conn)
.await
@@ -261,9 +295,23 @@ impl EvmActor {
.optional()
.map_err(DatabaseError::from)?
.ok_or(SignTransactionError::WalletNotFound)?;
// Verify wallet integrity envelope to ensure encrypted key is bound to address
let wallet = integrity::verify_entity(
&mut conn,
&self.keyholder,
EvmWalletIntegrity {
address: wallet.address.clone(),
aead_encrypted_id: wallet.aead_encrypted_id,
},
wallet.id,
)
.await
.map_err(|_| SignTransactionError::WalletIntegrityCheckFailed)?;
let wallet_access = schema::evm_wallet_access::table
.select(models::EvmWalletAccess::as_select())
.filter(schema::evm_wallet_access::wallet_id.eq(wallet.id))
.filter(schema::evm_wallet_access::wallet_id.eq(wallet.entity_id))
.filter(schema::evm_wallet_access::client_id.eq(client_id))
.first(&mut conn)
.await
@@ -282,6 +330,12 @@ impl EvmActor {
let signer = safe_signer::SafeSigner::from_cell(raw_key)?;
// Verify that the decrypted key's derived address matches the wallet address
// This prevents an attacker from substituting one wallet's key with another's even if they compromised the DB
if signer.address() != wallet_address {
return Err(SignTransactionError::KeyAddressMismatch);
}
self.engine
.evaluate_transaction(wallet_access, transaction.clone(), RunKind::Execution)
.await?;

View File

@@ -4,9 +4,8 @@ use diesel::{
dsl::{insert_into, update},
};
use diesel_async::{AsyncConnection, RunQueryDsl};
use hmac::{Hmac, Mac as _};
use hmac::Mac as _;
use kameo::{Actor, Reply, messages};
use sha2::Sha256;
use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info};
@@ -14,7 +13,7 @@ use crate::{
crypto::{
KeyCell, derive_key,
encryption::v1::{self, Nonce},
integrity::v1::compute_integrity_tag,
integrity::v1::HmacSha256,
},
safe_cell::SafeCell,
};
@@ -26,13 +25,6 @@ use crate::{
},
safe_cell::SafeCellHandle as _,
};
use encryption::v1::{self, KeyCell, Nonce};
type HmacSha256 = Hmac<Sha256>;
const INTEGRITY_SUBKEY_TAG: &[u8] = b"arbiter/db-integrity-key/v1";
pub mod encryption;
#[derive(Default, EnumDiscriminants)]
#[strum_discriminants(derive(Reply), vis(pub), name(KeyHolderState))]
@@ -142,19 +134,6 @@ impl KeyHolder {
Ok(nonce)
}
fn derive_integrity_key(root_key: &mut KeyCell) -> [u8; 32] {
root_key.0.read_inline(|root_key_bytes| {
let mut hmac = match HmacSha256::new_from_slice(root_key_bytes.as_slice()) {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
};
hmac.update(INTEGRITY_SUBKEY_TAG);
let mut out = [0u8; 32];
out.copy_from_slice(&hmac.finalize().into_bytes());
out
})
}
#[message]
pub async fn bootstrap(&mut self, seal_key_raw: SafeCell<Vec<u8>>) -> Result<(), Error> {
if !matches!(self.state, State::Unbootstrapped) {
@@ -272,22 +251,6 @@ impl KeyHolder {
Ok(())
}
// Signs a generic integrity payload using the vault-derived integrity key
#[message]
pub fn sign_integrity_tag(
&mut self,
purpose_tag: Vec<u8>,
data_parts: Vec<Vec<u8>>,
) -> Result<Vec<u8>, Error> {
let State::Unsealed { root_key, .. } = &mut self.state else {
return Err(Error::NotBootstrapped);
};
let tag =
compute_integrity_tag(root_key, &purpose_tag, data_parts.iter().map(Vec::as_slice));
Ok(tag.to_vec())
}
#[message]
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
let State::Unsealed { root_key, .. } = &mut self.state else {
@@ -371,12 +334,12 @@ impl KeyHolder {
return Err(Error::NotBootstrapped);
};
let integrity_key = Self::derive_integrity_key(root_key);
let mut hmac = match HmacSha256::new_from_slice(&integrity_key) {
let mut hmac = root_key
.0
.read_inline(|k| match HmacSha256::new_from_slice(k) {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
};
});
hmac.update(&root_key_history_id.to_be_bytes());
hmac.update(&mac_input);
@@ -403,11 +366,12 @@ impl KeyHolder {
return Ok(false);
}
let integrity_key = Self::derive_integrity_key(root_key);
let mut hmac = match HmacSha256::new_from_slice(&integrity_key) {
let mut hmac = root_key
.0
.read_inline(|k| match HmacSha256::new_from_slice(k) {
Ok(v) => v,
Err(_) => unreachable!("HMAC accepts keys of any size"),
};
});
hmac.update(&key_version.to_be_bytes());
hmac.update(&mac_input);

View File

@@ -30,11 +30,27 @@ pub enum Error {
}
impl Error {
fn internal(details: impl Into<String>) -> Self {
Self::Internal {
details: details.into(),
#[track_caller]
pub(super) fn internal(details: impl Into<String>, err: &impl std::fmt::Debug) -> Self {
let details = details.into();
let caller = std::panic::Location::caller();
error!(
caller_file = %caller.file(),
caller_line = caller.line(),
caller_column = caller.column(),
details = %details,
error = ?err,
"Internal error"
);
Self::Internal { details }
}
}
impl From<diesel::result::Error> for Error {
fn from(e: diesel::result::Error) -> Self {
Self::internal("Database error", &e)
}
}
#[derive(Debug, Clone)]

View File

@@ -1,27 +1,20 @@
use arbiter_proto::transport::Bi;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update};
use diesel_async::RunQueryDsl;
use kameo::error::SendError;
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef;
use tracing::error;
use super::Error;
use crate::{
actors::{
bootstrap::ConsumeToken,
keyholder::{self, SignIntegrityTag},
user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound},
keyholder::KeyHolder,
user_agent::{AuthPublicKey, UserAgentConnection, UserAgentCredentials, auth::Outbound},
},
crypto::integrity::v1::USERAGENT_INTEGRITY_TAG,
db::schema,
crypto::integrity,
db::{DatabasePool, schema::useragent_client},
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttestationStatus {
Attested,
NotAttested,
Unavailable,
}
pub struct ChallengeRequest {
pub pubkey: AuthPublicKey,
}
@@ -50,72 +43,167 @@ smlang::statemachine!(
}
);
async fn create_nonce(
db: &crate::db::DatabasePool,
pubkey_bytes: &[u8],
key_type: crate::db::models::KeyType,
) -> Result<i32, Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
/// Returns the current nonce, ready to use for the challenge nonce.
async fn get_current_nonce_and_id(
db: &DatabasePool,
key: &AuthPublicKey,
) -> Result<(i32, i32), Error> {
let mut db_conn = db
.get()
.await
.map_err(|e| Error::internal("Database unavailable", &e))?;
db_conn
.exclusive_transaction(|conn| {
Box::pin(async move {
let current_nonce = schema::useragent_client::table
.filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec()))
.filter(schema::useragent_client::key_type.eq(key_type))
.select(schema::useragent_client::nonce)
.first::<i32>(conn)
.await?;
update(schema::useragent_client::table)
.filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec()))
.filter(schema::useragent_client::key_type.eq(key_type))
.set(schema::useragent_client::nonce.eq(current_nonce + 1))
.execute(conn)
.await?;
Result::<_, diesel::result::Error>::Ok(current_nonce)
useragent_client::table
.filter(useragent_client::public_key.eq(key.to_stored_bytes()))
.filter(useragent_client::key_type.eq(key.key_type()))
.select((useragent_client::id, useragent_client::nonce))
.first::<(i32, i32)>(conn)
.await
})
})
.await
.optional()
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?
.map_err(|e| Error::internal("Database operation failed", &e))?
.ok_or_else(|| {
error!(?pubkey_bytes, "Public key not found in database");
error!(?key, "Public key not found in database");
Error::UnregisteredPublicKey
})
}
async fn register_key(
db: &crate::db::DatabasePool,
async fn verify_integrity(
db: &DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &AuthPublicKey,
) -> Result<(), Error> {
let mut db_conn = db
.get()
.await
.map_err(|e| Error::internal("Database unavailable", &e))?;
let (id, nonce) = get_current_nonce_and_id(db, pubkey).await?;
let attestation_status = integrity::check_entity_attestation(
&mut db_conn,
keyholder,
&UserAgentCredentials {
pubkey: pubkey.clone(),
nonce,
},
id,
)
.await
.map_err(|e| Error::internal("Integrity verification failed", &e))?;
use integrity::AttestationStatus as AS;
// SAFETY (policy): challenge auth must work in both vault states.
// While sealed, integrity checks can only report `Unavailable` because key material is not
// accessible. While unsealed, the same check can report `Attested`.
// This path intentionally accepts both outcomes to keep challenge auth available across state
// transitions; stricter verification is enforced in sensitive post-auth flows.
match attestation_status {
AS::Attested | AS::Unavailable => Ok(()),
}
}
async fn create_nonce(
db: &DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &AuthPublicKey,
) -> Result<i32, Error> {
let mut db_conn = db
.get()
.await
.map_err(|e| Error::internal("Database unavailable", &e))?;
let new_nonce = db_conn
.exclusive_transaction(|conn| {
Box::pin(async move {
let (id, new_nonce): (i32, i32) = update(useragent_client::table)
.filter(useragent_client::public_key.eq(pubkey.to_stored_bytes()))
.filter(useragent_client::key_type.eq(pubkey.key_type()))
.set(useragent_client::nonce.eq(useragent_client::nonce + 1))
.returning((useragent_client::id, useragent_client::nonce))
.get_result(conn)
.await
.map_err(|e| Error::internal("Database operation failed", &e))?;
integrity::sign_entity(
conn,
keyholder,
&UserAgentCredentials {
pubkey: pubkey.clone(),
nonce: new_nonce,
},
id,
)
.await
.map_err(|e| Error::internal("Database error", &e))?
.drop_verification_provenance();
Result::<_, Error>::Ok(new_nonce)
})
})
.await?;
Ok(new_nonce)
}
async fn register_key(
db: &DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &AuthPublicKey,
integrity_tag: Option<Vec<u8>>,
) -> Result<(), Error> {
let pubkey_bytes = pubkey.to_stored_bytes();
let key_type = pubkey.key_type();
let mut conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
diesel::insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes),
schema::useragent_client::nonce.eq(1),
schema::useragent_client::key_type.eq(key_type),
schema::useragent_client::pubkey_integrity_tag.eq(integrity_tag),
))
.execute(&mut conn)
let mut conn = db
.get()
.await
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?;
.map_err(|e| Error::internal("Database unavailable", &e))?;
conn.transaction(|conn| {
Box::pin(async move {
const NONCE_START: i32 = 1;
let id: i32 = diesel::insert_into(useragent_client::table)
.values((
useragent_client::public_key.eq(pubkey_bytes),
useragent_client::nonce.eq(NONCE_START),
useragent_client::key_type.eq(key_type),
))
.returning(useragent_client::id)
.get_result(conn)
.await
.map_err(|e| Error::internal("Database operation failed", &e))?;
if let Err(e) = integrity::sign_entity(
conn,
keyholder,
&UserAgentCredentials {
pubkey: pubkey.clone(),
nonce: NONCE_START,
},
id,
)
.await
{
match e {
integrity::Error::Keyholder(
crate::actors::keyholder::Error::NotBootstrapped,
) => {
// IMPORTANT: bootstrap-token auth must work before the vault has a root key.
// We intentionally allow creating the DB row first and backfill envelopes
// after bootstrap/unseal to keep the bootstrap flow possible.
}
other => {
return Err(Error::internal("Failed to register public key", &other));
}
}
}
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
}
@@ -141,15 +229,9 @@ where
&mut self,
ChallengeRequest { pubkey }: ChallengeRequest,
) -> Result<ChallengeContext, Self::Error> {
match self.verify_pubkey_attestation_status(&pubkey).await? {
AttestationStatus::Attested | AttestationStatus::Unavailable => {}
AttestationStatus::NotAttested => {
return Err(Error::InvalidChallengeSolution);
}
}
verify_integrity(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
let stored_bytes = pubkey.to_stored_bytes();
let nonce = create_nonce(&self.conn.db, &stored_bytes, pubkey.key_type()).await?;
let nonce = create_nonce(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
self.transport
.send(Ok(Outbound::AuthChallenge { nonce }))
@@ -179,33 +261,32 @@ where
token: token.clone(),
})
.await
.map_err(|e| {
error!(?e, "Failed to consume bootstrap token");
Error::internal("Failed to consume bootstrap token")
})?;
.map_err(|e| Error::internal("Failed to consume bootstrap token", &e))?;
if !token_ok {
error!("Invalid bootstrap token provided");
return Err(Error::InvalidBootstrapToken);
}
let integrity_tag = self
.try_sign_pubkey_integrity_tag(&pubkey)
.await
.map_err(|err| {
error!(?err, "Failed to sign user-agent pubkey integrity tag");
Error::internal("Failed to sign user-agent pubkey integrity tag")
})?;
register_key(&self.conn.db, &pubkey, integrity_tag).await?;
match token_ok {
true => {
register_key(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
self.transport
.send(Ok(Outbound::AuthSuccess))
.await
.map_err(|_| Error::Transport)?;
Ok(pubkey)
}
false => {
error!("Invalid bootstrap token provided");
self.transport
.send(Err(Error::InvalidBootstrapToken))
.await
.map_err(|_| Error::Transport)?;
Err(Error::InvalidBootstrapToken)
}
}
}
#[allow(missing_docs)]
#[allow(clippy::unused_unit)]
@@ -264,93 +345,3 @@ where
}
}
}
impl<T> AuthContext<'_, T>
where
T: Bi<super::Inbound, Result<super::Outbound, Error>> + Send,
{
async fn try_sign_pubkey_integrity_tag(
&self,
pubkey: &AuthPublicKey,
) -> Result<Option<Vec<u8>>, Error> {
let signed = self
.conn
.actors
.key_holder
.ask(SignIntegrityTag {
purpose_tag: USERAGENT_INTEGRITY_TAG.to_vec(),
data_parts: vec![
(pubkey.key_type() as i32).to_be_bytes().to_vec(),
pubkey.to_stored_bytes(),
],
})
.await;
match signed {
Ok(tag) => Ok(Some(tag)),
Err(SendError::HandlerError(keyholder::Error::NotBootstrapped)) => Ok(None),
Err(SendError::HandlerError(err)) => {
error!(
?err,
"Keyholder failed to sign user-agent pubkey integrity tag"
);
Err(Error::internal(
"Keyholder failed to sign user-agent pubkey integrity tag",
))
}
Err(err) => {
error!(
?err,
"Failed to contact keyholder for user-agent pubkey integrity tag"
);
Err(Error::internal(
"Failed to contact keyholder for user-agent pubkey integrity tag",
))
}
}
}
async fn verify_pubkey_attestation_status(
&self,
pubkey: &AuthPublicKey,
) -> Result<AttestationStatus, Error> {
let stored_tag: Option<Option<Vec<u8>>> = {
let mut conn = self.conn.db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
schema::useragent_client::table
.filter(schema::useragent_client::public_key.eq(pubkey.to_stored_bytes()))
.filter(schema::useragent_client::key_type.eq(pubkey.key_type()))
.select(schema::useragent_client::pubkey_integrity_tag)
.first::<Option<Vec<u8>>>(&mut conn)
.await
.optional()
.map_err(|e| {
error!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?
};
let Some(stored_tag) = stored_tag else {
return Err(Error::UnregisteredPublicKey);
};
let Some(expected_tag) = self.try_sign_pubkey_integrity_tag(pubkey).await? else {
return Ok(AttestationStatus::Unavailable);
};
match stored_tag {
Some(stored_tag) if stored_tag == expected_tag => Ok(AttestationStatus::Attested),
Some(_) => {
error!("User-agent pubkey integrity tag mismatch");
Ok(AttestationStatus::NotAttested)
}
None => {
error!("Missing pubkey integrity tag for registered key while vault is unsealed");
Ok(AttestationStatus::NotAttested)
}
}
}
}

View File

@@ -1,5 +1,6 @@
use crate::{
actors::{GlobalActors, client::ClientProfile},
crypto::integrity::Integrable,
db::{self, models::KeyType},
};
@@ -13,6 +14,16 @@ pub enum AuthPublicKey {
Rsa(rsa::RsaPublicKey),
}
#[derive(Debug)]
pub struct UserAgentCredentials {
pub pubkey: AuthPublicKey,
pub nonce: i32,
}
impl Integrable for UserAgentCredentials {
const KIND: &'static str = "useragent_credentials";
}
impl AuthPublicKey {
/// Canonical bytes stored in DB and echoed back in the challenge.
/// Ed25519: raw 32 bytes. ECDSA: SEC1 compressed 33 bytes. RSA: DER-encoded SPKI.
@@ -92,3 +103,18 @@ pub mod session;
pub use auth::authenticate;
pub use session::UserAgentSession;
use crate::crypto::integrity::hashing::Hashable;
impl Hashable for AuthPublicKey {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
hasher.update(self.to_stored_bytes());
}
}
impl Hashable for UserAgentCredentials {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.pubkey.hash(hasher);
self.nonce.hash(hasher);
}
}

View File

@@ -10,7 +10,6 @@ use kameo::prelude::Context;
use tracing::{error, info};
use x25519_dalek::{EphemeralSecret, PublicKey};
use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer;
use crate::actors::keyholder::KeyHolderState;
use crate::actors::user_agent::session::Error;
use crate::db::models::{
@@ -18,6 +17,10 @@ use crate::db::models::{
};
use crate::evm::policies::{Grant, SpecificGrant};
use crate::safe_cell::SafeCell;
use crate::{
actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer,
crypto::integrity::{self, Verified},
};
use crate::{
actors::{
evm::{
@@ -29,11 +32,67 @@ use crate::{
UserAgentSession,
state::{UnsealContext, UserAgentEvents, UserAgentStates},
},
user_agent::{AuthPublicKey, UserAgentCredentials},
},
db::schema::useragent_client,
safe_cell::SafeCellHandle as _,
};
fn is_vault_sealed_from_evm<M>(err: &SendError<M, crate::actors::evm::Error>) -> bool {
matches!(
err,
SendError::HandlerError(crate::actors::evm::Error::Keyholder(
keyholder::Error::NotBootstrapped
)) | SendError::HandlerError(crate::actors::evm::Error::Integrity(
crate::crypto::integrity::Error::Keyholder(keyholder::Error::NotBootstrapped)
))
)
}
impl UserAgentSession {
async fn backfill_useragent_integrity(&self) -> Result<(), Error> {
let mut conn = self.props.db.get().await?;
let keyholder = self.props.actors.key_holder.clone();
conn.transaction(|conn| {
Box::pin(async move {
let rows: Vec<(i32, i32, Vec<u8>, crate::db::models::KeyType)> =
useragent_client::table
.select((
useragent_client::id,
useragent_client::nonce,
useragent_client::public_key,
useragent_client::key_type,
))
.load(conn)
.await?;
for (id, nonce, public_key, key_type) in rows {
let pubkey = AuthPublicKey::try_from((key_type, public_key)).map_err(|e| {
Error::internal(format!("Invalid user-agent key in db: {e}"))
})?;
integrity::sign_entity(
conn,
&keyholder,
&UserAgentCredentials { pubkey, nonce },
id,
)
.await
.map_err(|e| {
Error::internal(format!("Failed to backfill user-agent integrity: {e}"))
})?
.drop_verification_provenance();
}
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(())
}
fn take_unseal_secret(&mut self) -> Result<(EphemeralSecret, PublicKey), Error> {
let UserAgentStates::WaitingForUnsealKey(unseal_context) = self.state.state() else {
error!("Received encrypted key in invalid state");
@@ -191,6 +250,7 @@ impl UserAgentSession {
.await
{
Ok(_) => {
self.backfill_useragent_integrity().await?;
info!("Successfully unsealed key with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(())
@@ -252,6 +312,7 @@ impl UserAgentSession {
.await
{
Ok(_) => {
self.backfill_useragent_integrity().await?;
info!("Successfully bootstrapped vault with client-provided key");
self.transition(UserAgentEvents::ReceivedValidKey)?;
Ok(())
@@ -297,7 +358,9 @@ impl UserAgentSession {
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_evm_wallet_create(&mut self) -> Result<(i32, Address), Error> {
pub(crate) async fn handle_evm_wallet_create(
&mut self,
) -> Result<(Verified<i32>, Address), Error> {
match self.props.actors.evm.ask(Generate {}).await {
Ok(address) => Ok(address),
Err(SendError::HandlerError(err)) => Err(Error::internal(format!(
@@ -325,12 +388,15 @@ impl UserAgentSession {
#[messages]
impl UserAgentSession {
#[message]
pub(crate) async fn handle_grant_list(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
pub(crate) async fn handle_grant_list(
&mut self,
) -> Result<Vec<Grant<SpecificGrant>>, GrantMutationError> {
match self.props.actors.evm.ask(UseragentListGrants {}).await {
Ok(grants) => Ok(grants),
Err(err) if is_vault_sealed_from_evm(&err) => Err(GrantMutationError::VaultSealed),
Err(err) => {
error!(?err, "EVM grant list failed");
Err(Error::internal("Failed to list EVM grants"))
Err(GrantMutationError::Internal)
}
}
}
@@ -340,7 +406,7 @@ impl UserAgentSession {
&mut self,
basic: crate::evm::policies::SharedGrantSettings,
grant: crate::evm::policies::SpecificGrant,
) -> Result<i32, GrantMutationError> {
) -> Result<Verified<i32>, GrantMutationError> {
match self
.props
.actors
@@ -349,9 +415,7 @@ impl UserAgentSession {
.await
{
Ok(grant_id) => Ok(grant_id),
Err(SendError::HandlerError(crate::actors::evm::Error::VaultSealed)) => {
Err(GrantMutationError::VaultSealed)
}
Err(err) if is_vault_sealed_from_evm(&err) => Err(GrantMutationError::VaultSealed),
Err(err) => {
error!(?err, "EVM grant create failed");
Err(GrantMutationError::Internal)
@@ -368,13 +432,13 @@ impl UserAgentSession {
.props
.actors
.evm
.ask(UseragentDeleteGrant { grant_id })
.ask(UseragentDeleteGrant {
_grant_id: grant_id,
})
.await
{
Ok(()) => Ok(()),
Err(SendError::HandlerError(crate::actors::evm::Error::VaultSealed)) => {
Err(GrantMutationError::VaultSealed)
}
Err(err) if is_vault_sealed_from_evm(&err) => Err(GrantMutationError::VaultSealed),
Err(err) => {
error!(?err, "EVM grant delete failed");
Err(GrantMutationError::Internal)

View File

@@ -1 +1,3 @@
pub mod v1;
pub use v1::*;

View File

@@ -1 +1,3 @@
pub mod v1;
pub use v1::*;

View File

@@ -1,78 +1,319 @@
use crate::{crypto::KeyCell, safe_cell::SafeCellHandle as _};
use chacha20poly1305::Key;
use hmac::Mac as _;
use crate::actors::keyholder;
use hmac::Hmac;
use sha2::Sha256;
use std::future::Future;
use std::ops::Deref;
use std::pin::Pin;
pub const USERAGENT_INTEGRITY_DERIVE_TAG: &[u8] = "arbiter/useragent/integrity-key/v1".as_bytes();
pub const USERAGENT_INTEGRITY_TAG: &[u8] = "arbiter/useragent/pubkey-entry/v1".as_bytes();
use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::{actor::ActorRef, error::SendError};
use sha2::Digest as _;
/// Computes an integrity tag for a specific domain and payload shape.
pub fn compute_integrity_tag<'a, I>(
integrity_key: &mut KeyCell,
purpose_tag: &[u8],
data_parts: I,
) -> [u8; 32]
where
I: IntoIterator<Item = &'a [u8]>,
{
type HmacSha256 = hmac::Hmac<sha2::Sha256>;
pub mod hashing;
pub mod verified;
use self::hashing::Hashable;
let mut output_tag = [0u8; 32];
integrity_key.0.read_inline(|integrity_key_bytes: &Key| {
let mut mac = <HmacSha256 as hmac::Mac>::new_from_slice(integrity_key_bytes.as_ref())
.expect("HMAC key initialization must not fail for 32-byte key");
mac.update(purpose_tag);
for data_part in data_parts {
mac.update(data_part);
use crate::{
actors::keyholder::{KeyHolder, SignIntegrity, VerifyIntegrity},
db::{
self,
models::{IntegrityEnvelope as IntegrityEnvelopeRow, NewIntegrityEnvelope},
schema::integrity_envelope,
},
};
pub const CURRENT_PAYLOAD_VERSION: i32 = 1;
pub const INTEGRITY_SUBKEY_TAG: &[u8] = b"arbiter/db-integrity-key/v1";
pub type HmacSha256 = Hmac<Sha256>;
pub use self::verified::{Nested, Root, VerificationOrigin, Verified};
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("Database error: {0}")]
Database(#[from] db::DatabaseError),
#[error("KeyHolder error: {0}")]
Keyholder(#[from] keyholder::Error),
#[error("KeyHolder mailbox error")]
KeyholderSend,
#[error("Integrity envelope is missing for entity {entity_kind}")]
MissingEnvelope { entity_kind: &'static str },
#[error(
"Integrity payload version mismatch for entity {entity_kind}: expected {expected}, found {found}"
)]
PayloadVersionMismatch {
entity_kind: &'static str,
expected: i32,
found: i32,
},
#[error("Integrity MAC mismatch for entity {entity_kind}")]
MacMismatch { entity_kind: &'static str },
}
output_tag.copy_from_slice(&mac.finalize().into_bytes());
});
output_tag
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[must_use]
pub enum AttestationStatus {
Attested,
Unavailable,
}
pub trait Integrable: Hashable {
const KIND: &'static str;
const VERSION: i32 = 1;
}
impl<T: Integrable> Integrable for &T {
const KIND: &'static str = T::KIND;
const VERSION: i32 = T::VERSION;
}
#[derive(Debug, Clone)]
pub struct EntityId(Vec<u8>);
impl Deref for EntityId {
type Target = [u8];
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl From<i32> for EntityId {
fn from(value: i32) -> Self {
Self(value.to_be_bytes().to_vec())
}
}
impl From<&'_ [u8]> for EntityId {
fn from(bytes: &'_ [u8]) -> Self {
Self(bytes.to_vec())
}
}
pub async fn lookup_verified<E, Id, C, F, Fut>(
conn: &mut C,
keyholder: &ActorRef<KeyHolder>,
entity_id: Id,
load: F,
) -> Result<Verified<Entity<E, Id>, Nested<E>>, Error>
where
C: AsyncConnection<Backend = Sqlite>,
E: Integrable,
Id: Into<EntityId> + Clone,
F: FnOnce(&mut C) -> Fut,
Fut: Future<Output = Result<E, db::DatabaseError>>,
{
let entity = load(conn).await?;
verify_entity(conn, keyholder, entity, entity_id).await
}
pub async fn lookup_verified_from_query<E, Id, C, F>(
conn: &mut C,
keyholder: &ActorRef<KeyHolder>,
load: F,
) -> Result<Verified<Entity<E, Id>, Nested<E>>, Error>
where
C: AsyncConnection<Backend = Sqlite> + Send,
E: Integrable,
Id: Into<EntityId> + Clone,
F: for<'a> FnOnce(
&'a mut C,
) -> Pin<
Box<dyn Future<Output = Result<(Id, E), db::DatabaseError>> + Send + 'a>,
>,
{
let (entity_id, entity) = load(conn).await?;
verify_entity(conn, keyholder, entity, entity_id).await
}
pub async fn sign_entity<E: Integrable, Id: Into<EntityId> + Clone>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
entity: &E,
as_entity_id: Id,
) -> Result<Verified<Id, Nested<E>>, Error> {
let payload_hash = payload_hash(entity);
let entity_id = as_entity_id.clone().into();
let mac_input = build_mac_input(E::KIND, &entity_id, E::VERSION, &payload_hash);
let (key_version, mac) = keyholder
.ask(SignIntegrity { mac_input })
.await
.map_err(|err| match err {
kameo::error::SendError::HandlerError(inner) => Error::Keyholder(inner),
_ => Error::KeyholderSend,
})?;
insert_into(integrity_envelope::table)
.values(NewIntegrityEnvelope {
entity_kind: E::KIND.to_owned(),
entity_id: entity_id.to_vec(),
payload_version: E::VERSION,
key_version,
mac: mac.to_vec(),
})
.on_conflict((
integrity_envelope::entity_id,
integrity_envelope::entity_kind,
))
.do_update()
.set((
integrity_envelope::payload_version.eq(E::VERSION),
integrity_envelope::key_version.eq(key_version),
integrity_envelope::mac.eq(mac),
))
.execute(conn)
.await
.map_err(db::DatabaseError::from)?;
Ok(Verified::<Id, Nested<E>>::new(as_entity_id))
}
pub async fn check_entity_attestation<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
entity: &E,
entity_id: impl Into<EntityId>,
) -> Result<AttestationStatus, Error> {
let entity_id = entity_id.into();
let envelope: IntegrityEnvelopeRow = integrity_envelope::table
.filter(integrity_envelope::entity_kind.eq(E::KIND))
.filter(integrity_envelope::entity_id.eq(&*entity_id))
.first(conn)
.await
.map_err(|err| match err {
diesel::result::Error::NotFound => Error::MissingEnvelope {
entity_kind: E::KIND,
},
other => Error::Database(db::DatabaseError::from(other)),
})?;
if envelope.payload_version != E::VERSION {
return Err(Error::PayloadVersionMismatch {
entity_kind: E::KIND,
expected: E::VERSION,
found: envelope.payload_version,
});
}
let payload_hash = payload_hash(entity);
let mac_input = build_mac_input(E::KIND, &entity_id, envelope.payload_version, &payload_hash);
let result = keyholder
.ask(VerifyIntegrity {
mac_input,
expected_mac: envelope.mac,
key_version: envelope.key_version,
})
.await;
match result {
Ok(true) => Ok(AttestationStatus::Attested),
Ok(false) => Err(Error::MacMismatch {
entity_kind: E::KIND,
}),
Err(SendError::HandlerError(keyholder::Error::NotBootstrapped)) => {
Ok(AttestationStatus::Unavailable)
}
Err(_) => Err(Error::KeyholderSend),
}
}
#[derive(Debug, Clone, crate::VerifiedFields!)]
#[repr(C)]
pub struct Entity<E, Id> {
pub entity: E,
pub entity_id: Id,
}
impl<E, Id> Deref for Entity<E, Id> {
type Target = E;
fn deref(&self) -> &Self::Target {
&self.entity
}
}
pub async fn verify_entity<E: Integrable, Id: Into<EntityId> + Clone>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
entity: E,
entity_id: Id,
) -> Result<Verified<Entity<E, Id>, Nested<E>>, Error> {
match check_entity_attestation(conn, keyholder, &entity, entity_id.clone()).await? {
AttestationStatus::Attested => Ok(Verified::<Entity<E, Id>, Nested<E>>::new(Entity {
entity,
entity_id,
})),
AttestationStatus::Unavailable => Err(Error::Keyholder(keyholder::Error::NotBootstrapped)),
}
}
pub async fn verify_entity_ref<'e, E: Integrable, Id: Into<EntityId> + Clone>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
entity: &'e E,
entity_id: Id,
) -> Result<Verified<Entity<&'e E, Id>, Nested<E>>, Error> {
match check_entity_attestation(conn, keyholder, entity, entity_id.clone()).await? {
AttestationStatus::Attested => Ok(Verified::<Entity<&'e E, Id>, Nested<E>>::new(Entity {
entity,
entity_id,
})),
AttestationStatus::Unavailable => Err(Error::Keyholder(keyholder::Error::NotBootstrapped)),
}
}
pub async fn delete_envelope<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
entity_id: impl Into<EntityId>,
) -> Result<usize, Error> {
let entity_id = entity_id.into();
let affected = diesel::delete(
integrity_envelope::table
.filter(integrity_envelope::entity_kind.eq(E::KIND))
.filter(integrity_envelope::entity_id.eq(&*entity_id)),
)
.execute(conn)
.await
.map_err(db::DatabaseError::from)?;
Ok(affected)
}
fn payload_hash(payload: &impl Hashable) -> [u8; 32] {
let mut hasher = Sha256::new();
payload.hash(&mut hasher);
hasher.finalize().into()
}
fn build_mac_input(
entity_kind: &str,
entity_id: &[u8],
payload_version: i32,
payload_hash: &[u8; 32],
) -> Vec<u8> {
let mut out = Vec::with_capacity(8 + entity_kind.len() + entity_id.len() + 32);
push_len_prefixed(&mut out, entity_kind.as_bytes());
push_len_prefixed(&mut out, entity_id);
out.extend_from_slice(&payload_version.to_be_bytes());
out.extend_from_slice(payload_hash);
out
}
fn push_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes);
}
#[cfg(test)]
mod tests {
use crate::{
crypto::{derive_key, encryption::v1::generate_salt},
safe_cell::{SafeCell, SafeCellHandle as _},
};
use super::{USERAGENT_INTEGRITY_TAG, compute_integrity_tag};
#[test]
pub fn integrity_tag_deterministic() {
let salt = generate_salt();
let mut integrity_key = derive_key(SafeCell::new(b"password".to_vec()), &salt);
let key_type = 1i32.to_be_bytes();
let t1 = compute_integrity_tag(
&mut integrity_key,
USERAGENT_INTEGRITY_TAG,
[key_type.as_slice(), b"pubkey".as_ref()],
);
let t2 = compute_integrity_tag(
&mut integrity_key,
USERAGENT_INTEGRITY_TAG,
[key_type.as_slice(), b"pubkey".as_ref()],
);
assert_eq!(t1, t2);
}
#[test]
pub fn integrity_tag_changes_with_payload() {
let salt = generate_salt();
let mut integrity_key = derive_key(SafeCell::new(b"password".to_vec()), &salt);
let key_type_1 = 1i32.to_be_bytes();
let key_type_2 = 2i32.to_be_bytes();
let t1 = compute_integrity_tag(
&mut integrity_key,
USERAGENT_INTEGRITY_TAG,
[key_type_1.as_slice(), b"pubkey".as_ref()],
);
let t2 = compute_integrity_tag(
&mut integrity_key,
USERAGENT_INTEGRITY_TAG,
[key_type_2.as_slice(), b"pubkey".as_ref()],
);
assert_ne!(t1, t2);
}
}
mod tests;

View File

@@ -0,0 +1,107 @@
use hmac::digest::Digest;
use std::collections::HashSet;
/// Deterministically hash a value by feeding its fields into the hasher in a consistent order.
pub trait Hashable {
fn hash<H: Digest>(&self, hasher: &mut H);
}
macro_rules! impl_numeric {
($($t:ty),*) => {
$(
impl Hashable for $t {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(&self.to_be_bytes());
}
}
)*
};
}
impl_numeric!(u8, u16, u32, u64, i8, i16, i32, i64);
impl Hashable for &[u8] {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self);
}
}
impl Hashable for String {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.as_bytes());
}
}
impl<T: Hashable + PartialOrd> Hashable for Vec<T> {
fn hash<H: Digest>(&self, hasher: &mut H) {
let ref_sorted = {
let mut sorted = self.iter().collect::<Vec<_>>();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
sorted
};
for item in ref_sorted {
item.hash(hasher);
}
}
}
impl<T: Hashable + PartialOrd> Hashable for HashSet<T> {
fn hash<H: Digest>(&self, hasher: &mut H) {
let ref_sorted = {
let mut sorted = self.iter().collect::<Vec<_>>();
sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
sorted
};
for item in ref_sorted {
item.hash(hasher);
}
}
}
impl<T: Hashable> Hashable for Option<T> {
fn hash<H: Digest>(&self, hasher: &mut H) {
match self {
Some(value) => {
hasher.update([1]);
value.hash(hasher);
}
None => hasher.update([0]),
}
}
}
impl<T: Hashable> Hashable for Box<T> {
fn hash<H: Digest>(&self, hasher: &mut H) {
self.as_ref().hash(hasher);
}
}
impl<T: Hashable> Hashable for &T {
fn hash<H: Digest>(&self, hasher: &mut H) {
(*self).hash(hasher);
}
}
impl Hashable for alloy::primitives::Address {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.as_slice());
}
}
impl Hashable for alloy::primitives::U256 {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.to_be_bytes::<32>());
}
}
impl Hashable for chrono::Duration {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.num_seconds().to_be_bytes());
}
}
impl Hashable for chrono::DateTime<chrono::Utc> {
fn hash<H: Digest>(&self, hasher: &mut H) {
hasher.update(self.timestamp_millis().to_be_bytes());
}
}

View File

@@ -0,0 +1,298 @@
use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl;
use kameo::{actor::ActorRef, prelude::Spawn};
use sha2::Digest;
use crate::{
actors::keyholder::{Bootstrap, KeyHolder},
db::{self, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
};
use super::hashing::Hashable;
use super::{
Error, Integrable, check_entity_attestation, lookup_verified, lookup_verified_from_query,
sign_entity, verify_entity,
};
#[derive(Clone, Debug)]
struct DummyEntity {
payload_version: i32,
payload: Vec<u8>,
}
impl Hashable for DummyEntity {
fn hash<H: Digest>(&self, hasher: &mut H) {
self.payload_version.hash(hasher);
self.payload.hash(hasher);
}
}
impl Integrable for DummyEntity {
const KIND: &'static str = "dummy_entity";
}
async fn bootstrapped_keyholder(db: &db::DatabasePool) -> ActorRef<KeyHolder> {
let actor = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
actor
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
})
.await
.unwrap();
actor
}
#[tokio::test]
async fn sign_writes_envelope_and_verify_passes() {
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-7";
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
.await
.unwrap()
.drop_verification_provenance();
let count: i64 = schema::integrity_envelope::table
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
.filter(schema::integrity_envelope::entity_id.eq(ENTITY_ID))
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(count, 1, "envelope row must be created exactly once");
let _ = check_entity_attestation(&mut conn, &keyholder, &entity, ENTITY_ID)
.await
.unwrap();
}
#[tokio::test]
async fn tampered_mac_fails_verification() {
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-11";
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
.await
.unwrap()
.drop_verification_provenance();
diesel::update(schema::integrity_envelope::table)
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
.filter(schema::integrity_envelope::entity_id.eq(ENTITY_ID))
.set(schema::integrity_envelope::mac.eq(vec![0u8; 32]))
.execute(&mut conn)
.await
.unwrap();
let err = check_entity_attestation(&mut conn, &keyholder, &entity, ENTITY_ID)
.await
.unwrap_err();
assert!(matches!(err, Error::MacMismatch { .. }));
}
#[tokio::test]
async fn changed_payload_fails_verification() {
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-21";
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
.await
.unwrap()
.drop_verification_provenance();
let tampered = DummyEntity {
payload: b"payload-v1-but-tampered".to_vec(),
..entity
};
let err = check_entity_attestation(&mut conn, &keyholder, &tampered, ENTITY_ID)
.await
.unwrap_err();
assert!(matches!(err, Error::MacMismatch { .. }));
}
#[tokio::test]
async fn strict_verify_fails_closed_while_sealed() {
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: &[u8] = b"entity-id-41";
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
.await
.unwrap()
.drop_verification_provenance();
drop(keyholder);
let sealed_keyholder = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
let err = verify_entity(&mut conn, &sealed_keyholder, &entity, ENTITY_ID)
.await
.unwrap_err();
assert!(matches!(
err,
Error::Keyholder(crate::actors::keyholder::Error::NotBootstrapped)
));
let err = lookup_verified(&mut conn, &sealed_keyholder, ENTITY_ID, |_| async {
Ok::<_, db::DatabaseError>(DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
})
})
.await
.unwrap_err();
assert!(matches!(
err,
Error::Keyholder(crate::actors::keyholder::Error::NotBootstrapped)
));
}
#[tokio::test]
async fn lookup_verified_supports_loaded_aggregate() {
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: i32 = 77;
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
.await
.unwrap()
.drop_verification_provenance();
let verified = lookup_verified(&mut conn, &keyholder, ENTITY_ID, |_| async {
Ok::<_, db::DatabaseError>(DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
})
})
.await
.unwrap();
assert_eq!(verified.entity.payload, b"payload-v1".to_vec());
}
#[tokio::test]
async fn extension_trait_lookup_verified_required_works() {
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: i32 = 79;
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
.await
.unwrap()
.drop_verification_provenance();
let verified = lookup_verified(&mut conn, &keyholder, ENTITY_ID, |_| {
Box::pin(async {
Ok::<_, db::DatabaseError>(DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
})
})
})
.await
.unwrap();
assert_eq!(verified.entity.payload, b"payload-v1".to_vec());
}
#[tokio::test]
async fn lookup_verified_from_query_helpers_work() {
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
const ENTITY_ID: i32 = 80;
let entity = DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity, ENTITY_ID)
.await
.unwrap()
.drop_verification_provenance();
let verified = lookup_verified_from_query(&mut conn, &keyholder, |_| {
Box::pin(async {
Ok::<_, db::DatabaseError>((
ENTITY_ID,
DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
},
))
})
})
.await
.unwrap();
assert_eq!(verified.entity.payload, b"payload-v1".to_vec());
drop(keyholder);
let sealed_keyholder = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
let err = lookup_verified_from_query(&mut conn, &sealed_keyholder, |_| {
Box::pin(async {
Ok::<_, db::DatabaseError>((
ENTITY_ID,
DummyEntity {
payload_version: 1,
payload: b"payload-v1".to_vec(),
},
))
})
})
.await
.unwrap_err();
assert!(matches!(
err,
Error::Keyholder(crate::actors::keyholder::Error::NotBootstrapped)
));
}

View File

@@ -0,0 +1,593 @@
use std::ops::Deref;
use super::Integrable;
mod private {
pub trait Sealed {}
}
/// Marker trait for type-level verification provenance.
///
/// This trait is intentionally sealed so external code cannot invent arbitrary
/// provenance tags and bypass the intended type-level guarantees.
pub trait VerificationOrigin: private::Sealed {
type Origin: VerificationOrigin;
}
/// Root provenance marker for values directly produced by integrity APIs.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub struct Root;
/// Nested provenance marker carrying the source integrable type and previous
/// provenance marker in the chain.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Nested<From, P: VerificationOrigin = Root>(core::marker::PhantomData<(From, P)>);
impl private::Sealed for Root {}
impl VerificationOrigin for Root {
type Origin = Self;
}
impl<T, P: VerificationOrigin> private::Sealed for Nested<T, P> {}
impl<T, P: VerificationOrigin> VerificationOrigin for Nested<T, P> {
type Origin = P;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(transparent)]
#[must_use = "Verified<T> is a proof-bearing wrapper; use self.drop_verification_provenance() to explicitly discard integrity provenance when needed"]
pub struct Verified<T, O: VerificationOrigin = Root> {
inner: T,
origin: core::marker::PhantomData<O>,
}
impl<T, O: VerificationOrigin> AsRef<Verified<T, O>> for Verified<&T, O> {
fn as_ref(&self) -> &Verified<T, O> {
// SAFETY: `Verified<T>` is `#[repr(transparent)]` over `T`, so `&T`
// and `&Verified<T>` have identical layout.
unsafe { reinterpret_layout_ref::<T, Verified<T, O>>(self.inner) }
}
}
impl<T, U: Integrable, O: VerificationOrigin> Deref for Verified<T, Nested<U, O>> {
type Target = Verified<T, O::Origin>;
fn deref(&self) -> &Self::Target {
// SAFETY: `Verified<T, Nested<U, O>>` is `#[repr(transparent)]` over `T`, so `&Verified<T, Nested<U, O>>`
// and `&Nested<U, O>` have identical layout.
unsafe { reinterpret_layout_ref::<Self, Verified<T, O::Origin>>(self) }
}
}
impl<T> Deref for Verified<T, Root> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.inner
}
}
impl<T, O: VerificationOrigin> Verified<T, O> {
/// Unwraps the verified value, discarding the integrity provenance.
///
/// The name is intentionally verbose — call sites where provenance is
/// dropped should be easy to find and audit.
pub fn drop_verification_provenance(self) -> T {
self.inner
}
/// Downgrades the origin provenance to any lower nestedness level,
/// e.g. `Verified<T, Nested<Other>>` to `Verified<T, Root>`.
pub fn unqualify_origin<Target: VerificationOrigin>(self) -> Verified<T, Target>
where
O: VerificationOrigin<Origin = Target>,
{
Verified {
inner: self.inner,
origin: core::marker::PhantomData,
}
}
/// Constructs a `Verified<T>` by wrapping a `T`.
pub(super) fn new(value: T) -> Self {
Self {
inner: value,
origin: core::marker::PhantomData,
}
}
/// Constructs a `Verified<T>` from a raw value without performing any
/// integrity check. Only available in test builds; use the integrity
/// module's functions to obtain a `Verified<T>` in production code.
#[cfg(test)]
pub(crate) fn new_unchecked(value: T) -> Self {
Self {
inner: value,
origin: core::marker::PhantomData,
}
}
/// Reinterprets `&T` as `&Verified<T>`.
#[allow(dead_code)]
pub(super) fn from_ref(from: &T) -> &Self {
// SAFETY: `Self` is `#[repr(transparent)]` over `T`.
unsafe { reinterpret_layout_ref::<T, Self>(from) }
}
}
/// Bit-copies `value: From` into a `To`, suppressing the source destructor so
/// the destination owns the bytes.
///
/// # Safety
///
/// The caller must guarantee that `From` and `To` have identical in-memory
/// layout — the raw bytes that encode a valid `From` must also encode a valid
/// `To`.
///
/// A `union` is used instead of [`std::mem::transmute`] because `transmute`
/// rejects generic source/destination types at the call site even when their
/// sizes are provably equal at monomorphization time.
#[allow(dead_code)]
#[inline]
pub const unsafe fn reinterpret_layout<From, To>(value: From) -> To {
const {
assert!(
::std::mem::size_of::<From>() == ::std::mem::size_of::<To>(),
"reinterpret_layout: source and destination must have identical size"
);
assert!(
::std::mem::align_of::<From>() == ::std::mem::align_of::<To>(),
"reinterpret_layout: source and destination must have identical alignment"
);
}
union Reinterpret<A, B> {
from: ::std::mem::ManuallyDrop<A>,
to: ::std::mem::ManuallyDrop<B>,
}
// SAFETY: caller guarantees layout equivalence (see fn docs). The union
// write-read copies the raw bytes of `value` into a `To` slot, and
// `ManuallyDrop` on the source side suppresses its destructor so the
// destination owns the bytes unambiguously — no double-drop is possible.
unsafe {
::std::mem::ManuallyDrop::into_inner(
Reinterpret {
from: ::std::mem::ManuallyDrop::new(value),
}
.to,
)
}
}
/// Reinterprets `&From` as `&To` via a layout-preserving pointer cast.
///
/// # Safety
///
/// Same invariants as [`reinterpret_layout`].
#[inline]
pub const unsafe fn reinterpret_layout_ref<From, To>(value: &From) -> &To {
const {
assert!(
::std::mem::size_of::<From>() == ::std::mem::size_of::<To>(),
"reinterpret_layout_ref: source and destination must have identical size"
);
assert!(
::std::mem::align_of::<From>() == ::std::mem::align_of::<To>(),
"reinterpret_layout_ref: source and destination must have identical alignment"
);
}
// SAFETY: caller guarantees layout equivalence (see fn docs). A reference
// cast between identically-laid-out types produces a reference with the
// same address and lifetime, which is sound.
unsafe { &*(value as *const From as *const To) }
}
/// Implemented on `Verified<T>` by [`VerifiedFields!`], exposing the field-wise counterpart.
///
/// ## Disclaimer
/// Do not implement this trait manually. It is intended to be implemented only
/// by the `VerifiedFields!` macro, which generates the necessary layout
/// guarantees for sound pointer casts.
///
/// ## Soundness
/// When [`verify_entity`][crate::crypto::integrity::verify_entity] attests an
/// entity, it returns `Verified<T>` — an aggregate proof over the whole value.
/// This trait converts that wrapper into `Counterpart` (e.g.
/// `VerifiedMyStruct`), where every field is individually wrapped in
/// [`Verified`], allowing verified data to flow into functions that require
/// `Verified<FieldType>` without re-verifying.
///
/// ## Safety
/// The conversion is a zero-cost reinterpretation — no copying (beyond a
/// bitwise move in the owned variant) or HMAC work occurs. Soundness rests on
/// identical memory layout between `Verified<T>` and `Counterpart`:
///
/// - `T` carries `#[repr(C)]` (enforced by `@require_repr` in the macro).
/// - `T` does **not** carry `packed` (enforced by `@reject_packed`).
/// - `Counterpart` also carries `#[repr(C)]`, with the same fields in the same
/// order.
/// - Each `Verified<F>` field is `#[repr(transparent)]` over `F`, so its size
/// and alignment match `F` exactly.
/// - `Verified<T>` itself is `#[repr(transparent)]` over `T`.
///
/// As an additional machine-checked guard, [`reinterpret_layout`] and
/// [`reinterpret_layout_ref`] assert size/align equality of the two types at
/// monomorphization time.
///
/// The trait is implemented directly on `Verified<T>` (not on `T`), so no
/// `Deref`-coercion or auto-ref stripping is needed at call sites — the impl
/// is unambiguous.
pub trait VerifiedFieldsAccessor {
/// The field-wise verified counterpart, e.g. `VerifiedMyStruct`.
type Counterpart;
/// Reinterprets `&self` as `&Counterpart` via a layout-preserving pointer cast.
///
/// No data is copied and no re-verification occurs. The returned reference
/// borrows from `self` and has the same lifetime.
fn inherit_ref(&self) -> &Self::Counterpart;
/// Consumes `self` and returns `Counterpart` via a layout-preserving
/// bitwise move.
///
/// The original `Verified<T>` is moved without running its destructor
/// (there is none — `Verified` is a transparent wrapper with no heap
/// allocation), and the returned counterpart owns the original bytes. No
/// re-verification occurs.
fn inherit(self) -> Self::Counterpart;
}
// todo! rewrite macro_rules to derive crate
#[macro_export]
macro_rules! VerifiedFields {
// --- Entry point (no source generics) ---
(
$(#$attr:tt)*
$vis:vis struct $name:ident
{
$(
$field_vis:vis $field_name:ident : $field_ty:ty
),* $(,)?
}
) => {
// Attribute-list checks run in isolation — they only receive the attrs,
// not the struct body.
$crate::VerifiedFields!(@require_repr [$(#$attr)*]);
$crate::VerifiedFields!(@reject_packed [$(#$attr)*]);
paste::paste! {
#[doc = concat!(
"Field-wise verified counterpart of [`", stringify!($name), "`]."
)]
//
// `#[repr(C)]` is required for the pointer casts in `inherit_ref`
// and `inherit` to be sound. Both the source struct (enforced by
// `@require_repr`) and this counterpart carry `#[repr(C)]`, which
// guarantees matching field offsets. Combined with each
// `Verified<F>` being `#[repr(transparent)]` over `F`, the two
// structs have identical memory layout.
//
// `#[repr(transparent)]` is not usable here because it only permits
// a single non-ZST field; multi-field structs would fail to compile.
#[repr(C)]
$vis struct [<Verified $name>]<P: $crate::crypto::integrity::v1::verified::VerificationOrigin>
{
$(
$field_vis $field_name : $crate::crypto::integrity::Verified<$field_ty, P>
),*
}
impl<P: $crate::crypto::integrity::v1::verified::VerificationOrigin>
$crate::crypto::integrity::v1::verified::VerifiedFieldsAccessor
for $crate::crypto::integrity::Verified<$name, P>
{
type Counterpart = [<Verified $name>]<P>;
fn inherit_ref(&self) -> &Self::Counterpart {
// SAFETY: `Self` is `Verified<T>` (transparent over
// `T #[repr(C)]`) and `Self::Counterpart` is `#[repr(C)]`
// with the same fields in the same order, each wrapped in
// a `#[repr(transparent)]` `Verified<F>`. The two types
// therefore have identical memory layout, which
// `reinterpret_layout_ref` re-checks as size/align
// equality at monomorphization.
unsafe {
$crate::crypto::integrity::v1::verified::reinterpret_layout_ref::<
Self,
Self::Counterpart,
>(self)
}
}
fn inherit(self) -> Self::Counterpart {
// SAFETY: identical layout — see `inherit_ref`. The owned
// helper additionally suppresses the source destructor so
// the returned counterpart owns the original bytes (no
// double-drop is possible).
unsafe {
$crate::crypto::integrity::v1::verified::reinterpret_layout::<
Self,
Self::Counterpart,
>(self)
}
}
}
}
};
// --- Entry point (source has generics) ---
(
$(#$attr:tt)*
$vis:vis struct $name:ident <$($gen:tt),*>
{
$(
$field_vis:vis $field_name:ident : $field_ty:ty
),* $(,)?
}
) => {
// Attribute-list checks run in isolation — they only receive the attrs,
// not the struct body.
$crate::VerifiedFields!(@require_repr [$(#$attr)*]);
$crate::VerifiedFields!(@reject_packed [$(#$attr)*]);
paste::paste! {
#[doc = concat!(
"Field-wise verified counterpart of [`", stringify!($name), "`]."
)]
//
// `#[repr(C)]` is required for the pointer casts in `inherit_ref`
// and `inherit` to be sound. Both the source struct (enforced by
// `@require_repr`) and this counterpart carry `#[repr(C)]`, which
// guarantees matching field offsets. Combined with each
// `Verified<F>` being `#[repr(transparent)]` over `F`, the two
// structs have identical memory layout.
//
// `#[repr(transparent)]` is not usable here because it only permits
// a single non-ZST field; multi-field structs would fail to compile.
#[repr(C)]
$vis struct [<Verified $name>]<$($gen),*, P: $crate::crypto::integrity::v1::verified::VerificationOrigin>
{
$(
$field_vis $field_name : $crate::crypto::integrity::Verified<$field_ty, P>
),*
}
impl<$($gen),*, P: $crate::crypto::integrity::v1::verified::VerificationOrigin>
$crate::crypto::integrity::v1::verified::VerifiedFieldsAccessor
for $crate::crypto::integrity::Verified<$name<$($gen),*>, P>
{
type Counterpart = [<Verified $name>]<$($gen),*, P>;
fn inherit_ref(&self) -> &Self::Counterpart {
// SAFETY: `Self` is `Verified<T>` (transparent over
// `T #[repr(C)]`) and `Self::Counterpart` is `#[repr(C)]`
// with the same fields in the same order, each wrapped in
// a `#[repr(transparent)]` `Verified<F>`. The two types
// therefore have identical memory layout, which
// `reinterpret_layout_ref` re-checks as size/align
// equality at monomorphization.
unsafe {
$crate::crypto::integrity::v1::verified::reinterpret_layout_ref::<
Self,
Self::Counterpart,
>(self)
}
}
fn inherit(self) -> Self::Counterpart {
// SAFETY: identical layout — see `inherit_ref`. The owned
// helper additionally suppresses the source destructor so
// the returned counterpart owns the original bytes (no
// double-drop is possible).
unsafe {
$crate::crypto::integrity::v1::verified::reinterpret_layout::<
Self,
Self::Counterpart,
>(self)
}
}
}
}
};
// --- @require_repr: ensure `#[repr(C)]` appears in the attribute list ---
(@require_repr [#[repr(C)] $($rest:tt)*]) => {};
(@require_repr [#$other:tt $($rest:tt)*]) => {
$crate::VerifiedFields!(@require_repr [$($rest)*]);
};
(@require_repr []) => {
::std::compile_error!(
"VerifiedFields requires `#[repr(C)]` on the struct to guarantee field layout"
);
};
// --- @reject_packed: walk attrs and reject any `#[repr(..., packed, ...)]`.
//
// Without this, a packed struct would still fail at monomorphization via
// the const assertions inside the `reinterpret_layout*` helpers, but the
// diagnostic would be much harder to read. `align(N)` is *not* rejected
// here because const assertions catch alignment mismatches cleanly, and
// forbidding it would be unnecessarily restrictive.
(@reject_packed [#[repr($($inner:tt)*)] $($rest:tt)*]) => {
$crate::VerifiedFields!(@reject_packed_inner [$($inner)*]);
$crate::VerifiedFields!(@reject_packed [$($rest)*]);
};
(@reject_packed [#$other:tt $($rest:tt)*]) => {
$crate::VerifiedFields!(@reject_packed [$($rest)*]);
};
(@reject_packed []) => {};
(@reject_packed_inner [packed $($rest:tt)*]) => {
::std::compile_error!(
"VerifiedFields does not support packed layouts; the generated \
counterpart would not share layout with the source struct"
);
};
(@reject_packed_inner [$first:tt $($rest:tt)*]) => {
$crate::VerifiedFields!(@reject_packed_inner [$($rest)*]);
};
(@reject_packed_inner []) => {};
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(VerifiedFields!)]
#[repr(C)]
#[derive(Default, Clone)]
pub struct MyStruct<T> {
pub field1: String,
pub field2: T,
}
fn verify<T>(t: T) -> Verified<T> {
Verified {
inner: t,
origin: core::marker::PhantomData,
}
}
// --- inherit_ref ---
// Verifies that `inherit_ref` returns a reference to the same memory
// address, confirming that no copy is made and the cast is purely a
// reinterpretation.
#[test]
fn inherit_ref_is_same_address() {
let v = verify(MyStruct {
field1: "hello".into(),
field2: 42u32,
});
let fields = v.inherit_ref();
assert_eq!(
&v as *const _ as *const u8, fields as *const _ as *const u8,
"inherit_ref must return a pointer to the same memory, not a copy"
);
}
// Verifies that field values are correctly accessible after `inherit_ref`.
#[test]
fn inherit_ref_field_values() {
let v = verify(MyStruct {
field1: "hello".into(),
field2: 99u32,
});
let fields = v.inherit_ref();
assert_eq!(*fields.field1, "hello");
assert_eq!(*fields.field2, 99u32);
}
// Verifies that casting the counterpart back to `Verified<T>` via a raw
// pointer lands on the original address — confirms the round-trip is a
// pure reinterpretation.
#[test]
fn inherit_ref_cast_roundtrip() {
let v = verify(MyStruct {
field1: "x".into(),
field2: 7u32,
});
let fields: &VerifiedMyStruct<u32, Root> = v.inherit_ref();
let back_ptr =
fields as *const VerifiedMyStruct<u32, Root> as *const Verified<MyStruct<u32>>;
assert_eq!(
back_ptr as *const u8, &v as *const _ as *const u8,
"cast of counterpart must point back to the same Verified<T>"
);
}
// ZST fields must still produce a counterpart with identical layout — the
// const asserts in `reinterpret_layout_ref` guard this at monomorphization.
#[test]
fn inherit_ref_with_zst_field() {
#[derive(VerifiedFields!)]
#[repr(C)]
struct WithZst {
pub unit: (),
pub val: u64,
}
let v = Verified::<WithZst>::new_unchecked(WithZst { unit: (), val: 777 });
let fields = v.inherit_ref();
assert_eq!(*fields.val, 777);
assert_eq!(*fields.unit, ());
}
// --- inherit ---
// Verifies that `inherit` preserves field values in the owned counterpart.
#[test]
fn inherit_field_values() {
let v = verify(MyStruct {
field1: "world".into(),
field2: 1234u64,
});
let VerifiedMyStruct { field1, field2 } = v.inherit();
assert_eq!(*field1, "world");
assert_eq!(*field2, 1234u64);
}
// Verifies that `inherit` does not double-drop the inner value.
// If `ManuallyDrop` handling is wrong, running under Miri or with a drop
// counter catches a double-free.
#[test]
fn inherit_no_double_drop() {
use std::sync::atomic::{AtomicUsize, Ordering};
static DROP_COUNT: AtomicUsize = AtomicUsize::new(0);
struct DropCounter;
impl Drop for DropCounter {
fn drop(&mut self) {
DROP_COUNT.fetch_add(1, Ordering::Relaxed);
}
}
#[derive(VerifiedFields!)]
#[repr(C)]
struct WithDrop {
pub val: DropCounter,
}
DROP_COUNT.store(0, Ordering::Relaxed);
{
let v = Verified::<WithDrop>::new_unchecked(WithDrop { val: DropCounter });
let _ = v.inherit();
}
assert_eq!(
DROP_COUNT.load(Ordering::Relaxed),
1,
"DropCounter must be dropped exactly once"
);
}
// --- Verified::from_ref ---
#[test]
fn from_ref_is_same_address() {
let val = 42u32;
let verified: &Verified<u32> = Verified::from_ref(&val);
assert_eq!(
&val as *const u32 as *const u8, verified as *const _ as *const u8,
"from_ref must alias the original reference, not copy the value"
);
}
#[test]
fn from_ref_value_preserved() {
let val = String::from("test");
let verified: &Verified<String> = Verified::from_ref(&val);
assert_eq!(**verified, "test");
}
// --- AsRef<Verified<T>> for Verified<&T> ---
#[test]
fn verified_ref_as_ref_is_same_address() {
let val = 99u32;
let vref: Verified<&u32> = Verified::new_unchecked(&val);
let v: &Verified<u32> = vref.as_ref();
assert_eq!(
&val as *const u32 as *const u8, v as *const _ as *const u8,
"AsRef<Verified<T>> for Verified<&T> must alias the referent, not copy it"
);
}
}

View File

@@ -102,11 +102,21 @@ impl KeyCell {
}
}
/// User password might be of different length, have not enough entropy, etc...
/// Derive a fixed-length key from the password using Argon2id, which is designed for password hashing and key derivation.
pub fn derive_key(mut password: SafeCell<Vec<u8>>, salt: &Salt) -> KeyCell {
let params = {
#[cfg(debug_assertions)]
{
argon2::Params::new(8, 1, 1, None).unwrap()
}
#[cfg(not(debug_assertions))]
{
argon2::Params::new(262_144, 3, 4, None).unwrap()
}
};
#[allow(clippy::unwrap_used)]
let params = argon2::Params::new(262_144, 3, 4, None).unwrap();
let hasher = Argon2::new(Algorithm::Argon2id, argon2::Version::V0x13, params);
let mut key = SafeCell::new(Key::default());
password.read_inline(|password_source| {

View File

@@ -133,6 +133,7 @@ pub async fn create_pool(url: Option<&str>) -> Result<DatabasePool, DatabaseSetu
Ok(pool)
}
#[mutants::skip]
pub async fn create_test_pool() -> DatabasePool {
use rand::distr::{Alphanumeric, SampleString as _};

View File

@@ -242,7 +242,6 @@ pub struct UseragentClient {
pub id: i32,
pub nonce: i32,
pub public_key: Vec<u8>,
pub pubkey_integrity_tag: Option<Vec<u8>>,
pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp,
pub key_type: KeyType,

View File

@@ -191,7 +191,6 @@ diesel::table! {
id -> Integer,
nonce -> Integer,
public_key -> Binary,
pubkey_integrity_tag -> Nullable<Binary>,
key_type -> Integer,
created_at -> Integer,
updated_at -> Integer,

View File

@@ -12,6 +12,7 @@ use kameo::actor::ActorRef;
use crate::{
actors::keyholder::KeyHolder,
crypto::integrity::{self, Verified, VerifiedEntity, verified::VerifiedFieldsAccessor},
db::{
self, DatabaseError,
models::{
@@ -20,11 +21,10 @@ use crate::{
schema::{self, evm_transaction_log},
},
evm::policies::{
DatabaseID, EvalContext, EvalViolation, FullGrant, Grant, Policy, SharedGrantSettings,
SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer,
CombinedSettings, DatabaseID, EvalContext, EvalViolation, Grant, Policy,
SharedGrantSettings, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer,
token_transfers::TokenTransfer,
},
integrity,
};
pub mod policies;
@@ -41,7 +41,6 @@ pub enum PolicyError {
NoMatchingGrant,
#[error("Integrity error: {0}")]
#[diagnostic(code(arbiter_server::evm::policy_error::integrity))]
Integrity(#[from] integrity::Error),
}
@@ -64,6 +63,15 @@ pub enum AnalyzeError {
UnsupportedTransactionType,
}
#[derive(Debug, thiserror::Error)]
pub enum ListError {
#[error("Database error")]
Database(#[from] crate::db::DatabaseError),
#[error("Integrity verification failed for grant")]
Integrity(#[from] integrity::Error),
}
/// Controls whether a transaction should be executed or only validated
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunKind {
@@ -82,6 +90,14 @@ async fn check_shared_constraints(
let mut violations = Vec::new();
let now = Utc::now();
if shared.chain != context.chain {
violations.push(EvalViolation::MismatchingChainId {
expected: shared.chain,
actual: context.chain,
});
return Ok(violations);
}
// Validity window
if shared.valid_from.is_some_and(|t| now < t) || shared.valid_until.is_some_and(|t| now > t) {
violations.push(EvalViolation::InvalidTime);
@@ -137,21 +153,46 @@ impl Engine {
{
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let grant = P::try_find_grant(&context, &mut conn)
let verified_settings =
match integrity::lookup_verified_from_query(&mut conn, &self.keyholder, |conn| {
let context = context.clone();
Box::pin(async move {
let grant = P::try_find_grant(&context, conn)
.await
.map_err(DatabaseError::from)?
.ok_or_else(|| DatabaseError::from(diesel::result::Error::NotFound))?;
Ok::<_, DatabaseError>((grant.common_settings_id, grant.settings))
})
})
.await
{
Ok(verified) => verified,
Err(integrity::Error::Database(DatabaseError::Connection(
diesel::result::Error::NotFound,
))) => return Err(PolicyError::NoMatchingGrant),
Err(err) => return Err(PolicyError::Integrity(err)),
};
let mut grant = P::try_find_grant(&context, &mut conn)
.await
.map_err(DatabaseError::from)?
.ok_or(PolicyError::NoMatchingGrant)?;
let signed_grant = integrity::evm::SignedEvmGrant::from_active_grant(&Grant {
id: grant.id,
shared_grant_id: grant.shared_grant_id,
shared: grant.shared.clone(),
settings: grant.settings.clone().into(),
});
integrity::verify_entity(&mut conn, &self.keyholder, &signed_grant).await?;
// IMPORTANT: policy evaluation uses extra non-integrity fields from Grant
// (e.g., per-policy ids), so we currently reload Grant after the query-native
// integrity check over canonicalized settings.
grant.settings = verified_settings
.inherit()
.entity
.drop_verification_provenance();
let mut violations =
check_shared_constraints(&context, &grant.shared, grant.shared_grant_id, &mut conn)
let mut violations = check_shared_constraints(
&context,
&grant.settings.shared,
grant.common_settings_id,
&mut conn,
)
.await
.map_err(DatabaseError::from)?;
violations.extend(
@@ -169,7 +210,7 @@ impl Engine {
Box::pin(async move {
let log_id: i32 = insert_into(evm_transaction_log::table)
.values(&NewEvmTransactionLog {
grant_id: grant.shared_grant_id,
grant_id: grant.common_settings_id,
wallet_access_id: context.target.id,
chain_id: context.chain as i32,
eth_value: utils::u256_to_bytes(context.value).to_vec(),
@@ -199,8 +240,8 @@ impl Engine {
pub async fn create_grant<P: Policy>(
&self,
full_grant: FullGrant<P::Settings>,
) -> Result<i32, DatabaseError>
full_grant: CombinedSettings<P::Settings>,
) -> Result<Verified<i32>, DatabaseError>
where
P::Settings: Clone,
{
@@ -214,25 +255,25 @@ impl Engine {
let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
.values(&NewEvmBasicGrant {
chain_id: full_grant.basic.chain as i32,
wallet_access_id: full_grant.basic.wallet_access_id,
valid_from: full_grant.basic.valid_from.map(SqliteTimestamp),
valid_until: full_grant.basic.valid_until.map(SqliteTimestamp),
chain_id: full_grant.shared.chain as i32,
wallet_access_id: full_grant.shared.wallet_access_id,
valid_from: full_grant.shared.valid_from.map(SqliteTimestamp),
valid_until: full_grant.shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: full_grant
.basic
.shared
.max_gas_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: full_grant
.basic
.shared
.max_priority_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: full_grant
.basic
.shared
.rate_limit
.as_ref()
.map(|rl| rl.count as i32),
rate_limit_window_secs: full_grant
.basic
.shared
.rate_limit
.as_ref()
.map(|rl| rl.window.num_seconds() as i32),
@@ -244,62 +285,65 @@ impl Engine {
P::create_grant(&basic_grant, &full_grant.specific, conn).await?;
let signed_grant = integrity::evm::SignedEvmGrant {
basic_grant_id: basic_grant.id,
shared: full_grant.basic.clone(),
specific: full_grant.specific.clone().into(),
revoked_at: basic_grant.revoked_at.map(Into::into),
};
integrity::sign_entity(conn, &keyholder, &signed_grant)
let verified_entity_id =
integrity::sign_entity(conn, &keyholder, &full_grant, basic_grant.id)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
QueryResult::Ok(basic_grant.id)
QueryResult::Ok(verified_entity_id)
})
})
.await?;
Ok(id)
Ok(id.unqualify_origin())
}
pub async fn list_all_grants(&self) -> Result<Vec<Grant<SpecificGrant>>, DatabaseError> {
let mut conn = self.db.get().await?;
async fn list_one_kind<Kind: Policy, Y>(
&self,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> Result<Vec<Grant<Y>>, ListError>
where
Y: From<Kind::Settings>,
{
let all_grants = Kind::find_all_grants(conn)
.await
.map_err(DatabaseError::from)?;
let mut verified_grants = Vec::with_capacity(all_grants.len());
// Verify integrity of all grants before returning any results.
for grant in all_grants {
let VerifiedEntity {
entity: verified_settings,
entity_id: _,
} = integrity::verify_entity(
conn,
&self.keyholder,
grant.settings,
grant.common_settings_id,
)
.await?
.inherit();
verified_grants.push(Grant {
id: grant.id,
common_settings_id: grant.common_settings_id,
settings: verified_settings
.drop_verification_provenance()
.generalize(),
});
}
Ok(verified_grants)
}
pub async fn list_all_grants(&self) -> Result<Vec<Grant<SpecificGrant>>, ListError> {
let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let mut grants: Vec<Grant<SpecificGrant>> = Vec::new();
grants.extend(
EtherTransfer::find_all_grants(&mut conn)
.await?
.into_iter()
.map(|g| Grant {
id: g.id,
shared_grant_id: g.shared_grant_id,
shared: g.shared,
settings: SpecificGrant::EtherTransfer(g.settings),
}),
);
grants.extend(
TokenTransfer::find_all_grants(&mut conn)
.await?
.into_iter()
.map(|g| Grant {
id: g.id,
shared_grant_id: g.shared_grant_id,
shared: g.shared,
settings: SpecificGrant::TokenTransfer(g.settings),
}),
);
for grant in &grants {
let signed = integrity::evm::SignedEvmGrant::from_active_grant(grant);
integrity::verify_entity(&mut conn, &self.keyholder, &signed)
.await
.map_err(|err| match err {
integrity::Error::Database(db_err) => db_err,
_ => DatabaseError::Connection(diesel::result::Error::RollbackTransaction),
})?;
}
grants.extend(self.list_one_kind::<EtherTransfer, _>(&mut conn).await?);
grants.extend(self.list_one_kind::<TokenTransfer, _>(&mut conn).await?);
Ok(grants)
}
@@ -345,3 +389,255 @@ impl Engine {
Err(VetError::UnsupportedTransactionType)
}
}
#[cfg(test)]
mod tests {
use alloy::primitives::{Address, Bytes, U256, address};
use chrono::{Duration, Utc};
use diesel::{SelectableHelper, insert_into};
use diesel_async::RunQueryDsl;
use rstest::rstest;
use crate::db::{
self, DatabaseConnection,
models::{
EvmBasicGrant, EvmWalletAccess, NewEvmBasicGrant, NewEvmTransactionLog, SqliteTimestamp,
},
schema::{evm_basic_grant, evm_transaction_log},
};
use crate::evm::policies::{
EvalContext, EvalViolation, SharedGrantSettings, TransactionRateLimit,
};
use super::check_shared_constraints;
const WALLET_ACCESS_ID: i32 = 1;
const CHAIN_ID: u64 = 1;
const RECIPIENT: Address = address!("1111111111111111111111111111111111111111");
fn context() -> EvalContext {
EvalContext {
target: EvmWalletAccess {
id: WALLET_ACCESS_ID,
wallet_id: 10,
client_id: 20,
created_at: SqliteTimestamp(Utc::now()),
},
chain: CHAIN_ID,
to: RECIPIENT,
value: U256::ZERO,
calldata: Bytes::new(),
max_fee_per_gas: 100,
max_priority_fee_per_gas: 10,
}
}
fn shared_settings() -> SharedGrantSettings {
SharedGrantSettings {
wallet_access_id: WALLET_ACCESS_ID,
chain: CHAIN_ID,
valid_from: None,
valid_until: None,
max_gas_fee_per_gas: None,
max_priority_fee_per_gas: None,
rate_limit: None,
}
}
async fn insert_basic_grant(
conn: &mut DatabaseConnection,
shared: &SharedGrantSettings,
) -> EvmBasicGrant {
insert_into(evm_basic_grant::table)
.values(NewEvmBasicGrant {
wallet_access_id: shared.wallet_access_id,
chain_id: shared.chain as i32,
valid_from: shared.valid_from.map(SqliteTimestamp),
valid_until: shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: shared
.max_gas_fee_per_gas
.map(|fee| super::utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: shared
.max_priority_fee_per_gas
.map(|fee| super::utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: shared.rate_limit.as_ref().map(|limit| limit.count as i32),
rate_limit_window_secs: shared
.rate_limit
.as_ref()
.map(|limit| limit.window.num_seconds() as i32),
revoked_at: None,
})
.returning(EvmBasicGrant::as_select())
.get_result(conn)
.await
.unwrap()
}
#[rstest]
#[case::matching_chain(CHAIN_ID, false)]
#[case::mismatching_chain(CHAIN_ID + 1, true)]
#[tokio::test]
async fn check_shared_constraints_enforces_chain_id(
#[case] context_chain: u64,
#[case] expect_mismatch: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let context = EvalContext {
chain: context_chain,
..context()
};
let violations = check_shared_constraints(&context, &shared_settings(), 999, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::MismatchingChainId { .. })),
expect_mismatch
);
if expect_mismatch {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
#[rstest]
#[case::valid_from_in_bounds(Some(Utc::now() - Duration::hours(1)), None, false)]
#[case::valid_from_out_of_bounds(Some(Utc::now() + Duration::hours(1)), None, true)]
#[case::valid_until_in_bounds(None, Some(Utc::now() + Duration::hours(1)), false)]
#[case::valid_until_out_of_bounds(None, Some(Utc::now() - Duration::hours(1)), true)]
#[tokio::test]
async fn check_shared_constraints_enforces_validity_window(
#[case] valid_from: Option<chrono::DateTime<Utc>>,
#[case] valid_until: Option<chrono::DateTime<Utc>>,
#[case] expect_invalid_time: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let shared = SharedGrantSettings {
valid_from,
valid_until,
..shared_settings()
};
let violations = check_shared_constraints(&context(), &shared, 999, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::InvalidTime)),
expect_invalid_time
);
if expect_invalid_time {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
#[rstest]
#[case::max_fee_within_limit(Some(U256::from(100u64)), None, 100, 10, false)]
#[case::max_fee_exceeded(Some(U256::from(99u64)), None, 100, 10, true)]
#[case::priority_fee_within_limit(None, Some(U256::from(10u64)), 100, 10, false)]
#[case::priority_fee_exceeded(None, Some(U256::from(9u64)), 100, 10, true)]
#[tokio::test]
async fn check_shared_constraints_enforces_gas_fee_caps(
#[case] max_gas_fee_per_gas: Option<U256>,
#[case] max_priority_fee_per_gas: Option<U256>,
#[case] actual_max_fee_per_gas: u128,
#[case] actual_max_priority_fee_per_gas: u128,
#[case] expect_gas_limit_violation: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let context = EvalContext {
max_fee_per_gas: actual_max_fee_per_gas,
max_priority_fee_per_gas: actual_max_priority_fee_per_gas,
..context()
};
let shared = SharedGrantSettings {
max_gas_fee_per_gas,
max_priority_fee_per_gas,
..shared_settings()
};
let violations = check_shared_constraints(&context, &shared, 999, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::GasLimitExceeded { .. })),
expect_gas_limit_violation
);
if expect_gas_limit_violation {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
#[rstest]
#[case::under_rate_limit(2, false)]
#[case::at_rate_limit(1, true)]
#[tokio::test]
async fn check_shared_constraints_enforces_rate_limit(
#[case] rate_limit_count: u32,
#[case] expect_rate_limit_violation: bool,
) {
let db = db::create_test_pool().await;
let mut conn = db.get().await.unwrap();
let shared = SharedGrantSettings {
rate_limit: Some(TransactionRateLimit {
count: rate_limit_count,
window: Duration::hours(1),
}),
..shared_settings()
};
let basic_grant = insert_basic_grant(&mut conn, &shared).await;
insert_into(evm_transaction_log::table)
.values(NewEvmTransactionLog {
grant_id: basic_grant.id,
wallet_access_id: WALLET_ACCESS_ID,
chain_id: CHAIN_ID as i32,
eth_value: super::utils::u256_to_bytes(U256::ZERO).to_vec(),
signed_at: SqliteTimestamp(Utc::now()),
})
.execute(&mut *conn)
.await
.unwrap();
let violations = check_shared_constraints(&context(), &shared, basic_grant.id, &mut *conn)
.await
.unwrap();
assert_eq!(
violations
.iter()
.any(|violation| matches!(violation, EvalViolation::RateLimitExceeded)),
expect_rate_limit_violation
);
if expect_rate_limit_violation {
assert_eq!(violations.len(), 1);
} else {
assert!(violations.is_empty());
}
}
}

View File

@@ -10,6 +10,7 @@ use diesel_async::{AsyncConnection, RunQueryDsl};
use thiserror::Error;
use crate::{
crypto::integrity::v1::Integrable,
db::models::{self, EvmBasicGrant, EvmWalletAccess},
evm::utils,
};
@@ -55,6 +56,9 @@ pub enum EvalViolation {
#[error("Transaction type is not allowed by this grant")]
InvalidTransactionType,
#[error("Mismatching chain ID")]
MismatchingChainId { expected: ChainId, actual: ChainId },
}
pub type DatabaseID = i32;
@@ -62,13 +66,12 @@ pub type DatabaseID = i32;
#[derive(Debug)]
pub struct Grant<PolicySettings> {
pub id: DatabaseID,
pub shared_grant_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods
pub shared: SharedGrantSettings,
pub settings: PolicySettings,
pub common_settings_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods
pub settings: CombinedSettings<PolicySettings>,
}
pub trait Policy: Sized {
type Settings: Send + Sync + 'static + Into<SpecificGrant>;
type Settings: Send + Sync + 'static + Into<SpecificGrant> + Integrable;
type Meaning: Display + std::fmt::Debug + Send + Sync + 'static + Into<SpecificMeaning>;
fn analyze(context: &EvalContext) -> Option<Self::Meaning>;
@@ -124,13 +127,13 @@ pub enum SpecificMeaning {
TokenTransfer(token_transfers::Meaning),
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct TransactionRateLimit {
pub count: u32,
pub window: Duration,
}
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct VolumeRateLimit {
pub max_volume: U256,
pub window: Duration,
@@ -197,7 +200,57 @@ pub enum SpecificGrant {
TokenTransfer(token_transfers::Settings),
}
pub struct FullGrant<PolicyGrant> {
pub basic: SharedGrantSettings,
#[derive(Debug, Clone)]
pub struct CombinedSettings<PolicyGrant> {
pub shared: SharedGrantSettings,
pub specific: PolicyGrant,
}
impl<P> CombinedSettings<P> {
pub fn generalize<Y: From<P>>(self) -> CombinedSettings<Y> {
CombinedSettings {
shared: self.shared,
specific: self.specific.into(),
}
}
}
impl<P: Integrable> Integrable for CombinedSettings<P> {
const KIND: &'static str = P::KIND;
const VERSION: i32 = P::VERSION;
}
use crate::crypto::integrity::hashing::Hashable;
impl Hashable for TransactionRateLimit {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.count.hash(hasher);
self.window.hash(hasher);
}
}
impl Hashable for VolumeRateLimit {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.max_volume.hash(hasher);
self.window.hash(hasher);
}
}
impl Hashable for SharedGrantSettings {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.wallet_access_id.hash(hasher);
self.chain.hash(hasher);
self.valid_from.hash(hasher);
self.valid_until.hash(hasher);
self.max_gas_fee_per_gas.hash(hasher);
self.max_priority_fee_per_gas.hash(hasher);
self.rate_limit.hash(hasher);
}
}
impl<P: Hashable> Hashable for CombinedSettings<P> {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.shared.hash(hasher);
self.specific.hash(hasher);
}
}

View File

@@ -8,13 +8,14 @@ use diesel::sqlite::Sqlite;
use diesel::{ExpressionMethods, JoinOnDsl, prelude::*};
use diesel_async::{AsyncConnection, RunQueryDsl};
use crate::crypto::integrity::v1::Integrable;
use crate::db::models::{
EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferLimit,
NewEvmEtherTransferLimit, SqliteTimestamp,
};
use crate::db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log};
use crate::evm::policies::{
Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
};
use crate::{
db::{
@@ -56,6 +57,18 @@ pub struct Settings {
pub target: Vec<Address>,
pub limit: VolumeRateLimit,
}
impl Integrable for Settings {
const KIND: &'static str = "EtherTransfer";
}
use crate::crypto::integrity::hashing::Hashable;
impl Hashable for Settings {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.target.hash(hasher);
self.limit.hash(hasher);
}
}
impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant {
@@ -95,17 +108,18 @@ async fn check_rate_limits(
db: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<EvalViolation>> {
let mut violations = Vec::new();
let window = grant.settings.limit.window;
let window = grant.settings.specific.limit.window;
let past_transaction = query_relevant_past_transaction(grant.id, window, db).await?;
let past_transaction =
query_relevant_past_transaction(grant.common_settings_id, window, db).await?;
let window_start = chrono::Utc::now() - grant.settings.limit.window;
let window_start = chrono::Utc::now() - grant.settings.specific.limit.window;
let prospective_cumulative_volume: U256 = past_transaction
.iter()
.filter(|(_, timestamp)| timestamp >= &window_start)
.fold(current_transfer_value, |acc, (value, _)| acc + *value);
if prospective_cumulative_volume > grant.settings.limit.max_volume {
if prospective_cumulative_volume > grant.settings.specific.limit.max_volume {
violations.push(EvalViolation::VolumetricLimitExceeded);
}
@@ -138,7 +152,7 @@ impl Policy for EtherTransfer {
let mut violations = Vec::new();
// Check if the target address is within the grant's allowed targets
if !grant.settings.target.contains(&meaning.to) {
if !grant.settings.specific.target.contains(&meaning.to) {
violations.push(EvalViolation::InvalidTarget { target: meaning.to });
}
@@ -236,20 +250,21 @@ impl Policy for EtherTransfer {
})
.collect();
let settings = Settings {
target: targets,
limit: VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&limit.max_volume)
.map_err(|err| diesel::result::Error::DeserializationError(Box::new(err)))?,
window: chrono::Duration::seconds(limit.window_secs as i64),
},
};
Ok(Some(Grant {
id: grant.id,
shared_grant_id: grant.basic_grant_id,
common_settings_id: grant.basic_grant_id,
settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic_grant)?,
settings,
specific: Settings {
target: targets,
limit: VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err(|err| {
diesel::result::Error::DeserializationError(Box::new(err))
})?,
window: chrono::Duration::seconds(limit.window_secs as i64),
},
},
},
}))
}
@@ -327,9 +342,10 @@ impl Policy for EtherTransfer {
Ok(Grant {
id: specific.id,
shared_grant_id: specific.basic_grant_id,
common_settings_id: specific.basic_grant_id,
settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic)?,
settings: Settings {
specific: Settings {
target: targets,
limit: VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err(
@@ -338,6 +354,7 @@ impl Policy for EtherTransfer {
window: Duration::seconds(limit.window_secs as i64),
},
},
},
})
})
.collect()

View File

@@ -11,7 +11,10 @@ use crate::db::{
schema::{evm_basic_grant, evm_transaction_log},
};
use crate::evm::{
policies::{EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, VolumeRateLimit},
policies::{
CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings,
VolumeRateLimit,
},
utils,
};
@@ -81,8 +84,6 @@ fn shared() -> SharedGrantSettings {
}
}
// ── analyze ─────────────────────────────────────────────────────────────
#[test]
fn analyze_matches_empty_calldata() {
let m = EtherTransfer::analyze(&ctx(ALLOWED, U256::from(1_000u64))).unwrap();
@@ -99,8 +100,6 @@ fn analyze_rejects_nonempty_calldata() {
assert!(EtherTransfer::analyze(&context).is_none());
}
// ── evaluate ────────────────────────────────────────────────────────────
#[tokio::test]
async fn evaluate_passes_for_allowed_target() {
let db = db::create_test_pool().await;
@@ -108,9 +107,11 @@ async fn evaluate_passes_for_allowed_target() {
let grant = Grant {
id: 999,
shared_grant_id: 999,
common_settings_id: 999,
settings: CombinedSettings {
shared: shared(),
settings: make_settings(vec![ALLOWED], 1_000_000),
specific: make_settings(vec![ALLOWED], 1_000_000),
},
};
let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap();
@@ -127,9 +128,11 @@ async fn evaluate_rejects_disallowed_target() {
let grant = Grant {
id: 999,
shared_grant_id: 999,
common_settings_id: 999,
settings: CombinedSettings {
shared: shared(),
settings: make_settings(vec![ALLOWED], 1_000_000),
specific: make_settings(vec![ALLOWED], 1_000_000),
},
};
let context = ctx(OTHER, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap();
@@ -167,9 +170,11 @@ async fn evaluate_passes_when_volume_within_limit() {
let grant = Grant {
id: grant_id,
shared_grant_id: basic.id,
common_settings_id: basic.id,
settings: CombinedSettings {
shared: shared(),
settings,
specific: settings,
},
};
let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap();
@@ -207,9 +212,11 @@ async fn evaluate_rejects_volume_over_limit() {
let grant = Grant {
id: grant_id,
shared_grant_id: basic.id,
common_settings_id: basic.id,
settings: CombinedSettings {
shared: shared(),
settings,
specific: settings,
},
};
let context = ctx(ALLOWED, U256::from(1u64));
let m = EtherTransfer::analyze(&context).unwrap();
@@ -248,9 +255,11 @@ async fn evaluate_passes_at_exactly_volume_limit() {
let grant = Grant {
id: grant_id,
shared_grant_id: basic.id,
common_settings_id: basic.id,
settings: CombinedSettings {
shared: shared(),
settings,
specific: settings,
},
};
let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap();
@@ -263,8 +272,6 @@ async fn evaluate_passes_at_exactly_volume_limit() {
);
}
// ── try_find_grant ───────────────────────────────────────────────────────
#[tokio::test]
async fn try_find_grant_roundtrip() {
let db = db::create_test_pool().await;
@@ -282,8 +289,11 @@ async fn try_find_grant_roundtrip() {
assert!(found.is_some());
let g = found.unwrap();
assert_eq!(g.settings.target, vec![ALLOWED]);
assert_eq!(g.settings.limit.max_volume, U256::from(1_000_000u64));
assert_eq!(g.settings.specific.target, vec![ALLOWED]);
assert_eq!(
g.settings.specific.limit.max_volume,
U256::from(1_000_000u64)
);
}
#[tokio::test]
@@ -320,7 +330,36 @@ async fn try_find_grant_wrong_target_returns_none() {
assert!(found.is_none());
}
// ── find_all_grants ──────────────────────────────────────────────────────
proptest::proptest! {
#[test]
fn target_order_does_not_affect_hash(
raw_addrs in proptest::collection::vec(proptest::prelude::any::<[u8; 20]>(), 0..8),
seed in proptest::prelude::any::<u64>(),
max_volume in proptest::prelude::any::<u64>(),
window_secs in 1i64..=86400,
) {
use rand::{SeedableRng, seq::SliceRandom};
use sha2::Digest;
use crate::crypto::integrity::hashing::Hashable;
let addrs: Vec<Address> = raw_addrs.iter().map(|b| Address::from(*b)).collect();
let mut shuffled = addrs.clone();
shuffled.shuffle(&mut rand::rngs::StdRng::seed_from_u64(seed));
let limit = VolumeRateLimit {
max_volume: U256::from(max_volume),
window: Duration::seconds(window_secs),
};
let mut h1 = sha2::Sha256::new();
Settings { target: addrs, limit: limit.clone() }.hash(&mut h1);
let mut h2 = sha2::Sha256::new();
Settings { target: shuffled, limit }.hash(&mut h2);
proptest::prop_assert_eq!(h1.finalize(), h2.finalize());
}
}
#[tokio::test]
async fn find_all_grants_empty_db() {
@@ -347,7 +386,7 @@ async fn find_all_grants_excludes_revoked() {
let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].settings.target, vec![ALLOWED]);
assert_eq!(all[0].settings.specific.target, vec![ALLOWED]);
}
#[tokio::test]
@@ -363,8 +402,11 @@ async fn find_all_grants_multiple_targets() {
let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].settings.target.len(), 2);
assert_eq!(all[0].settings.limit.max_volume, U256::from(1_000_000u64));
assert_eq!(all[0].settings.specific.target.len(), 2);
assert_eq!(
all[0].settings.specific.limit.max_volume,
U256::from(1_000_000u64)
);
}
#[tokio::test]

View File

@@ -1,20 +1,5 @@
use std::collections::HashMap;
use alloy::{
primitives::{Address, U256},
sol_types::SolCall,
};
use arbiter_tokens_registry::evm::nonfungible::{self, TokenInfo};
use chrono::{DateTime, Duration, Utc};
use diesel::dsl::{auto_type, insert_into};
use diesel::sqlite::Sqlite;
use diesel::{ExpressionMethods, prelude::*};
use diesel_async::{AsyncConnection, RunQueryDsl};
use crate::db::models::{
EvmBasicGrant, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit, NewEvmTokenTransferGrant,
NewEvmTokenTransferLog, NewEvmTokenTransferVolumeLimit, SqliteTimestamp,
};
use crate::db::schema::{
evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log,
evm_token_transfer_volume_limit,
@@ -26,6 +11,25 @@ use crate::evm::{
},
utils,
};
use crate::{
crypto::integrity::Integrable,
db::models::{
EvmBasicGrant, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit,
NewEvmTokenTransferGrant, NewEvmTokenTransferLog, NewEvmTokenTransferVolumeLimit,
SqliteTimestamp,
},
evm::policies::CombinedSettings,
};
use alloy::{
primitives::{Address, U256},
sol_types::SolCall,
};
use arbiter_tokens_registry::evm::nonfungible::{self, TokenInfo};
use chrono::{DateTime, Duration, Utc};
use diesel::dsl::{auto_type, insert_into};
use diesel::sqlite::Sqlite;
use diesel::{ExpressionMethods, prelude::*};
use diesel_async::{AsyncConnection, RunQueryDsl};
use super::{DatabaseID, EvalContext, EvalViolation};
@@ -38,9 +42,9 @@ fn grant_join() -> _ {
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Meaning {
pub(crate) token: &'static TokenInfo,
pub(crate) to: Address,
pub(crate) value: U256,
pub token: &'static TokenInfo,
pub to: Address,
pub value: U256,
}
impl std::fmt::Display for Meaning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -64,6 +68,20 @@ pub struct Settings {
pub target: Option<Address>,
pub volume_limits: Vec<VolumeRateLimit>,
}
impl Integrable for Settings {
const KIND: &'static str = "TokenTransfer";
}
use crate::crypto::integrity::hashing::Hashable;
impl Hashable for Settings {
fn hash<H: sha2::Digest>(&self, hasher: &mut H) {
self.token_contract.hash(hasher);
self.target.hash(hasher);
self.volume_limits.hash(hasher);
}
}
impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant {
SpecificGrant::TokenTransfer(val)
@@ -106,13 +124,20 @@ async fn check_volume_rate_limits(
) -> QueryResult<Vec<EvalViolation>> {
let mut violations = Vec::new();
let Some(longest_window) = grant.settings.volume_limits.iter().map(|l| l.window).max() else {
let Some(longest_window) = grant
.settings
.specific
.volume_limits
.iter()
.map(|l| l.window)
.max()
else {
return Ok(violations);
};
let past_transfers = query_relevant_past_transfers(grant.id, longest_window, db).await?;
for limit in &grant.settings.volume_limits {
for limit in &grant.settings.specific.volume_limits {
let window_start = chrono::Utc::now() - limit.window;
let prospective_cumulative_volume: U256 = past_transfers
.iter()
@@ -158,7 +183,7 @@ impl Policy for TokenTransfer {
return Ok(violations);
}
if let Some(allowed) = grant.settings.target
if let Some(allowed) = grant.settings.specific.target
&& allowed != meaning.to
{
violations.push(EvalViolation::InvalidTarget { target: meaning.to });
@@ -261,17 +286,17 @@ impl Policy for TokenTransfer {
}
};
let settings = Settings {
Ok(Some(Grant {
id: token_grant.id,
common_settings_id: token_grant.basic_grant_id,
settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic_grant)?,
specific: Settings {
token_contract: Address::from(token_contract),
target,
volume_limits,
};
Ok(Some(Grant {
id: token_grant.id,
shared_grant_id: token_grant.basic_grant_id,
shared: SharedGrantSettings::try_from_model(basic_grant)?,
settings,
},
},
}))
}
@@ -369,13 +394,15 @@ impl Policy for TokenTransfer {
Ok(Grant {
id: specific.id,
shared_grant_id: specific.basic_grant_id,
common_settings_id: specific.basic_grant_id,
settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic)?,
settings: Settings {
specific: Settings {
token_contract: Address::from(token_contract),
target,
volume_limits,
},
},
})
})
.collect()

View File

@@ -11,7 +11,10 @@ use crate::db::{
};
use crate::evm::{
abi::IERC20::transferCall,
policies::{EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, VolumeRateLimit},
policies::{
CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings,
VolumeRateLimit,
},
utils,
};
@@ -98,8 +101,6 @@ fn shared() -> SharedGrantSettings {
}
}
// ── analyze ─────────────────────────────────────────────────────────────
#[test]
fn analyze_known_token_valid_calldata() {
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
@@ -125,8 +126,6 @@ fn analyze_empty_calldata_returns_none() {
assert!(TokenTransfer::analyze(&ctx(DAI, Bytes::new())).is_none());
}
// ── evaluate ────────────────────────────────────────────────────────────
#[tokio::test]
async fn evaluate_rejects_nonzero_eth_value() {
let db = db::create_test_pool().await;
@@ -134,9 +133,11 @@ async fn evaluate_rejects_nonzero_eth_value() {
let grant = Grant {
id: 999,
shared_grant_id: 999,
common_settings_id: 999,
settings: CombinedSettings {
shared: shared(),
settings: make_settings(None, None),
specific: make_settings(None, None),
},
};
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let mut context = ctx(DAI, calldata);
@@ -163,9 +164,11 @@ async fn evaluate_passes_any_recipient_when_no_restriction() {
let grant = Grant {
id: 999,
shared_grant_id: 999,
common_settings_id: 999,
settings: CombinedSettings {
shared: shared(),
settings: make_settings(None, None),
specific: make_settings(None, None),
},
};
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata);
@@ -183,9 +186,11 @@ async fn evaluate_passes_matching_restricted_recipient() {
let grant = Grant {
id: 999,
shared_grant_id: 999,
common_settings_id: 999,
settings: CombinedSettings {
shared: shared(),
settings: make_settings(Some(RECIPIENT), None),
specific: make_settings(Some(RECIPIENT), None),
},
};
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata);
@@ -203,9 +208,11 @@ async fn evaluate_rejects_wrong_restricted_recipient() {
let grant = Grant {
id: 999,
shared_grant_id: 999,
common_settings_id: 999,
settings: CombinedSettings {
shared: shared(),
settings: make_settings(Some(RECIPIENT), None),
specific: make_settings(Some(RECIPIENT), None),
},
};
let calldata = transfer_calldata(OTHER, U256::from(100u64));
let context = ctx(DAI, calldata);
@@ -247,9 +254,11 @@ async fn evaluate_passes_volume_at_exact_limit() {
let grant = Grant {
id: grant_id,
shared_grant_id: basic.id,
common_settings_id: basic.id,
settings: CombinedSettings {
shared: shared(),
settings,
specific: settings,
},
};
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata);
@@ -290,9 +299,11 @@ async fn evaluate_rejects_volume_over_limit() {
let grant = Grant {
id: grant_id,
shared_grant_id: basic.id,
common_settings_id: basic.id,
settings: CombinedSettings {
shared: shared(),
settings,
specific: settings,
},
};
let calldata = transfer_calldata(RECIPIENT, U256::from(1u64));
let context = ctx(DAI, calldata);
@@ -313,9 +324,11 @@ async fn evaluate_no_volume_limits_always_passes() {
let grant = Grant {
id: 999,
shared_grant_id: 999,
common_settings_id: 999,
settings: CombinedSettings {
shared: shared(),
settings: make_settings(None, None), // no volume limits
specific: make_settings(None, None), // no volume limits
},
};
let calldata = transfer_calldata(RECIPIENT, U256::from(u64::MAX));
let context = ctx(DAI, calldata);
@@ -349,10 +362,13 @@ async fn try_find_grant_roundtrip() {
assert!(found.is_some());
let g = found.unwrap();
assert_eq!(g.settings.token_contract, DAI);
assert_eq!(g.settings.target, Some(RECIPIENT));
assert_eq!(g.settings.volume_limits.len(), 1);
assert_eq!(g.settings.volume_limits[0].max_volume, U256::from(5_000u64));
assert_eq!(g.settings.specific.token_contract, DAI);
assert_eq!(g.settings.specific.target, Some(RECIPIENT));
assert_eq!(g.settings.specific.volume_limits.len(), 1);
assert_eq!(
g.settings.specific.volume_limits[0].max_volume,
U256::from(5_000u64)
);
}
#[tokio::test]
@@ -392,7 +408,39 @@ async fn try_find_grant_unknown_token_returns_none() {
assert!(found.is_none());
}
// ── find_all_grants ──────────────────────────────────────────────────────
proptest::proptest! {
#[test]
fn volume_limits_order_does_not_affect_hash(
raw_limits in proptest::collection::vec(
(proptest::prelude::any::<u64>(), 1i64..=86400),
0..8,
),
seed in proptest::prelude::any::<u64>(),
) {
use rand::{SeedableRng, seq::SliceRandom};
use sha2::Digest;
use crate::crypto::integrity::hashing::Hashable;
let limits: Vec<VolumeRateLimit> = raw_limits
.iter()
.map(|(max_vol, window_secs)| VolumeRateLimit {
max_volume: U256::from(*max_vol),
window: Duration::seconds(*window_secs),
})
.collect();
let mut shuffled = limits.clone();
shuffled.shuffle(&mut rand::rngs::StdRng::seed_from_u64(seed));
let mut h1 = sha2::Sha256::new();
Settings { token_contract: DAI, target: None, volume_limits: limits }.hash(&mut h1);
let mut h2 = sha2::Sha256::new();
Settings { token_contract: DAI, target: None, volume_limits: shuffled }.hash(&mut h2);
proptest::prop_assert_eq!(h1.finalize(), h2.finalize());
}
}
#[tokio::test]
async fn find_all_grants_empty_db() {
@@ -434,9 +482,9 @@ async fn find_all_grants_loads_volume_limits() {
let all = TokenTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].settings.volume_limits.len(), 1);
assert_eq!(all[0].settings.specific.volume_limits.len(), 1);
assert_eq!(
all[0].settings.volume_limits[0].max_volume,
all[0].settings.specific.volume_limits[0].max_volume,
U256::from(9_999u64)
);
}

View File

@@ -22,6 +22,7 @@ use tracing::warn;
use crate::{
actors::client::{self, ClientConnection, auth},
crypto::integrity::Verified,
grpc::request_tracker::RequestTracker,
};
@@ -68,6 +69,7 @@ impl<'a> AuthTransportAdapter<'a> {
auth::Error::ApproveError(auth::ApproveError::Internal)
| auth::Error::DatabasePoolUnavailable
| auth::Error::DatabaseOperationFailed
| auth::Error::IntegrityCheckFailed
| auth::Error::Transport => ProtoAuthResult::Internal,
}
.into(),
@@ -199,7 +201,7 @@ pub async fn start(
conn: &mut ClientConnection,
bi: &mut GrpcBi<ClientRequest, ClientResponse>,
request_tracker: &mut RequestTracker,
) -> Result<i32, auth::Error> {
) -> Result<Verified<i32>, auth::Error> {
let mut transport = AuthTransportAdapter::new(bi, request_tracker);
client::auth::authenticate(conn, &mut transport).await
}

View File

@@ -8,7 +8,7 @@ use arbiter_proto::proto::{
EvalViolation as ProtoEvalViolation, GasLimitExceededViolation, NoMatchingGrantError,
PolicyViolationsError, SpecificMeaning as ProtoSpecificMeaning,
TokenInfo as ProtoTokenInfo, TransactionEvalError as ProtoTransactionEvalError,
eval_violation::Kind as ProtoEvalViolationKind,
eval_violation as proto_eval_violation, eval_violation::Kind as ProtoEvalViolationKind,
specific_meaning::Meaning as ProtoSpecificMeaningKind,
transaction_eval_error::Kind as ProtoTransactionEvalErrorKind,
},
@@ -79,6 +79,12 @@ impl Convert for EvalViolation {
EvalViolation::InvalidTransactionType => {
ProtoEvalViolationKind::InvalidTransactionType(())
}
EvalViolation::MismatchingChainId { expected, actual } => {
ProtoEvalViolationKind::ChainIdMismatch(proto_eval_violation::ChainIdMismatch {
expected,
actual,
})
}
};
ProtoEvalViolation { kind: Some(kind) }

View File

@@ -66,7 +66,7 @@ async fn handle_wallet_create(
) -> Result<Option<UserAgentResponsePayload>, Status> {
let result = match actor.ask(HandleEvmWalletCreate {}).await {
Ok((wallet_id, address)) => WalletCreateResult::Wallet(WalletEntry {
id: wallet_id,
id: wallet_id.drop_verification_provenance(),
address: address.to_vec(),
}),
Err(err) => {
@@ -114,13 +114,16 @@ async fn handle_grant_list(
grants: grants
.into_iter()
.map(|grant| GrantEntry {
id: grant.shared_grant_id,
wallet_access_id: grant.shared.wallet_access_id,
shared: Some(grant.shared.convert()),
specific: Some(grant.settings.convert()),
id: grant.common_settings_id,
wallet_access_id: grant.settings.shared.wallet_access_id,
shared: Some(grant.settings.shared.convert()),
specific: Some(grant.settings.specific.convert()),
})
.collect(),
}),
Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => {
EvmGrantListResult::Error(ProtoEvmError::VaultSealed.into())
}
Err(err) => {
warn!(error = ?err, "Failed to list EVM grants");
EvmGrantListResult::Error(ProtoEvmError::Internal.into())
@@ -147,7 +150,7 @@ async fn handle_grant_create(
.try_convert()?;
let result = match actor.ask(HandleGrantCreate { basic, grant }).await {
Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id),
Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id.drop_verification_provenance()),
Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => {
EvmGrantCreateResult::Error(ProtoEvmError::VaultSealed.into())
}

View File

@@ -1,336 +0,0 @@
use alloy::primitives::Address;
use chrono::{DateTime, Utc};
use diesel::sqlite::Sqlite;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, SelectableHelper as _};
use diesel_async::{AsyncConnection, RunQueryDsl};
use prost::Message;
use prost_types::Timestamp;
use crate::{
db::{models, schema},
evm::policies::{Grant, SharedGrantSettings, SpecificGrant, VolumeRateLimit},
integrity::IntegrityEntity,
};
pub const EVM_GRANT_ENTITY_KIND: &str = "evm_grant";
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IntegrityVolumeRateLimit {
#[prost(bytes, tag = "1")]
pub max_volume: Vec<u8>,
#[prost(int64, tag = "2")]
pub window_secs: i64,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IntegrityTransactionRateLimit {
#[prost(uint32, tag = "1")]
pub count: u32,
#[prost(int64, tag = "2")]
pub window_secs: i64,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IntegritySharedGrantSettings {
#[prost(int32, tag = "1")]
pub wallet_access_id: i32,
#[prost(uint64, tag = "2")]
pub chain_id: u64,
#[prost(message, optional, tag = "3")]
pub valid_from: Option<::prost_types::Timestamp>,
#[prost(message, optional, tag = "4")]
pub valid_until: Option<::prost_types::Timestamp>,
#[prost(bytes, optional, tag = "5")]
pub max_gas_fee_per_gas: Option<Vec<u8>>,
#[prost(bytes, optional, tag = "6")]
pub max_priority_fee_per_gas: Option<Vec<u8>>,
#[prost(message, optional, tag = "7")]
pub rate_limit: Option<IntegrityTransactionRateLimit>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IntegrityEtherTransferSettings {
#[prost(bytes, repeated, tag = "1")]
pub targets: Vec<Vec<u8>>,
#[prost(message, optional, tag = "2")]
pub limit: Option<IntegrityVolumeRateLimit>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IntegrityTokenTransferSettings {
#[prost(bytes, tag = "1")]
pub token_contract: Vec<u8>,
#[prost(bytes, optional, tag = "2")]
pub target: Option<Vec<u8>>,
#[prost(message, repeated, tag = "3")]
pub volume_limits: Vec<IntegrityVolumeRateLimit>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IntegritySpecificGrant {
#[prost(oneof = "integrity_specific_grant::Grant", tags = "1, 2")]
pub grant: Option<integrity_specific_grant::Grant>,
}
pub mod integrity_specific_grant {
use super::*;
#[derive(Clone, PartialEq, ::prost::Oneof)]
pub enum Grant {
#[prost(message, tag = "1")]
EtherTransfer(IntegrityEtherTransferSettings),
#[prost(message, tag = "2")]
TokenTransfer(IntegrityTokenTransferSettings),
}
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct IntegrityEvmGrantPayloadV1 {
#[prost(int32, tag = "1")]
pub basic_grant_id: i32,
#[prost(message, optional, tag = "2")]
pub shared: Option<IntegritySharedGrantSettings>,
#[prost(message, optional, tag = "3")]
pub specific: Option<IntegritySpecificGrant>,
#[prost(message, optional, tag = "4")]
pub revoked_at: Option<::prost_types::Timestamp>,
}
#[derive(Debug, Clone)]
pub struct SignedEvmGrant {
pub basic_grant_id: i32,
pub shared: SharedGrantSettings,
pub specific: SpecificGrant,
pub revoked_at: Option<DateTime<Utc>>,
}
impl SignedEvmGrant {
pub fn from_active_grant(grant: &Grant<SpecificGrant>) -> Self {
Self {
basic_grant_id: grant.shared_grant_id,
shared: grant.shared.clone(),
specific: grant.settings.clone(),
revoked_at: None,
}
}
}
fn timestamp(value: DateTime<Utc>) -> Timestamp {
Timestamp {
seconds: value.timestamp(),
nanos: 0,
}
}
fn encode_shared(shared: &SharedGrantSettings) -> IntegritySharedGrantSettings {
IntegritySharedGrantSettings {
wallet_access_id: shared.wallet_access_id,
chain_id: shared.chain,
valid_from: shared.valid_from.map(timestamp),
valid_until: shared.valid_until.map(timestamp),
max_gas_fee_per_gas: shared
.max_gas_fee_per_gas
.map(|v| v.to_le_bytes::<32>().to_vec()),
max_priority_fee_per_gas: shared
.max_priority_fee_per_gas
.map(|v| v.to_le_bytes::<32>().to_vec()),
rate_limit: shared
.rate_limit
.as_ref()
.map(|rl| IntegrityTransactionRateLimit {
count: rl.count,
window_secs: rl.window.num_seconds(),
}),
}
}
fn encode_volume_limit(limit: &VolumeRateLimit) -> IntegrityVolumeRateLimit {
IntegrityVolumeRateLimit {
max_volume: limit.max_volume.to_le_bytes::<32>().to_vec(),
window_secs: limit.window.num_seconds(),
}
}
fn try_bytes_to_u256(bytes: &[u8]) -> diesel::result::QueryResult<alloy::primitives::U256> {
let bytes: [u8; 32] = bytes.try_into().map_err(|_| {
diesel::result::Error::DeserializationError(
format!("Expected 32-byte U256 payload, got {}", bytes.len()).into(),
)
})?;
Ok(alloy::primitives::U256::from_le_bytes(bytes))
}
fn encode_specific(specific: &SpecificGrant) -> IntegritySpecificGrant {
let grant = match specific {
SpecificGrant::EtherTransfer(settings) => {
let mut targets: Vec<Vec<u8>> =
settings.target.iter().map(|addr| addr.to_vec()).collect();
targets.sort_unstable();
integrity_specific_grant::Grant::EtherTransfer(IntegrityEtherTransferSettings {
targets,
limit: Some(encode_volume_limit(&settings.limit)),
})
}
SpecificGrant::TokenTransfer(settings) => {
let mut volume_limits: Vec<IntegrityVolumeRateLimit> = settings
.volume_limits
.iter()
.map(encode_volume_limit)
.collect();
volume_limits.sort_by(|left, right| {
left.window_secs
.cmp(&right.window_secs)
.then_with(|| left.max_volume.cmp(&right.max_volume))
});
integrity_specific_grant::Grant::TokenTransfer(IntegrityTokenTransferSettings {
token_contract: settings.token_contract.to_vec(),
target: settings.target.map(|a| a.to_vec()),
volume_limits,
})
}
};
IntegritySpecificGrant { grant: Some(grant) }
}
impl IntegrityEntity for SignedEvmGrant {
fn entity_kind(&self) -> &'static str {
EVM_GRANT_ENTITY_KIND
}
fn entity_id_bytes(&self) -> Vec<u8> {
self.basic_grant_id.to_be_bytes().to_vec()
}
fn payload_version(&self) -> i32 {
1
}
fn canonical_payload_bytes(&self) -> Vec<u8> {
IntegrityEvmGrantPayloadV1 {
basic_grant_id: self.basic_grant_id,
shared: Some(encode_shared(&self.shared)),
specific: Some(encode_specific(&self.specific)),
revoked_at: self.revoked_at.map(timestamp),
}
.encode_to_vec()
}
}
pub async fn load_signed_grant_by_basic_id(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
basic_grant_id: i32,
) -> diesel::result::QueryResult<SignedEvmGrant> {
let basic: models::EvmBasicGrant = schema::evm_basic_grant::table
.filter(schema::evm_basic_grant::id.eq(basic_grant_id))
.select(models::EvmBasicGrant::as_select())
.first(conn)
.await?;
let specific_token: Option<models::EvmTokenTransferGrant> =
schema::evm_token_transfer_grant::table
.filter(schema::evm_token_transfer_grant::basic_grant_id.eq(basic_grant_id))
.select(models::EvmTokenTransferGrant::as_select())
.first(conn)
.await
.optional()?;
let revoked_at = basic.revoked_at.clone().map(Into::into);
let shared = SharedGrantSettings::try_from_model(basic)?;
if let Some(token) = specific_token {
let limits: Vec<models::EvmTokenTransferVolumeLimit> =
schema::evm_token_transfer_volume_limit::table
.filter(schema::evm_token_transfer_volume_limit::grant_id.eq(token.id))
.select(models::EvmTokenTransferVolumeLimit::as_select())
.load(conn)
.await?;
let token_contract: [u8; 20] = token.token_contract.try_into().map_err(|_| {
diesel::result::Error::DeserializationError(
"Invalid token contract address length".into(),
)
})?;
let target = match token.receiver {
None => None,
Some(bytes) => {
let arr: [u8; 20] = bytes.try_into().map_err(|_| {
diesel::result::Error::DeserializationError(
"Invalid receiver address length".into(),
)
})?;
Some(Address::from(arr))
}
};
let volume_limits = limits
.into_iter()
.map(|row| {
Ok(VolumeRateLimit {
max_volume: try_bytes_to_u256(&row.max_volume)?,
window: chrono::Duration::seconds(row.window_secs as i64),
})
})
.collect::<diesel::result::QueryResult<Vec<_>>>()?;
return Ok(SignedEvmGrant {
basic_grant_id,
shared,
specific: SpecificGrant::TokenTransfer(
crate::evm::policies::token_transfers::Settings {
token_contract: Address::from(token_contract),
target,
volume_limits,
},
),
revoked_at,
});
}
let ether: models::EvmEtherTransferGrant = schema::evm_ether_transfer_grant::table
.filter(schema::evm_ether_transfer_grant::basic_grant_id.eq(basic_grant_id))
.select(models::EvmEtherTransferGrant::as_select())
.first(conn)
.await?;
let targets_rows: Vec<models::EvmEtherTransferGrantTarget> =
schema::evm_ether_transfer_grant_target::table
.filter(schema::evm_ether_transfer_grant_target::grant_id.eq(ether.id))
.select(models::EvmEtherTransferGrantTarget::as_select())
.load(conn)
.await?;
let limit: models::EvmEtherTransferLimit = schema::evm_ether_transfer_limit::table
.filter(schema::evm_ether_transfer_limit::id.eq(ether.limit_id))
.select(models::EvmEtherTransferLimit::as_select())
.first(conn)
.await?;
let targets = targets_rows
.into_iter()
.map(|row| {
let arr: [u8; 20] = row.address.try_into().map_err(|_| {
diesel::result::Error::DeserializationError(
"Invalid ether target address length".into(),
)
})?;
Ok(Address::from(arr))
})
.collect::<diesel::result::QueryResult<Vec<_>>>()?;
Ok(SignedEvmGrant {
basic_grant_id,
shared,
specific: SpecificGrant::EtherTransfer(crate::evm::policies::ether_transfer::Settings {
target: targets,
limit: VolumeRateLimit {
max_volume: try_bytes_to_u256(&limit.max_volume)?,
window: chrono::Duration::seconds(limit.window_secs as i64),
},
}),
revoked_at,
})
}

View File

@@ -1,307 +0,0 @@
use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef;
use sha2::{Digest as _, Sha256};
use crate::{
actors::keyholder::{KeyHolder, SignIntegrity, VerifyIntegrity},
db::{
self,
models::{IntegrityEnvelope, NewIntegrityEnvelope},
schema::integrity_envelope,
},
};
pub const CURRENT_PAYLOAD_VERSION: i32 = 1;
pub mod evm;
pub trait IntegrityEntity {
fn entity_kind(&self) -> &'static str;
fn entity_id_bytes(&self) -> Vec<u8>;
fn payload_version(&self) -> i32;
fn canonical_payload_bytes(&self) -> Vec<u8>;
}
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
pub enum Error {
#[error("Database error: {0}")]
#[diagnostic(code(arbiter::integrity::database))]
Database(#[from] db::DatabaseError),
#[error("KeyHolder error: {0}")]
#[diagnostic(code(arbiter::integrity::keyholder))]
Keyholder(#[from] crate::actors::keyholder::Error),
#[error("KeyHolder mailbox error")]
#[diagnostic(code(arbiter::integrity::keyholder_send))]
KeyholderSend,
#[error("Integrity envelope is missing for entity {entity_kind}")]
#[diagnostic(code(arbiter::integrity::missing_envelope))]
MissingEnvelope { entity_kind: &'static str },
#[error(
"Integrity payload version mismatch for entity {entity_kind}: expected {expected}, found {found}"
)]
#[diagnostic(code(arbiter::integrity::payload_version_mismatch))]
PayloadVersionMismatch {
entity_kind: &'static str,
expected: i32,
found: i32,
},
#[error("Integrity MAC mismatch for entity {entity_kind}")]
#[diagnostic(code(arbiter::integrity::mac_mismatch))]
MacMismatch { entity_kind: &'static str },
}
fn payload_hash(payload: &[u8]) -> [u8; 32] {
Sha256::digest(payload).into()
}
fn push_len_prefixed(out: &mut Vec<u8>, bytes: &[u8]) {
out.extend_from_slice(&(bytes.len() as u32).to_be_bytes());
out.extend_from_slice(bytes);
}
fn build_mac_input(
entity_kind: &str,
entity_id: &[u8],
payload_version: i32,
payload_hash: &[u8; 32],
) -> Vec<u8> {
let mut out = Vec::with_capacity(8 + entity_kind.len() + entity_id.len() + 32);
push_len_prefixed(&mut out, entity_kind.as_bytes());
push_len_prefixed(&mut out, entity_id);
out.extend_from_slice(&payload_version.to_be_bytes());
out.extend_from_slice(payload_hash);
out
}
pub async fn sign_entity(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
entity: &impl IntegrityEntity,
) -> Result<(), Error> {
let entity_kind = entity.entity_kind();
let entity_id = entity.entity_id_bytes();
let payload_version = entity.payload_version();
let payload = entity.canonical_payload_bytes();
let payload_hash = payload_hash(&payload);
let mac_input = build_mac_input(entity_kind, &entity_id, payload_version, &payload_hash);
let (key_version, mac) = keyholder
.ask(SignIntegrity { mac_input })
.await
.map_err(|err| match err {
kameo::error::SendError::HandlerError(inner) => Error::Keyholder(inner),
_ => Error::KeyholderSend,
})?;
diesel::delete(integrity_envelope::table)
.filter(integrity_envelope::entity_kind.eq(entity_kind))
.filter(integrity_envelope::entity_id.eq(&entity_id))
.execute(conn)
.await
.map_err(db::DatabaseError::from)?;
insert_into(integrity_envelope::table)
.values(NewIntegrityEnvelope {
entity_kind: entity_kind.to_string(),
entity_id,
payload_version,
key_version,
mac,
})
.execute(conn)
.await
.map_err(db::DatabaseError::from)?;
Ok(())
}
pub async fn verify_entity(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
entity: &impl IntegrityEntity,
) -> Result<(), Error> {
let entity_kind = entity.entity_kind();
let entity_id = entity.entity_id_bytes();
let expected_payload_version = entity.payload_version();
let envelope: IntegrityEnvelope = integrity_envelope::table
.filter(integrity_envelope::entity_kind.eq(entity_kind))
.filter(integrity_envelope::entity_id.eq(&entity_id))
.first(conn)
.await
.map_err(|err| match err {
diesel::result::Error::NotFound => Error::MissingEnvelope { entity_kind },
other => Error::Database(db::DatabaseError::from(other)),
})?;
if envelope.payload_version != expected_payload_version {
return Err(Error::PayloadVersionMismatch {
entity_kind,
expected: expected_payload_version,
found: envelope.payload_version,
});
}
let payload = entity.canonical_payload_bytes();
let payload_hash = payload_hash(&payload);
let mac_input = build_mac_input(
entity_kind,
&entity_id,
envelope.payload_version,
&payload_hash,
);
let ok = keyholder
.ask(VerifyIntegrity {
mac_input,
expected_mac: envelope.mac,
key_version: envelope.key_version,
})
.await
.map_err(|err| match err {
kameo::error::SendError::HandlerError(inner) => Error::Keyholder(inner),
_ => Error::KeyholderSend,
})?;
if !ok {
return Err(Error::MacMismatch { entity_kind });
}
Ok(())
}
#[cfg(test)]
mod tests {
use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl;
use kameo::{actor::ActorRef, prelude::Spawn};
use crate::{
actors::keyholder::{Bootstrap, KeyHolder},
db::{self, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
};
use super::{Error, IntegrityEntity, sign_entity, verify_entity};
#[derive(Clone)]
struct DummyEntity {
id: i32,
payload_version: i32,
payload: Vec<u8>,
}
impl IntegrityEntity for DummyEntity {
fn entity_kind(&self) -> &'static str {
"dummy_entity"
}
fn entity_id_bytes(&self) -> Vec<u8> {
self.id.to_be_bytes().to_vec()
}
fn payload_version(&self) -> i32 {
self.payload_version
}
fn canonical_payload_bytes(&self) -> Vec<u8> {
self.payload.clone()
}
}
async fn bootstrapped_keyholder(db: &db::DatabasePool) -> ActorRef<KeyHolder> {
let actor = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
actor
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
})
.await
.unwrap();
actor
}
#[tokio::test]
async fn sign_writes_envelope_and_verify_passes() {
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
let entity = DummyEntity {
id: 7,
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity).await.unwrap();
let count: i64 = schema::integrity_envelope::table
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
.filter(schema::integrity_envelope::entity_id.eq(entity.entity_id_bytes()))
.count()
.get_result(&mut conn)
.await
.unwrap();
assert_eq!(count, 1, "envelope row must be created exactly once");
verify_entity(&mut conn, &keyholder, &entity).await.unwrap();
}
#[tokio::test]
async fn tampered_mac_fails_verification() {
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
let entity = DummyEntity {
id: 11,
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity).await.unwrap();
diesel::update(schema::integrity_envelope::table)
.filter(schema::integrity_envelope::entity_kind.eq("dummy_entity"))
.filter(schema::integrity_envelope::entity_id.eq(entity.entity_id_bytes()))
.set(schema::integrity_envelope::mac.eq(vec![0u8; 32]))
.execute(&mut conn)
.await
.unwrap();
let err = verify_entity(&mut conn, &keyholder, &entity)
.await
.unwrap_err();
assert!(matches!(err, Error::MacMismatch { .. }));
}
#[tokio::test]
async fn changed_payload_fails_verification() {
let db = db::create_test_pool().await;
let keyholder = bootstrapped_keyholder(&db).await;
let mut conn = db.get().await.unwrap();
let entity = DummyEntity {
id: 21,
payload_version: 1,
payload: b"payload-v1".to_vec(),
};
sign_entity(&mut conn, &keyholder, &entity).await.unwrap();
let tampered = DummyEntity {
payload: b"payload-v1-but-tampered".to_vec(),
..entity
};
let err = verify_entity(&mut conn, &keyholder, &tampered)
.await
.unwrap_err();
assert!(matches!(err, Error::MacMismatch { .. }));
}
}

View File

@@ -1,13 +1,14 @@
#![forbid(unsafe_code)]
use crate::context::ServerContext;
#[macro_use]
extern crate macro_rules_attribute;
pub mod actors;
pub mod context;
pub mod crypto;
pub mod db;
pub mod evm;
pub mod grpc;
pub mod integrity;
pub mod safe_cell;
pub mod utils;

View File

@@ -10,6 +10,7 @@ use tracing::info;
const PORT: u16 = 50051;
#[tokio::main]
#[mutants::skip]
async fn main() -> anyhow::Result<()> {
aws_lc_rs::default_provider().install_default().unwrap();

View File

@@ -1,3 +1,5 @@
use std::ops::Deref;
struct DeferClosure<F: FnOnce()> {
f: Option<F>,
}
@@ -14,3 +16,19 @@ impl<F: FnOnce()> Drop for DeferClosure<F> {
pub fn defer<F: FnOnce()>(f: F) -> impl Drop + Sized {
DeferClosure { f: Some(f) }
}
/// A trait for casting between two transparently wrapped types with identical memory layouts.
///
/// [`ReinterpretWrapper`] enables zero-cost conversions between two types (`Self` and `Counterpart`)
/// that wrap the same underlying data but differ in how that data is presented. Both types must
/// transparently wrap the same "deref target" and provide bidirectional `AsRef` conversions.
pub trait ReinterpretWrapper<Counterpart>
where
Self: Deref<Target = Self::Inner> + AsRef<Counterpart>,
Counterpart: Deref<Target = Self::Inner> + AsRef<Self>,
{
/// The shared target type that both `Self` and `Counterpart` transparently wrap.
type Inner;
/// Reinterprets `Self` as `Counterpart`.
fn reinterpret(self) -> Counterpart;
}

View File

@@ -1,9 +1,14 @@
use arbiter_proto::ClientMetadata;
use arbiter_proto::transport::{Receiver, Sender};
use arbiter_server::actors::GlobalActors;
use arbiter_server::{
actors::client::{ClientConnection, auth, connect_client},
db,
actors::{
GlobalActors,
client::{ClientConnection, ClientCredentials, auth, connect_client},
keyholder::Bootstrap,
},
crypto::integrity,
db::{self, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
};
use diesel::{ExpressionMethods as _, NullableExpressionMethods as _, QueryDsl as _, insert_into};
use diesel_async::RunQueryDsl;
@@ -21,7 +26,8 @@ fn metadata(name: &str, description: Option<&str>, version: Option<&str>) -> Cli
async fn insert_registered_client(
db: &db::DatabasePool,
pubkey: Vec<u8>,
actors: &GlobalActors,
pubkey: ed25519_dalek::VerifyingKey,
metadata: &ClientMetadata,
) {
use arbiter_server::db::schema::{client_metadata, program_client};
@@ -37,23 +43,64 @@ async fn insert_registered_client(
.get_result(&mut conn)
.await
.unwrap();
insert_into(program_client::table)
let client_id: i32 = insert_into(program_client::table)
.values((
program_client::public_key.eq(pubkey),
program_client::public_key.eq(pubkey.to_bytes().to_vec()),
program_client::metadata_id.eq(metadata_id),
))
.returning(program_client::id)
.get_result(&mut conn)
.await
.unwrap();
let _ = integrity::sign_entity(
&mut conn,
&actors.key_holder,
&ClientCredentials { pubkey, nonce: 1 },
client_id,
)
.await
.unwrap();
}
async fn insert_bootstrap_sentinel_useragent(db: &db::DatabasePool) {
let mut conn = db.get().await.unwrap();
let sentinel_key = ed25519_dalek::SigningKey::generate(&mut rand::rng())
.verifying_key()
.to_bytes()
.to_vec();
insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(sentinel_key),
schema::useragent_client::key_type.eq(1i32),
))
.execute(&mut conn)
.await
.unwrap();
}
async fn spawn_test_actors(db: &db::DatabasePool) -> GlobalActors {
insert_bootstrap_sentinel_useragent(db).await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
.key_holder
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
.await
.unwrap();
actors
}
#[tokio::test]
#[test_log::test]
pub async fn test_unregistered_pubkey_rejected() {
let db = db::create_test_pool().await;
let (server_transport, mut test_transport) = ChannelTransport::new();
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let actors = spawn_test_actors(&db).await;
let props = ClientConnection::new(db.clone(), actors);
let task = tokio::spawn(async move {
let mut server_transport = server_transport;
@@ -78,20 +125,19 @@ pub async fn test_unregistered_pubkey_rejected() {
#[test_log::test]
pub async fn test_challenge_auth() {
let db = db::create_test_pool().await;
let actors = spawn_test_actors(&db).await;
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
insert_registered_client(
&db,
pubkey_bytes.clone(),
&actors,
new_key.verifying_key(),
&metadata("client", Some("desc"), Some("1.0.0")),
)
.await;
let (server_transport, mut test_transport) = ChannelTransport::new();
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let props = ClientConnection::new(db.clone(), actors);
let task = tokio::spawn(async move {
let mut server_transport = server_transport;
@@ -147,34 +193,13 @@ pub async fn test_challenge_auth() {
#[test_log::test]
pub async fn test_metadata_unchanged_does_not_append_history() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let props = ClientConnection::new(db.clone(), actors);
let actors = spawn_test_actors(&db).await;
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let requested = metadata("client", Some("desc"), Some("1.0.0"));
{
use arbiter_server::db::schema::{client_metadata, program_client};
let mut conn = db.get().await.unwrap();
let metadata_id: i32 = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&requested.name),
client_metadata::description.eq(&requested.description),
client_metadata::version.eq(&requested.version),
))
.returning(client_metadata::id)
.get_result(&mut conn)
.await
.unwrap();
insert_into(program_client::table)
.values((
program_client::public_key.eq(new_key.verifying_key().to_bytes().to_vec()),
program_client::metadata_id.eq(metadata_id),
))
.execute(&mut conn)
.await
.unwrap();
}
insert_registered_client(&db, &actors, new_key.verifying_key(), &requested).await;
let props = ClientConnection::new(db.clone(), actors);
let (server_transport, mut test_transport) = ChannelTransport::new();
let task = tokio::spawn(async move {
@@ -225,33 +250,18 @@ pub async fn test_metadata_unchanged_does_not_append_history() {
#[test_log::test]
pub async fn test_metadata_change_appends_history_and_repoints_binding() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
let props = ClientConnection::new(db.clone(), actors);
let actors = spawn_test_actors(&db).await;
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
{
use arbiter_server::db::schema::{client_metadata, program_client};
let mut conn = db.get().await.unwrap();
let metadata_id: i32 = insert_into(client_metadata::table)
.values((
client_metadata::name.eq("client"),
client_metadata::description.eq(Some("old")),
client_metadata::version.eq(Some("1.0.0")),
))
.returning(client_metadata::id)
.get_result(&mut conn)
.await
.unwrap();
insert_into(program_client::table)
.values((
program_client::public_key.eq(new_key.verifying_key().to_bytes().to_vec()),
program_client::metadata_id.eq(metadata_id),
))
.execute(&mut conn)
.await
.unwrap();
}
insert_registered_client(
&db,
&actors,
new_key.verifying_key(),
&metadata("client", Some("old"), Some("1.0.0")),
)
.await;
let props = ClientConnection::new(db.clone(), actors);
let (server_transport, mut test_transport) = ChannelTransport::new();
let task = tokio::spawn(async move {
@@ -322,3 +332,59 @@ pub async fn test_metadata_change_appends_history_and_repoints_binding() {
);
}
}
#[tokio::test]
#[test_log::test]
pub async fn test_challenge_auth_rejects_integrity_tag_mismatch() {
let db = db::create_test_pool().await;
let actors = spawn_test_actors(&db).await;
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let requested = metadata("client", Some("desc"), Some("1.0.0"));
{
use arbiter_server::db::schema::{client_metadata, program_client};
let mut conn = db.get().await.unwrap();
let metadata_id: i32 = insert_into(client_metadata::table)
.values((
client_metadata::name.eq(&requested.name),
client_metadata::description.eq(&requested.description),
client_metadata::version.eq(&requested.version),
))
.returning(client_metadata::id)
.get_result(&mut conn)
.await
.unwrap();
insert_into(program_client::table)
.values((
program_client::public_key.eq(new_key.verifying_key().to_bytes().to_vec()),
program_client::metadata_id.eq(metadata_id),
))
.execute(&mut conn)
.await
.unwrap();
}
let (server_transport, mut test_transport) = ChannelTransport::new();
let props = ClientConnection::new(db.clone(), actors);
let task = tokio::spawn(async move {
let mut server_transport = server_transport;
connect_client(props, &mut server_transport).await;
});
test_transport
.send(auth::Inbound::AuthChallengeRequest {
pubkey: new_key.verifying_key(),
metadata: requested,
})
.await
.unwrap();
let response = test_transport
.recv()
.await
.expect("should receive auth rejection");
assert!(matches!(response, Err(auth::Error::IntegrityCheckFailed)));
task.await.unwrap();
}

View File

@@ -4,8 +4,9 @@ use arbiter_server::{
GlobalActors,
bootstrap::GetToken,
keyholder::Bootstrap,
user_agent::{AuthPublicKey, UserAgentConnection, auth},
user_agent::{AuthPublicKey, UserAgentConnection, UserAgentCredentials, auth},
},
crypto::integrity,
db::{self, schema},
safe_cell::{SafeCell, SafeCellHandle as _},
};
@@ -20,6 +21,13 @@ use super::common::ChannelTransport;
pub async fn test_bootstrap_token_auth() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
.key_holder
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
.await
.unwrap();
let token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap();
let (server_transport, mut test_transport) = ChannelTransport::new();
@@ -99,20 +107,40 @@ pub async fn test_bootstrap_invalid_token_auth() {
pub async fn test_challenge_auth() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
.key_holder
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
.await
.unwrap();
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
{
let mut conn = db.get().await.unwrap();
insert_into(schema::useragent_client::table)
let id: i32 = insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32),
))
.execute(&mut conn)
.returning(schema::useragent_client::id)
.get_result(&mut conn)
.await
.unwrap();
integrity::sign_entity(
&mut conn,
&actors.key_holder,
&UserAgentCredentials {
pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()),
nonce: 1,
},
id,
)
.await
.unwrap()
.drop_verification_provenance();
}
let (server_transport, mut test_transport) = ChannelTransport::new();
@@ -187,7 +215,6 @@ pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed()
.values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32),
schema::useragent_client::pubkey_integrity_tag.eq(Some(vec![0u8; 32])),
))
.execute(&mut conn)
.await
@@ -211,7 +238,7 @@ pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed()
assert!(matches!(
task.await.unwrap(),
Err(auth::Error::InvalidChallengeSolution)
Err(auth::Error::Internal { .. })
));
}
@@ -220,20 +247,40 @@ pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed()
pub async fn test_challenge_auth_rejects_invalid_signature() {
let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap();
actors
.key_holder
.ask(Bootstrap {
seal_key_raw: SafeCell::new(b"test-seal-key".to_vec()),
})
.await
.unwrap();
let new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
{
let mut conn = db.get().await.unwrap();
insert_into(schema::useragent_client::table)
let id: i32 = insert_into(schema::useragent_client::table)
.values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32),
))
.execute(&mut conn)
.returning(schema::useragent_client::id)
.get_result(&mut conn)
.await
.unwrap();
integrity::sign_entity(
&mut conn,
&actors.key_holder,
&UserAgentCredentials {
pubkey: AuthPublicKey::Ed25519(new_key.verifying_key()),
nonce: 1,
},
id,
)
.await
.unwrap()
.drop_verification_provenance();
}
let (server_transport, mut test_transport) = ChannelTransport::new();

View File

@@ -11,8 +11,6 @@ use arbiter_server::{
safe_cell::{SafeCell, SafeCellHandle as _},
};
use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit};
use diesel::{ExpressionMethods as _, QueryDsl as _, insert_into};
use diesel_async::RunQueryDsl;
use kameo::actor::Spawn as _;
use x25519_dalek::{EphemeralSecret, PublicKey};
@@ -152,42 +150,3 @@ pub async fn test_unseal_retry_after_invalid_key() {
assert!(matches!(response, Ok(())));
}
}
#[tokio::test]
#[test_log::test]
pub async fn test_unseal_backfills_missing_pubkey_integrity_tags() {
let seal_key = b"test-seal-key";
let (db, user_agent) = setup_sealed_user_agent(seal_key).await;
{
let mut conn = db.get().await.unwrap();
insert_into(arbiter_server::db::schema::useragent_client::table)
.values((
arbiter_server::db::schema::useragent_client::public_key
.eq(vec![1u8, 2u8, 3u8, 4u8]),
arbiter_server::db::schema::useragent_client::key_type.eq(1i32),
arbiter_server::db::schema::useragent_client::pubkey_integrity_tag
.eq(Option::<Vec<u8>>::None),
))
.execute(&mut conn)
.await
.unwrap();
}
let encrypted_key = client_dh_encrypt(&user_agent, seal_key).await;
let response = user_agent.ask(encrypted_key).await;
assert!(matches!(response, Ok(())));
{
let mut conn = db.get().await.unwrap();
let tags: Vec<Option<Vec<u8>>> = arbiter_server::db::schema::useragent_client::table
.select(arbiter_server::db::schema::useragent_client::pubkey_integrity_tag)
.load(&mut conn)
.await
.unwrap();
assert!(
tags.iter()
.all(|tag| matches!(tag, Some(v) if v.len() == 32))
);
}
}