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
This commit was merged in pull request #51.
This commit is contained in:
2026-04-05 16:26:51 +00:00
26 changed files with 1126 additions and 495 deletions

93
server/Cargo.lock generated
View File

@@ -744,6 +744,8 @@ dependencies = [
"kameo", "kameo",
"memsafe", "memsafe",
"pem", "pem",
"postcard",
"prost",
"prost-types", "prost-types",
"rand 0.10.0", "rand 0.10.0",
"rcgen", "rcgen",
@@ -751,6 +753,8 @@ dependencies = [
"rsa", "rsa",
"rustls", "rustls",
"secrecy", "secrecy",
"serde",
"serde_with",
"sha2 0.10.9", "sha2 0.10.9",
"smlang", "smlang",
"spki", "spki",
@@ -1053,6 +1057,15 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "atomic-polyfill"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4"
dependencies = [
"critical-section",
]
[[package]] [[package]]
name = "atomic-waker" name = "atomic-waker"
version = "1.1.2" version = "1.1.2"
@@ -1443,6 +1456,15 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "cobs"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1"
dependencies = [
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "console" name = "console"
version = "0.15.11" version = "0.15.11"
@@ -1550,6 +1572,12 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]] [[package]]
name = "crossbeam-utils" name = "crossbeam-utils"
version = "0.8.21" version = "0.8.21"
@@ -1955,6 +1983,7 @@ version = "3.0.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890" checksum = "c6e914c7c52decb085cea910552e24c63ac019e3ab8bf001ff736da9a9d9d890"
dependencies = [ dependencies = [
"serde",
"signature 3.0.0-rc.10", "signature 3.0.0-rc.10",
] ]
@@ -1967,6 +1996,7 @@ dependencies = [
"curve25519-dalek 5.0.0-pre.6", "curve25519-dalek 5.0.0-pre.6",
"ed25519", "ed25519",
"rand_core 0.10.0", "rand_core 0.10.0",
"serde",
"sha2 0.11.0-rc.5", "sha2 0.11.0-rc.5",
"subtle", "subtle",
"zeroize", "zeroize",
@@ -2013,6 +2043,18 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "embedded-io"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced"
[[package]]
name = "embedded-io"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
[[package]] [[package]]
name = "encode_unicode" name = "encode_unicode"
version = "1.0.0" version = "1.0.0"
@@ -2052,7 +2094,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -2430,6 +2472,15 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "hash32"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67"
dependencies = [
"byteorder",
]
[[package]] [[package]]
name = "hashbrown" name = "hashbrown"
version = "0.12.3" version = "0.12.3"
@@ -2464,6 +2515,20 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "heapless"
version = "0.7.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f"
dependencies = [
"atomic-polyfill",
"hash32",
"rustc_version 0.4.1",
"serde",
"spin",
"stable_deref_trait",
]
[[package]] [[package]]
name = "heck" name = "heck"
version = "0.5.0" version = "0.5.0"
@@ -3187,7 +3252,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [ dependencies = [
"windows-sys 0.59.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -3212,6 +3277,7 @@ dependencies = [
"num-iter", "num-iter",
"num-traits", "num-traits",
"rand 0.8.5", "rand 0.8.5",
"serde",
"smallvec", "smallvec",
"zeroize", "zeroize",
] ]
@@ -3523,6 +3589,19 @@ version = "1.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
[[package]]
name = "postcard"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24"
dependencies = [
"cobs",
"embedded-io 0.4.0",
"embedded-io 0.6.1",
"heapless",
"serde",
]
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.4" version = "0.1.4"
@@ -4151,6 +4230,7 @@ dependencies = [
"pkcs1", "pkcs1",
"pkcs8", "pkcs8",
"rand_core 0.6.4", "rand_core 0.6.4",
"serde",
"sha2 0.10.9", "sha2 0.10.9",
"signature 2.2.0", "signature 2.2.0",
"spki", "spki",
@@ -4286,7 +4366,7 @@ dependencies = [
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -4702,7 +4782,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.60.2", "windows-sys 0.61.2",
] ]
[[package]] [[package]]
@@ -4710,6 +4790,9 @@ name = "spin"
version = "0.9.8" version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
dependencies = [
"lock_api",
]
[[package]] [[package]]
name = "spki" name = "spki"
@@ -4896,7 +4979,7 @@ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.52.0", "windows-sys 0.61.2",
] ]
[[package]] [[package]]

View File

@@ -42,4 +42,5 @@ k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] }
rsa = { version = "0.9", features = ["sha2"] } rsa = { version = "0.9", features = ["sha2"] }
sha2 = "0.10" sha2 = "0.10"
spki = "0.7" spki = "0.7"
prost = "0.14.3"
miette = { version = "7.6.0", features = ["fancy", "serde"] } miette = { version = "7.6.0", features = ["fancy", "serde"] }

View File

@@ -11,7 +11,7 @@ tokio.workspace = true
futures.workspace = true futures.workspace = true
hex = "0.4.3" hex = "0.4.3"
tonic-prost = "0.14.5" tonic-prost = "0.14.5"
prost = "0.14.3" prost.workspace = true
kameo.workspace = true kameo.workspace = true
url = "2.5.8" url = "2.5.8"
miette.workspace = true miette.workspace = true

View File

@@ -17,6 +17,7 @@ diesel-async = { version = "0.8.0", features = [
"tokio", "tokio",
] } ] }
ed25519-dalek.workspace = true ed25519-dalek.workspace = true
ed25519-dalek.features = ["serde"]
arbiter-proto.path = "../arbiter-proto" arbiter-proto.path = "../arbiter-proto"
tracing.workspace = true tracing.workspace = true
tracing-subscriber = { version = "0.3", features = ["env-filter"] } tracing-subscriber = { version = "0.3", features = ["env-filter"] }
@@ -46,14 +47,20 @@ restructed = "0.2.2"
strum = { version = "0.28.0", features = ["derive"] } strum = { version = "0.28.0", features = ["derive"] }
pem = "3.0.6" pem = "3.0.6"
k256.workspace = true k256.workspace = true
k256.features = ["serde"]
rsa.workspace = true rsa.workspace = true
rsa.features = ["serde"]
sha2.workspace = true sha2.workspace = true
hmac = "0.12" hmac = "0.12"
spki.workspace = true spki.workspace = true
alloy.workspace = true alloy.workspace = true
prost-types.workspace = true prost-types.workspace = true
prost.workspace = true
arbiter-tokens-registry.path = "../arbiter-tokens-registry" arbiter-tokens-registry.path = "../arbiter-tokens-registry"
anyhow = "1.0.102" anyhow = "1.0.102"
postcard = { version = "1.1.3", features = ["use-std"] }
serde = { version = "1.0.228", features = ["derive"] }
serde_with = "3.18.0"
[dev-dependencies] [dev-dependencies]
insta = "1.46.3" insta = "1.46.3"

View File

@@ -47,7 +47,6 @@ create table if not exists useragent_client (
id integer not null primary key, id integer not null primary key,
nonce integer not null default(1), -- used for auth challenge nonce integer not null default(1), -- used for auth challenge
public_key blob not null, public_key blob not null,
pubkey_integrity_tag blob,
key_type integer not null default(1), -- 1=Ed25519, 2=ECDSA(secp256k1) key_type integer not null default(1), -- 1=Ed25519, 2=ECDSA(secp256k1)
created_at integer not null default(unixepoch ('now')), created_at integer not null default(unixepoch ('now')),
updated_at integer not null default(unixepoch ('now')) updated_at integer not null default(unixepoch ('now'))
@@ -192,3 +191,19 @@ create table if not exists evm_ether_transfer_grant_target (
) STRICT; ) STRICT;
create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target (grant_id, address); create unique index if not exists uniq_ether_transfer_target on evm_ether_transfer_grant_target (grant_id, address);
-- ===============================
-- Integrity Envelopes
-- ===============================
create table if not exists integrity_envelope (
id integer not null primary key,
entity_kind text not null,
entity_id blob not null,
payload_version integer not null,
key_version integer not null,
mac blob not null, -- 20-byte recipient address
signed_at integer not null default(unixepoch ('now')),
created_at integer not null default(unixepoch ('now'))
) STRICT;
create unique index if not exists uniq_integrity_envelope_entity on integrity_envelope (entity_kind, entity_id);

View File

@@ -7,18 +7,18 @@ use kameo::{Actor, actor::ActorRef, messages};
use rand::{SeedableRng, rng, rngs::StdRng}; use rand::{SeedableRng, rng, rngs::StdRng};
use crate::{ use crate::{
actors::keyholder::{CreateNew, Decrypt, KeyHolder}, actors::keyholder::{CreateNew, Decrypt, GetState, KeyHolder, KeyHolderState},
crypto::integrity,
db::{ db::{
DatabaseError, DatabasePool, DatabaseError, DatabasePool,
models::{self, SqliteTimestamp}, models::{self, SqliteTimestamp},
schema, schema,
}, },
evm::{ evm::{
self, RunKind, self, ListError, RunKind, policies::{
policies::{ CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
FullGrant, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning,
ether_transfer::EtherTransfer, token_transfers::TokenTransfer, ether_transfer::EtherTransfer, token_transfers::TokenTransfer,
}, }
}, },
safe_cell::{SafeCell, SafeCellHandle as _}, safe_cell::{SafeCell, SafeCellHandle as _},
}; };
@@ -56,6 +56,9 @@ pub enum Error {
#[error("Database error: {0}")] #[error("Database error: {0}")]
Database(#[from] DatabaseError), Database(#[from] DatabaseError),
#[error("Integrity violation: {0}")]
Integrity(#[from] integrity::Error),
} }
#[derive(Actor)] #[derive(Actor)]
@@ -71,7 +74,7 @@ impl EvmActor {
// is it safe to seed rng from system once? // is it safe to seed rng from system once?
// todo: audit // todo: audit
let rng = StdRng::from_rng(&mut rng()); let rng = StdRng::from_rng(&mut rng());
let engine = evm::Engine::new(db.clone()); let engine = evm::Engine::new(db.clone(), keyholder.clone());
Self { Self {
keyholder, keyholder,
db, db,
@@ -132,46 +135,59 @@ impl EvmActor {
&mut self, &mut self,
basic: SharedGrantSettings, basic: SharedGrantSettings,
grant: SpecificGrant, grant: SpecificGrant,
) -> Result<i32, DatabaseError> { ) -> Result<i32, Error> {
match grant { match grant {
SpecificGrant::EtherTransfer(settings) => { SpecificGrant::EtherTransfer(settings) => self
self.engine .engine
.create_grant::<EtherTransfer>(FullGrant { .create_grant::<EtherTransfer>(CombinedSettings {
basic, shared: basic,
specific: settings, specific: settings,
}) })
.await .await
} .map_err(Error::from),
SpecificGrant::TokenTransfer(settings) => { SpecificGrant::TokenTransfer(settings) => self
self.engine .engine
.create_grant::<TokenTransfer>(FullGrant { .create_grant::<TokenTransfer>(CombinedSettings {
basic, shared: basic,
specific: settings, specific: settings,
}) })
.await .await
} .map_err(Error::from),
} }
} }
#[message] #[message]
pub async fn useragent_delete_grant(&mut self, grant_id: i32) -> Result<(), Error> { 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 mut conn = self.db.get().await.map_err(DatabaseError::from)?;
diesel::update(schema::evm_basic_grant::table) // let keyholder = self.keyholder.clone();
.filter(schema::evm_basic_grant::id.eq(grant_id))
.set(schema::evm_basic_grant::revoked_at.eq(SqliteTimestamp::now())) // diesel_async::AsyncConnection::transaction(&mut conn, |conn| {
.execute(&mut conn) // Box::pin(async move {
.await // diesel::update(schema::evm_basic_grant::table)
.map_err(DatabaseError::from)?; // .filter(schema::evm_basic_grant::id.eq(grant_id))
Ok(()) // .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?;
// diesel::result::QueryResult::Ok(())
// })
// })
// .await
// .map_err(DatabaseError::from)?;
// Ok(())
todo!()
} }
#[message] #[message]
pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> { pub async fn useragent_list_grants(&mut self) -> Result<Vec<Grant<SpecificGrant>>, Error> {
Ok(self match self.engine.list_all_grants().await {
.engine Ok(grants) => Ok(grants),
.list_all_grants() Err(ListError::Database(db_err)) => Err(Error::Database(db_err)),
.await Err(ListError::Integrity(integrity_err)) => Err(Error::Integrity(integrity_err)),
.map_err(DatabaseError::from)?) }
} }
#[message] #[message]

View File

@@ -4,6 +4,7 @@ use diesel::{
dsl::{insert_into, update}, dsl::{insert_into, update},
}; };
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use hmac::Mac as _;
use kameo::{Actor, Reply, messages}; use kameo::{Actor, Reply, messages};
use strum::{EnumDiscriminants, IntoDiscriminant}; use strum::{EnumDiscriminants, IntoDiscriminant};
use tracing::{error, info}; use tracing::{error, info};
@@ -12,7 +13,7 @@ use crate::{
crypto::{ crypto::{
KeyCell, derive_key, KeyCell, derive_key,
encryption::v1::{self, Nonce}, encryption::v1::{self, Nonce},
integrity::v1::compute_integrity_tag, integrity::v1::HmacSha256,
}, },
safe_cell::SafeCell, safe_cell::SafeCell,
}; };
@@ -250,22 +251,6 @@ impl KeyHolder {
Ok(()) 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] #[message]
pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> { pub async fn decrypt(&mut self, aead_id: i32) -> Result<SafeCell<Vec<u8>>, Error> {
let State::Unsealed { root_key, .. } = &mut self.state else { let State::Unsealed { root_key, .. } = &mut self.state else {
@@ -339,6 +324,60 @@ impl KeyHolder {
self.state.discriminant() self.state.discriminant()
} }
#[message]
pub fn sign_integrity(&mut self, mac_input: Vec<u8>) -> Result<(i32, Vec<u8>), Error> {
let State::Unsealed {
root_key,
root_key_history_id,
} = &mut self.state
else {
return Err(Error::NotBootstrapped);
};
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);
let mac = hmac.finalize().into_bytes().to_vec();
Ok((*root_key_history_id, mac))
}
#[message]
pub fn verify_integrity(
&mut self,
mac_input: Vec<u8>,
expected_mac: Vec<u8>,
key_version: i32,
) -> Result<bool, Error> {
let State::Unsealed {
root_key,
root_key_history_id,
} = &mut self.state
else {
return Err(Error::NotBootstrapped);
};
if *root_key_history_id != key_version {
return Ok(false);
}
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);
Ok(hmac.verify_slice(&expected_mac).is_ok())
}
#[message] #[message]
pub fn seal(&mut self) -> Result<(), Error> { pub fn seal(&mut self) -> Result<(), Error> {
let State::Unsealed { let State::Unsealed {

View File

@@ -37,6 +37,13 @@ impl Error {
} }
} }
impl From<diesel::result::Error> for Error {
fn from(e: diesel::result::Error) -> Self {
error!(?e, "Database error");
Self::internal("Database error")
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Outbound { pub enum Outbound {
AuthChallenge { nonce: i32 }, AuthChallenge { nonce: i32 },

View File

@@ -1,27 +1,20 @@
use arbiter_proto::transport::Bi; use arbiter_proto::transport::Bi;
use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update}; use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, update};
use diesel_async::RunQueryDsl; use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::error::SendError; use kameo::{actor::ActorRef, error::SendError};
use tracing::error; use tracing::error;
use super::Error; use super::Error;
use crate::{ use crate::{
actors::{ actors::{
bootstrap::ConsumeToken, bootstrap::ConsumeToken,
keyholder::{self, SignIntegrityTag}, keyholder::KeyHolder,
user_agent::{AuthPublicKey, UserAgentConnection, auth::Outbound}, user_agent::{AuthPublicKey, UserAgentConnection, UserAgentCredentials, auth::Outbound},
}, },
crypto::integrity::v1::USERAGENT_INTEGRITY_TAG, crypto::integrity::{self, AttestationStatus},
db::schema, db::{DatabasePool, schema::useragent_client},
}; };
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttestationStatus {
Attested,
NotAttested,
Unavailable,
}
pub struct ChallengeRequest { pub struct ChallengeRequest {
pub pubkey: AuthPublicKey, pub pubkey: AuthPublicKey,
} }
@@ -50,11 +43,11 @@ smlang::statemachine!(
} }
); );
async fn create_nonce( /// Returns the current nonce, ready to use for the challenge nonce.
db: &crate::db::DatabasePool, async fn get_current_nonce_and_id(
pubkey_bytes: &[u8], db: &DatabasePool,
key_type: crate::db::models::KeyType, key: &AuthPublicKey,
) -> Result<i32, Error> { ) -> Result<(i32, i32), Error> {
let mut db_conn = db.get().await.map_err(|e| { let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error"); error!(error = ?e, "Database pool error");
Error::internal("Database unavailable") Error::internal("Database unavailable")
@@ -62,21 +55,12 @@ async fn create_nonce(
db_conn db_conn
.exclusive_transaction(|conn| { .exclusive_transaction(|conn| {
Box::pin(async move { Box::pin(async move {
let current_nonce = schema::useragent_client::table useragent_client::table
.filter(schema::useragent_client::public_key.eq(pubkey_bytes.to_vec())) .filter(useragent_client::public_key.eq(key.to_stored_bytes()))
.filter(schema::useragent_client::key_type.eq(key_type)) .filter(useragent_client::key_type.eq(key.key_type()))
.select(schema::useragent_client::nonce) .select((useragent_client::id, useragent_client::nonce))
.first::<i32>(conn) .first::<(i32, i32)>(conn)
.await?; .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)
}) })
}) })
.await .await
@@ -86,15 +70,92 @@ async fn create_nonce(
Error::internal("Database operation failed") Error::internal("Database operation failed")
})? })?
.ok_or_else(|| { .ok_or_else(|| {
error!(?pubkey_bytes, "Public key not found in database"); error!(?key, "Public key not found in database");
Error::UnregisteredPublicKey Error::UnregisteredPublicKey
}) })
} }
async fn register_key( async fn verify_integrity(
db: &crate::db::DatabasePool, db: &DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &AuthPublicKey,
) -> Result<(), Error> {
let mut db_conn = db.get().await.map_err(|e| {
error!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
let (id, nonce) = get_current_nonce_and_id(db, pubkey).await?;
let result = integrity::verify_entity(
&mut db_conn,
keyholder,
&UserAgentCredentials {
pubkey: pubkey.clone(),
nonce,
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity verification failed");
Error::internal("Integrity verification failed")
})?;
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!(error = ?e, "Database pool error");
Error::internal("Database unavailable")
})?;
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!(error = ?e, "Database error");
Error::internal("Database operation failed")
})?;
integrity::sign_entity(
conn,
keyholder,
&UserAgentCredentials {
pubkey: pubkey.clone(),
nonce: new_nonce,
},
id,
)
.await
.map_err(|e| {
error!(?e, "Integrity signature update failed");
Error::internal("Database error")
})?;
Result::<_, Error>::Ok(new_nonce)
})
})
.await?;
Ok(new_nonce)
}
async fn register_key(
db: &DatabasePool,
keyholder: &ActorRef<KeyHolder>,
pubkey: &AuthPublicKey, pubkey: &AuthPublicKey,
integrity_tag: Option<Vec<u8>>,
) -> Result<(), Error> { ) -> Result<(), Error> {
let pubkey_bytes = pubkey.to_stored_bytes(); let pubkey_bytes = pubkey.to_stored_bytes();
let key_type = pubkey.key_type(); let key_type = pubkey.key_type();
@@ -103,20 +164,41 @@ async fn register_key(
Error::internal("Database unavailable") Error::internal("Database unavailable")
})?; })?;
diesel::insert_into(schema::useragent_client::table) conn.transaction(|conn| {
Box::pin(async move {
const NONCE_START: i32 = 1;
let id: i32 = diesel::insert_into(useragent_client::table)
.values(( .values((
schema::useragent_client::public_key.eq(pubkey_bytes), useragent_client::public_key.eq(pubkey_bytes),
schema::useragent_client::nonce.eq(1), useragent_client::nonce.eq(NONCE_START),
schema::useragent_client::key_type.eq(key_type), useragent_client::key_type.eq(key_type),
schema::useragent_client::pubkey_integrity_tag.eq(integrity_tag),
)) ))
.execute(&mut conn) .returning(useragent_client::id)
.get_result(conn)
.await .await
.map_err(|e| { .map_err(|e| {
error!(error = ?e, "Database error"); error!(error = ?e, "Database error");
Error::internal("Database operation failed") Error::internal("Database operation failed")
})?; })?;
let entity = UserAgentCredentials {
pubkey: pubkey.clone(),
nonce: NONCE_START,
};
integrity::sign_entity(conn, &keyholder, &entity, id)
.await
.map_err(|e| {
error!(error = ?e, "Failed to sign integrity tag for new user-agent key");
Error::internal("Failed to register public key")
})?;
Result::<_, Error>::Ok(())
})
})
.await?;
Ok(()) Ok(())
} }
@@ -141,15 +223,9 @@ where
&mut self, &mut self,
ChallengeRequest { pubkey }: ChallengeRequest, ChallengeRequest { pubkey }: ChallengeRequest,
) -> Result<ChallengeContext, Self::Error> { ) -> Result<ChallengeContext, Self::Error> {
match self.verify_pubkey_attestation_status(&pubkey).await? { verify_integrity(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
AttestationStatus::Attested | AttestationStatus::Unavailable => {}
AttestationStatus::NotAttested => {
return Err(Error::InvalidChallengeSolution);
}
}
let stored_bytes = pubkey.to_stored_bytes(); let nonce = create_nonce(&self.conn.db, &self.conn.actors.key_holder, &pubkey).await?;
let nonce = create_nonce(&self.conn.db, &stored_bytes, pubkey.key_type()).await?;
self.transport self.transport
.send(Ok(Outbound::AuthChallenge { nonce })) .send(Ok(Outbound::AuthChallenge { nonce }))
@@ -189,23 +265,25 @@ where
return Err(Error::InvalidBootstrapToken); return Err(Error::InvalidBootstrapToken);
} }
let integrity_tag = self match token_ok {
.try_sign_pubkey_integrity_tag(&pubkey) true => {
.await register_key(&self.conn.db, &self.conn.actors.key_holder, &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?;
self.transport self.transport
.send(Ok(Outbound::AuthSuccess)) .send(Ok(Outbound::AuthSuccess))
.await .await
.map_err(|_| Error::Transport)?; .map_err(|_| Error::Transport)?;
Ok(pubkey) 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(missing_docs)]
#[allow(clippy::unused_unit)] #[allow(clippy::unused_unit)]
@@ -264,93 +342,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,18 +1,65 @@
use crate::{ use crate::{
actors::{GlobalActors, client::ClientProfile}, actors::{GlobalActors, client::ClientProfile}, crypto::integrity::Integrable, db::{self, models::KeyType}
db::{self, models::KeyType},
}; };
fn serialize_ecdsa<S>(key: &k256::ecdsa::VerifyingKey, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
// Serialize as hex string for easier debugging (33 bytes compressed SEC1 format)
let key = key.to_encoded_point(true);
let bytes = key.as_bytes();
serializer.serialize_bytes(bytes)
}
fn deserialize_ecdsa<'de, D>(deserializer: D) -> Result<k256::ecdsa::VerifyingKey, D::Error>
where
D: serde::Deserializer<'de>,
{
struct EcdsaVisitor;
impl<'de> serde::de::Visitor<'de> for EcdsaVisitor {
type Value = k256::ecdsa::VerifyingKey;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a compressed SEC1-encoded ECDSA public key")
}
fn visit_bytes<E>(self, v: &[u8]) -> Result<Self::Value, E>
where
E: serde::de::Error,
{
let point = k256::EncodedPoint::from_bytes(v)
.map_err(|_| E::custom("invalid compressed SEC1 format"))?;
k256::ecdsa::VerifyingKey::from_encoded_point(&point)
.map_err(|_| E::custom("invalid ECDSA public key"))
}
}
deserializer.deserialize_bytes(EcdsaVisitor)
}
/// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake. /// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake.
#[derive(Clone, Debug)] #[derive(Clone, Debug, Serialize)]
pub enum AuthPublicKey { pub enum AuthPublicKey {
Ed25519(ed25519_dalek::VerifyingKey), Ed25519(ed25519_dalek::VerifyingKey),
/// Compressed SEC1 public key; signature bytes are raw 64-byte (r||s). /// Compressed SEC1 public key; signature bytes are raw 64-byte (r||s).
#[serde(serialize_with = "serialize_ecdsa", deserialize_with = "deserialize_ecdsa")]
EcdsaSecp256k1(k256::ecdsa::VerifyingKey), EcdsaSecp256k1(k256::ecdsa::VerifyingKey),
/// RSA-2048+ public key (Windows Hello / KeyCredentialManager); signature bytes are PSS+SHA-256. /// RSA-2048+ public key (Windows Hello / KeyCredentialManager); signature bytes are PSS+SHA-256.
Rsa(rsa::RsaPublicKey), Rsa(rsa::RsaPublicKey),
} }
#[derive(Debug, Serialize)]
pub struct UserAgentCredentials {
pub pubkey: AuthPublicKey,
pub nonce: i32
}
impl Integrable for UserAgentCredentials {
const KIND: &'static str = "useragent_credentials";
}
impl AuthPublicKey { impl AuthPublicKey {
/// Canonical bytes stored in DB and echoed back in the challenge. /// Canonical bytes stored in DB and echoed back in the challenge.
/// Ed25519: raw 32 bytes. ECDSA: SEC1 compressed 33 bytes. RSA: DER-encoded SPKI. /// Ed25519: raw 32 bytes. ECDSA: SEC1 compressed 33 bytes. RSA: DER-encoded SPKI.
@@ -91,4 +138,5 @@ pub mod auth;
pub mod session; pub mod session;
pub use auth::authenticate; pub use auth::authenticate;
use serde::Serialize;
pub use session::UserAgentSession; pub use session::UserAgentSession;

View File

@@ -120,6 +120,15 @@ pub enum SignTransactionError {
Internal, Internal,
} }
#[derive(Debug, Error)]
pub enum GrantMutationError {
#[error("Vault is sealed")]
VaultSealed,
#[error("Internal grant mutation error")]
Internal,
}
#[messages] #[messages]
impl UserAgentSession { impl UserAgentSession {
#[message] #[message]
@@ -331,7 +340,7 @@ impl UserAgentSession {
&mut self, &mut self,
basic: crate::evm::policies::SharedGrantSettings, basic: crate::evm::policies::SharedGrantSettings,
grant: crate::evm::policies::SpecificGrant, grant: crate::evm::policies::SpecificGrant,
) -> Result<i32, Error> { ) -> Result<i32, GrantMutationError> {
match self match self
.props .props
.actors .actors
@@ -342,13 +351,16 @@ impl UserAgentSession {
Ok(grant_id) => Ok(grant_id), Ok(grant_id) => Ok(grant_id),
Err(err) => { Err(err) => {
error!(?err, "EVM grant create failed"); error!(?err, "EVM grant create failed");
Err(Error::internal("Failed to create EVM grant")) Err(GrantMutationError::Internal)
} }
} }
} }
#[message] #[message]
pub(crate) async fn handle_grant_delete(&mut self, grant_id: i32) -> Result<(), Error> { pub(crate) async fn handle_grant_delete(
&mut self,
grant_id: i32,
) -> Result<(), GrantMutationError> {
match self match self
.props .props
.actors .actors
@@ -359,7 +371,7 @@ impl UserAgentSession {
Ok(()) => Ok(()), Ok(()) => Ok(()),
Err(err) => { Err(err) => {
error!(?err, "EVM grant delete failed"); error!(?err, "EVM grant delete failed");
Err(Error::internal("Failed to delete EVM grant")) Err(GrantMutationError::Internal)
} }
} }
} }

View File

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

View File

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

View File

@@ -1,78 +1,319 @@
use crate::{crypto::KeyCell, safe_cell::SafeCellHandle as _}; use crate::{actors::keyholder, crypto::KeyCell,safe_cell::SafeCellHandle as _};
use chacha20poly1305::Key; use chacha20poly1305::Key;
use hmac::Mac as _; use hmac::{Hmac, Mac as _};
use serde::Serialize;
use sha2::Sha256;
pub const USERAGENT_INTEGRITY_DERIVE_TAG: &[u8] = "arbiter/useragent/integrity-key/v1".as_bytes(); use diesel::{ExpressionMethods as _, QueryDsl, dsl::insert_into, sqlite::Sqlite};
pub const USERAGENT_INTEGRITY_TAG: &[u8] = "arbiter/useragent/pubkey-entry/v1".as_bytes(); 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. use crate::{
pub fn compute_integrity_tag<'a, I>( actors::keyholder::{KeyHolder, SignIntegrity, VerifyIntegrity},
integrity_key: &mut KeyCell, db::{
purpose_tag: &[u8], self,
data_parts: I, models::{IntegrityEnvelope, NewIntegrityEnvelope},
) -> [u8; 32] schema::integrity_envelope,
where },
I: IntoIterator<Item = &'a [u8]>, };
{
type HmacSha256 = hmac::Hmac<sha2::Sha256>;
let mut output_tag = [0u8; 32]; #[derive(Debug, thiserror::Error)]
integrity_key.0.read_inline(|integrity_key_bytes: &Key| { pub enum Error {
let mut mac = <HmacSha256 as hmac::Mac>::new_from_slice(integrity_key_bytes.as_ref()) #[error("Database error: {0}")]
.expect("HMAC key initialization must not fail for 32-byte key"); Database(#[from] db::DatabaseError),
mac.update(purpose_tag);
for data_part in data_parts { #[error("KeyHolder error: {0}")]
mac.update(data_part); 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 },
#[error("Payload serialization error: {0}")]
PayloadSerialization(#[from] postcard::Error),
} }
output_tag.copy_from_slice(&mac.finalize().into_bytes());
});
output_tag #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AttestationStatus {
Attested,
Unavailable,
}
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 trait Integrable: Serialize {
const KIND: &'static str;
const VERSION: i32 = 1;
}
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 trait IntoId {
fn into_id(self) -> Vec<u8>;
}
impl IntoId for i32 {
fn into_id(self) -> Vec<u8> {
self.to_be_bytes().to_vec()
}
}
impl IntoId for &'_ [u8] {
fn into_id(self) -> Vec<u8> {
self.to_vec()
}
}
pub async fn sign_entity<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
entity: &E,
entity_id: impl IntoId,
) -> Result<(), Error> {
let payload = postcard::to_stdvec(entity)?;
let payload_hash = payload_hash(&payload);
let entity_id = entity_id.into_id();
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,
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(())
}
pub async fn verify_entity<E: Integrable>(
conn: &mut impl AsyncConnection<Backend = Sqlite>,
keyholder: &ActorRef<KeyHolder>,
entity: &E,
entity_id: impl IntoId,
) -> Result<AttestationStatus, Error> {
let entity_id = entity_id.into_id();
let envelope: IntegrityEnvelope = 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 = postcard::to_stdvec(entity)?;
let payload_hash = payload_hash(&payload);
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),
}
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use diesel::{ExpressionMethods as _, QueryDsl};
use diesel_async::RunQueryDsl;
use kameo::{actor::ActorRef, prelude::Spawn};
use crate::{ use crate::{
crypto::{derive_key, encryption::v1::generate_salt}, actors::keyholder::{Bootstrap, KeyHolder},
db::{self, schema},
safe_cell::{SafeCell, SafeCellHandle as _}, safe_cell::{SafeCell, SafeCellHandle as _},
}; };
use super::{USERAGENT_INTEGRITY_TAG, compute_integrity_tag}; use super::{Error, Integrable, sign_entity, verify_entity};
#[test] #[derive(Clone, serde::Serialize)]
pub fn integrity_tag_deterministic() { struct DummyEntity {
let salt = generate_salt(); payload_version: i32,
let mut integrity_key = derive_key(SafeCell::new(b"password".to_vec()), &salt); payload: Vec<u8>,
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] impl Integrable for DummyEntity {
pub fn integrity_tag_changes_with_payload() { const KIND: &'static str = "dummy_entity";
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(); async fn bootstrapped_keyholder(db: &db::DatabasePool) -> ActorRef<KeyHolder> {
let key_type_2 = 2i32.to_be_bytes(); let actor = KeyHolder::spawn(KeyHolder::new(db.clone()).await.unwrap());
let t1 = compute_integrity_tag( actor
&mut integrity_key, .ask(Bootstrap {
USERAGENT_INTEGRITY_TAG, seal_key_raw: SafeCell::new(b"integrity-test-seal-key".to_vec()),
[key_type_1.as_slice(), b"pubkey".as_ref()], })
); .await
let t2 = compute_integrity_tag( .unwrap();
&mut integrity_key, actor
USERAGENT_INTEGRITY_TAG, }
[key_type_2.as_slice(), b"pubkey".as_ref()],
); #[tokio::test]
assert_ne!(t1, t2); 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();
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");
verify_entity(&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();
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 = verify_entity(&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();
let tampered = DummyEntity {
payload: b"payload-v1-but-tampered".to_vec(),
..entity
};
let err = verify_entity(&mut conn, &keyholder, &tampered, ENTITY_ID)
.await
.unwrap_err();
assert!(matches!(err, Error::MacMismatch { .. }));
} }
} }

View File

@@ -5,7 +5,7 @@ use crate::db::schema::{
self, aead_encrypted, arbiter_settings, evm_basic_grant, evm_ether_transfer_grant, self, aead_encrypted, arbiter_settings, evm_basic_grant, evm_ether_transfer_grant,
evm_ether_transfer_grant_target, evm_ether_transfer_limit, evm_token_transfer_grant, evm_ether_transfer_grant_target, evm_ether_transfer_limit, evm_token_transfer_grant,
evm_token_transfer_log, evm_token_transfer_volume_limit, evm_transaction_log, evm_wallet, evm_token_transfer_log, evm_token_transfer_volume_limit, evm_transaction_log, evm_wallet,
root_key_history, tls_history, integrity_envelope, root_key_history, tls_history,
}; };
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use diesel::{prelude::*, sqlite::Sqlite}; use diesel::{prelude::*, sqlite::Sqlite};
@@ -242,7 +242,6 @@ pub struct UseragentClient {
pub id: i32, pub id: i32,
pub nonce: i32, pub nonce: i32,
pub public_key: Vec<u8>, pub public_key: Vec<u8>,
pub pubkey_integrity_tag: Option<Vec<u8>>,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
pub updated_at: SqliteTimestamp, pub updated_at: SqliteTimestamp,
pub key_type: KeyType, pub key_type: KeyType,
@@ -377,3 +376,22 @@ pub struct EvmTokenTransferLog {
pub value: Vec<u8>, pub value: Vec<u8>,
pub created_at: SqliteTimestamp, pub created_at: SqliteTimestamp,
} }
#[derive(Models, Queryable, Debug, Insertable, Selectable)]
#[diesel(table_name = integrity_envelope, check_for_backend(Sqlite))]
#[view(
NewIntegrityEnvelope,
derive(Insertable),
omit(id, signed_at, created_at),
attributes_with = "deriveless"
)]
pub struct IntegrityEnvelope {
pub id: i32,
pub entity_kind: String,
pub entity_id: Vec<u8>,
pub payload_version: i32,
pub key_version: i32,
pub mac: Vec<u8>,
pub signed_at: SqliteTimestamp,
pub created_at: SqliteTimestamp,
}

View File

@@ -139,6 +139,19 @@ diesel::table! {
} }
} }
diesel::table! {
integrity_envelope (id) {
id -> Integer,
entity_kind -> Text,
entity_id -> Binary,
payload_version -> Integer,
key_version -> Integer,
mac -> Binary,
signed_at -> Integer,
created_at -> Integer,
}
}
diesel::table! { diesel::table! {
program_client (id) { program_client (id) {
id -> Integer, id -> Integer,
@@ -178,7 +191,6 @@ diesel::table! {
id -> Integer, id -> Integer,
nonce -> Integer, nonce -> Integer,
public_key -> Binary, public_key -> Binary,
pubkey_integrity_tag -> Nullable<Binary>,
key_type -> Integer, key_type -> Integer,
created_at -> Integer, created_at -> Integer,
updated_at -> Integer, updated_at -> Integer,
@@ -220,6 +232,7 @@ diesel::allow_tables_to_appear_in_same_query!(
evm_transaction_log, evm_transaction_log,
evm_wallet, evm_wallet,
evm_wallet_access, evm_wallet_access,
integrity_envelope,
program_client, program_client,
root_key_history, root_key_history,
tls_history, tls_history,

View File

@@ -8,8 +8,11 @@ use alloy::{
use chrono::Utc; use chrono::Utc;
use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite}; use diesel::{ExpressionMethods as _, QueryDsl as _, QueryResult, insert_into, sqlite::Sqlite};
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use kameo::actor::ActorRef;
use crate::{ use crate::{
actors::keyholder::KeyHolder,
crypto::integrity,
db::{ db::{
self, DatabaseError, self, DatabaseError,
models::{ models::{
@@ -18,7 +21,7 @@ use crate::{
schema::{self, evm_transaction_log}, schema::{self, evm_transaction_log},
}, },
evm::policies::{ evm::policies::{
DatabaseID, EvalContext, EvalViolation, FullGrant, Grant, Policy, SharedGrantSettings, DatabaseID, EvalContext, EvalViolation, Grant, Policy, CombinedSettings, SharedGrantSettings,
SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer, SpecificGrant, SpecificMeaning, ether_transfer::EtherTransfer,
token_transfers::TokenTransfer, token_transfers::TokenTransfer,
}, },
@@ -36,6 +39,9 @@ pub enum PolicyError {
Violations(Vec<EvalViolation>), Violations(Vec<EvalViolation>),
#[error("No matching grant found")] #[error("No matching grant found")]
NoMatchingGrant, NoMatchingGrant,
#[error("Integrity error: {0}")]
Integrity(#[from] integrity::Error),
} }
#[derive(Debug, thiserror::Error)] #[derive(Debug, thiserror::Error)]
@@ -57,6 +63,15 @@ pub enum AnalyzeError {
UnsupportedTransactionType, 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 /// Controls whether a transaction should be executed or only validated
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RunKind { pub enum RunKind {
@@ -115,6 +130,7 @@ async fn check_shared_constraints(
// Supporting only EIP-1559 transactions for now, but we can easily extend this to support legacy transactions if needed // Supporting only EIP-1559 transactions for now, but we can easily extend this to support legacy transactions if needed
pub struct Engine { pub struct Engine {
db: db::DatabasePool, db: db::DatabasePool,
keyholder: ActorRef<KeyHolder>,
} }
impl Engine { impl Engine {
@@ -123,7 +139,10 @@ impl Engine {
context: EvalContext, context: EvalContext,
meaning: &P::Meaning, meaning: &P::Meaning,
run_kind: RunKind, run_kind: RunKind,
) -> Result<(), PolicyError> { ) -> Result<(), PolicyError>
where
P::Settings: Clone,
{
let mut conn = self.db.get().await.map_err(DatabaseError::from)?; let mut conn = self.db.get().await.map_err(DatabaseError::from)?;
let grant = P::try_find_grant(&context, &mut conn) let grant = P::try_find_grant(&context, &mut conn)
@@ -131,8 +150,14 @@ impl Engine {
.map_err(DatabaseError::from)? .map_err(DatabaseError::from)?
.ok_or(PolicyError::NoMatchingGrant)?; .ok_or(PolicyError::NoMatchingGrant)?;
let mut violations = integrity::verify_entity(&mut conn, &self.keyholder, &grant.settings, grant.id).await?;
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 .await
.map_err(DatabaseError::from)?; .map_err(DatabaseError::from)?;
violations.extend( violations.extend(
@@ -143,12 +168,14 @@ impl Engine {
if !violations.is_empty() { if !violations.is_empty() {
return Err(PolicyError::Violations(violations)); return Err(PolicyError::Violations(violations));
} else if run_kind == RunKind::Execution { }
if run_kind == RunKind::Execution {
conn.transaction(|conn| { conn.transaction(|conn| {
Box::pin(async move { Box::pin(async move {
let log_id: i32 = insert_into(evm_transaction_log::table) let log_id: i32 = insert_into(evm_transaction_log::table)
.values(&NewEvmTransactionLog { .values(&NewEvmTransactionLog {
grant_id: grant.shared_grant_id, grant_id: grant.common_settings_id,
wallet_access_id: context.target.id, wallet_access_id: context.target.id,
chain_id: context.chain as i32, chain_id: context.chain as i32,
eth_value: utils::u256_to_bytes(context.value).to_vec(), eth_value: utils::u256_to_bytes(context.value).to_vec(),
@@ -172,15 +199,19 @@ impl Engine {
} }
impl Engine { impl Engine {
pub fn new(db: db::DatabasePool) -> Self { pub fn new(db: db::DatabasePool, keyholder: ActorRef<KeyHolder>) -> Self {
Self { db } Self { db, keyholder }
} }
pub async fn create_grant<P: Policy>( pub async fn create_grant<P: Policy>(
&self, &self,
full_grant: FullGrant<P::Settings>, full_grant: CombinedSettings<P::Settings>,
) -> Result<i32, DatabaseError> { ) -> Result<i32, DatabaseError>
where
P::Settings: Clone,
{
let mut conn = self.db.get().await?; let mut conn = self.db.get().await?;
let keyholder = self.keyholder.clone();
let id = conn let id = conn
.transaction(|conn| { .transaction(|conn| {
@@ -189,25 +220,25 @@ impl Engine {
let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table) let basic_grant: EvmBasicGrant = insert_into(evm_basic_grant::table)
.values(&NewEvmBasicGrant { .values(&NewEvmBasicGrant {
chain_id: full_grant.basic.chain as i32, chain_id: full_grant.shared.chain as i32,
wallet_access_id: full_grant.basic.wallet_access_id, wallet_access_id: full_grant.shared.wallet_access_id,
valid_from: full_grant.basic.valid_from.map(SqliteTimestamp), valid_from: full_grant.shared.valid_from.map(SqliteTimestamp),
valid_until: full_grant.basic.valid_until.map(SqliteTimestamp), valid_until: full_grant.shared.valid_until.map(SqliteTimestamp),
max_gas_fee_per_gas: full_grant max_gas_fee_per_gas: full_grant
.basic .shared
.max_gas_fee_per_gas .max_gas_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()), .map(|fee| utils::u256_to_bytes(fee).to_vec()),
max_priority_fee_per_gas: full_grant max_priority_fee_per_gas: full_grant
.basic .shared
.max_priority_fee_per_gas .max_priority_fee_per_gas
.map(|fee| utils::u256_to_bytes(fee).to_vec()), .map(|fee| utils::u256_to_bytes(fee).to_vec()),
rate_limit_count: full_grant rate_limit_count: full_grant
.basic .shared
.rate_limit .rate_limit
.as_ref() .as_ref()
.map(|rl| rl.count as i32), .map(|rl| rl.count as i32),
rate_limit_window_secs: full_grant rate_limit_window_secs: full_grant
.basic .shared
.rate_limit .rate_limit
.as_ref() .as_ref()
.map(|rl| rl.window.num_seconds() as i32), .map(|rl| rl.window.num_seconds() as i32),
@@ -217,7 +248,18 @@ impl Engine {
.get_result(conn) .get_result(conn)
.await?; .await?;
P::create_grant(&basic_grant, &full_grant.specific, conn).await P::create_grant(&basic_grant, &full_grant.specific, conn).await?;
integrity::sign_entity(
conn,
&keyholder,
&full_grant,
basic_grant.id,
)
.await
.map_err(|_| diesel::result::Error::RollbackTransaction)?;
QueryResult::Ok(basic_grant.id)
}) })
}) })
.await?; .await?;
@@ -225,33 +267,36 @@ impl Engine {
Ok(id) Ok(id)
} }
pub async fn list_all_grants(&self) -> Result<Vec<Grant<SpecificGrant>>, DatabaseError> { async fn list_one_kind<Kind: Policy, Y>(
let mut conn = self.db.get().await?; &self,
conn: &mut impl AsyncConnection<Backend = Sqlite>,
) -> Result<impl Iterator<Item = Grant<Y>>, ListError>
where
Y: From<Kind::Settings>,
{
let all_grants = Kind::find_all_grants(conn)
.await
.map_err(DatabaseError::from)?;
// Verify integrity of all grants before returning any results
for grant in &all_grants {
integrity::verify_entity(conn, &self.keyholder, &grant.settings, grant.id).await?;
}
Ok(all_grants.into_iter().map(|g| Grant {
id: g.id,
common_settings_id: g.common_settings_id,
settings: g.settings.generalize(),
}))
}
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(); let mut grants: Vec<Grant<SpecificGrant>> = Vec::new();
grants.extend( grants.extend(self.list_one_kind::<EtherTransfer, _>(&mut conn).await?);
EtherTransfer::find_all_grants(&mut conn) grants.extend(self.list_one_kind::<TokenTransfer, _>(&mut conn).await?);
.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),
}),
);
Ok(grants) Ok(grants)
} }

View File

@@ -7,11 +7,11 @@ use diesel::{
}; };
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use serde::Serialize;
use thiserror::Error; use thiserror::Error;
use crate::{ use crate::{
db::models::{self, EvmBasicGrant, EvmWalletAccess}, crypto::integrity::v1::Integrable, db::models::{self, EvmBasicGrant, EvmWalletAccess}, evm::utils
evm::utils,
}; };
pub mod ether_transfer; pub mod ether_transfer;
@@ -59,16 +59,15 @@ pub enum EvalViolation {
pub type DatabaseID = i32; pub type DatabaseID = i32;
#[derive(Debug)] #[derive(Debug, Serialize)]
pub struct Grant<PolicySettings> { pub struct Grant<PolicySettings> {
pub id: DatabaseID, pub id: DatabaseID,
pub shared_grant_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods pub common_settings_id: DatabaseID, // ID of the basic grant for shared-logic checks like rate limits and validity periods
pub shared: SharedGrantSettings, pub settings: CombinedSettings<PolicySettings>,
pub settings: PolicySettings,
} }
pub trait Policy: Sized { 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>; type Meaning: Display + std::fmt::Debug + Send + Sync + 'static + Into<SpecificMeaning>;
fn analyze(context: &EvalContext) -> Option<Self::Meaning>; fn analyze(context: &EvalContext) -> Option<Self::Meaning>;
@@ -124,19 +123,19 @@ pub enum SpecificMeaning {
TokenTransfer(token_transfers::Meaning), TokenTransfer(token_transfers::Meaning),
} }
#[derive(Clone, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
pub struct TransactionRateLimit { pub struct TransactionRateLimit {
pub count: u32, pub count: u32,
pub window: Duration, pub window: Duration,
} }
#[derive(Clone, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
pub struct VolumeRateLimit { pub struct VolumeRateLimit {
pub max_volume: U256, pub max_volume: U256,
pub window: Duration, pub window: Duration,
} }
#[derive(Clone, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize)]
pub struct SharedGrantSettings { pub struct SharedGrantSettings {
pub wallet_access_id: i32, pub wallet_access_id: i32,
pub chain: ChainId, pub chain: ChainId,
@@ -151,7 +150,7 @@ pub struct SharedGrantSettings {
} }
impl SharedGrantSettings { impl SharedGrantSettings {
fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> { pub(crate) fn try_from_model(model: EvmBasicGrant) -> QueryResult<Self> {
Ok(Self { Ok(Self {
wallet_access_id: model.wallet_access_id, wallet_access_id: model.wallet_access_id,
chain: model.chain_id as u64, // safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants chain: model.chain_id as u64, // safe because chain_id is stored as i32 but is guaranteed to be a valid ChainId by the API when creating grants
@@ -197,7 +196,23 @@ pub enum SpecificGrant {
TokenTransfer(token_transfers::Settings), TokenTransfer(token_transfers::Settings),
} }
pub struct FullGrant<PolicyGrant> { #[derive(Debug, Serialize)]
pub basic: SharedGrantSettings, pub struct CombinedSettings<PolicyGrant> {
pub shared: SharedGrantSettings,
pub specific: PolicyGrant, 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;
}

View File

@@ -8,13 +8,14 @@ use diesel::sqlite::Sqlite;
use diesel::{ExpressionMethods, JoinOnDsl, prelude::*}; use diesel::{ExpressionMethods, JoinOnDsl, prelude::*};
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use crate::crypto::integrity::v1::Integrable;
use crate::db::models::{ use crate::db::models::{
EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferLimit, EvmBasicGrant, EvmEtherTransferGrant, EvmEtherTransferGrantTarget, EvmEtherTransferLimit,
NewEvmEtherTransferLimit, SqliteTimestamp, NewEvmEtherTransferLimit, SqliteTimestamp,
}; };
use crate::db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log}; use crate::db::schema::{evm_basic_grant, evm_ether_transfer_limit, evm_transaction_log};
use crate::evm::policies::{ use crate::evm::policies::{
Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit, CombinedSettings, Grant, SharedGrantSettings, SpecificGrant, SpecificMeaning, VolumeRateLimit,
}; };
use crate::{ use crate::{
db::{ db::{
@@ -51,11 +52,14 @@ impl From<Meaning> for SpecificMeaning {
} }
// A grant for ether transfers, which can be scoped to specific target addresses and volume limits // A grant for ether transfers, which can be scoped to specific target addresses and volume limits
#[derive(Debug, Clone)] #[derive(Debug, Clone, serde::Serialize)]
pub struct Settings { pub struct Settings {
pub target: Vec<Address>, pub target: Vec<Address>,
pub limit: VolumeRateLimit, pub limit: VolumeRateLimit,
} }
impl Integrable for Settings {
const KIND: &'static str = "EtherTransfer";
}
impl From<Settings> for SpecificGrant { impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant { fn from(val: Settings) -> SpecificGrant {
@@ -95,17 +99,17 @@ async fn check_rate_limits(
db: &mut impl AsyncConnection<Backend = Sqlite>, db: &mut impl AsyncConnection<Backend = Sqlite>,
) -> QueryResult<Vec<EvalViolation>> { ) -> QueryResult<Vec<EvalViolation>> {
let mut violations = Vec::new(); 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.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 let prospective_cumulative_volume: U256 = past_transaction
.iter() .iter()
.filter(|(_, timestamp)| timestamp >= &window_start) .filter(|(_, timestamp)| timestamp >= &window_start)
.fold(current_transfer_value, |acc, (value, _)| acc + *value); .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); violations.push(EvalViolation::VolumetricLimitExceeded);
} }
@@ -138,7 +142,7 @@ impl Policy for EtherTransfer {
let mut violations = Vec::new(); let mut violations = Vec::new();
// Check if the target address is within the grant's allowed targets // 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 }); violations.push(EvalViolation::InvalidTarget { target: meaning.to });
} }
@@ -247,9 +251,11 @@ impl Policy for EtherTransfer {
Ok(Some(Grant { Ok(Some(Grant {
id: grant.id, 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)?, shared: SharedGrantSettings::try_from_model(basic_grant)?,
settings, specific: settings,
},
})) }))
} }
@@ -327,9 +333,10 @@ impl Policy for EtherTransfer {
Ok(Grant { Ok(Grant {
id: specific.id, 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)?, shared: SharedGrantSettings::try_from_model(basic)?,
settings: Settings { specific: Settings {
target: targets, target: targets,
limit: VolumeRateLimit { limit: VolumeRateLimit {
max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err( max_volume: utils::try_bytes_to_u256(&limit.max_volume).map_err(
@@ -338,6 +345,7 @@ impl Policy for EtherTransfer {
window: Duration::seconds(limit.window_secs as i64), window: Duration::seconds(limit.window_secs as i64),
}, },
}, },
},
}) })
}) })
.collect() .collect()

View File

@@ -11,7 +11,10 @@ use crate::db::{
schema::{evm_basic_grant, evm_transaction_log}, schema::{evm_basic_grant, evm_transaction_log},
}; };
use crate::evm::{ use crate::evm::{
policies::{EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, VolumeRateLimit}, policies::{
CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings,
VolumeRateLimit,
},
utils, utils,
}; };
@@ -108,9 +111,11 @@ async fn evaluate_passes_for_allowed_target() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
shared_grant_id: 999, common_settings_id: 999,
settings: CombinedSettings {
shared: shared(), 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 context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap(); let m = EtherTransfer::analyze(&context).unwrap();
@@ -127,9 +132,11 @@ async fn evaluate_rejects_disallowed_target() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
shared_grant_id: 999, common_settings_id: 999,
settings: CombinedSettings {
shared: shared(), 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 context = ctx(OTHER, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap(); let m = EtherTransfer::analyze(&context).unwrap();
@@ -167,9 +174,11 @@ async fn evaluate_passes_when_volume_within_limit() {
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
shared_grant_id: basic.id, common_settings_id: basic.id,
settings: CombinedSettings {
shared: shared(), shared: shared(),
settings, specific: settings,
},
}; };
let context = ctx(ALLOWED, U256::from(100u64)); let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap(); let m = EtherTransfer::analyze(&context).unwrap();
@@ -207,9 +216,11 @@ async fn evaluate_rejects_volume_over_limit() {
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
shared_grant_id: basic.id, common_settings_id: basic.id,
settings: CombinedSettings {
shared: shared(), shared: shared(),
settings, specific: settings,
},
}; };
let context = ctx(ALLOWED, U256::from(1u64)); let context = ctx(ALLOWED, U256::from(1u64));
let m = EtherTransfer::analyze(&context).unwrap(); let m = EtherTransfer::analyze(&context).unwrap();
@@ -248,9 +259,11 @@ async fn evaluate_passes_at_exactly_volume_limit() {
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
shared_grant_id: basic.id, common_settings_id: basic.id,
settings: CombinedSettings {
shared: shared(), shared: shared(),
settings, specific: settings,
},
}; };
let context = ctx(ALLOWED, U256::from(100u64)); let context = ctx(ALLOWED, U256::from(100u64));
let m = EtherTransfer::analyze(&context).unwrap(); let m = EtherTransfer::analyze(&context).unwrap();
@@ -282,8 +295,11 @@ async fn try_find_grant_roundtrip() {
assert!(found.is_some()); assert!(found.is_some());
let g = found.unwrap(); let g = found.unwrap();
assert_eq!(g.settings.target, vec![ALLOWED]); assert_eq!(g.settings.specific.target, vec![ALLOWED]);
assert_eq!(g.settings.limit.max_volume, U256::from(1_000_000u64)); assert_eq!(
g.settings.specific.limit.max_volume,
U256::from(1_000_000u64)
);
} }
#[tokio::test] #[tokio::test]
@@ -347,7 +363,7 @@ async fn find_all_grants_excludes_revoked() {
let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap(); let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 1); assert_eq!(all.len(), 1);
assert_eq!(all[0].settings.target, vec![ALLOWED]); assert_eq!(all[0].settings.specific.target, vec![ALLOWED]);
} }
#[tokio::test] #[tokio::test]
@@ -363,8 +379,11 @@ async fn find_all_grants_multiple_targets() {
let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap(); let all = EtherTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 1); assert_eq!(all.len(), 1);
assert_eq!(all[0].settings.target.len(), 2); assert_eq!(all[0].settings.specific.target.len(), 2);
assert_eq!(all[0].settings.limit.max_volume, U256::from(1_000_000u64)); assert_eq!(
all[0].settings.specific.limit.max_volume,
U256::from(1_000_000u64)
);
} }
#[tokio::test] #[tokio::test]

View File

@@ -10,11 +10,8 @@ use diesel::dsl::{auto_type, insert_into};
use diesel::sqlite::Sqlite; use diesel::sqlite::Sqlite;
use diesel::{ExpressionMethods, prelude::*}; use diesel::{ExpressionMethods, prelude::*};
use diesel_async::{AsyncConnection, RunQueryDsl}; use diesel_async::{AsyncConnection, RunQueryDsl};
use serde::Serialize;
use crate::db::models::{
EvmBasicGrant, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit, NewEvmTokenTransferGrant,
NewEvmTokenTransferLog, NewEvmTokenTransferVolumeLimit, SqliteTimestamp,
};
use crate::db::schema::{ use crate::db::schema::{
evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log, evm_basic_grant, evm_token_transfer_grant, evm_token_transfer_log,
evm_token_transfer_volume_limit, evm_token_transfer_volume_limit,
@@ -26,6 +23,15 @@ use crate::evm::{
}, },
utils, utils,
}; };
use crate::{
crypto::integrity::Integrable,
db::models::{
EvmBasicGrant, EvmTokenTransferGrant, EvmTokenTransferVolumeLimit,
NewEvmTokenTransferGrant, NewEvmTokenTransferLog, NewEvmTokenTransferVolumeLimit,
SqliteTimestamp,
},
evm::policies::CombinedSettings,
};
use super::{DatabaseID, EvalContext, EvalViolation}; use super::{DatabaseID, EvalContext, EvalViolation};
@@ -38,9 +44,9 @@ fn grant_join() -> _ {
#[derive(Clone, Debug, PartialEq, Eq, Hash)] #[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct Meaning { pub struct Meaning {
pub(crate) token: &'static TokenInfo, pub token: &'static TokenInfo,
pub(crate) to: Address, pub to: Address,
pub(crate) value: U256, pub value: U256,
} }
impl std::fmt::Display for Meaning { impl std::fmt::Display for Meaning {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
@@ -58,12 +64,15 @@ impl From<Meaning> for SpecificMeaning {
} }
// A grant for token transfers, which can be scoped to specific target addresses and volume limits // A grant for token transfers, which can be scoped to specific target addresses and volume limits
#[derive(Debug, Clone)] #[derive(Debug, Clone, Serialize)]
pub struct Settings { pub struct Settings {
pub token_contract: Address, pub token_contract: Address,
pub target: Option<Address>, pub target: Option<Address>,
pub volume_limits: Vec<VolumeRateLimit>, pub volume_limits: Vec<VolumeRateLimit>,
} }
impl Integrable for Settings {
const KIND: &'static str = "TokenTransfer";
}
impl From<Settings> for SpecificGrant { impl From<Settings> for SpecificGrant {
fn from(val: Settings) -> SpecificGrant { fn from(val: Settings) -> SpecificGrant {
SpecificGrant::TokenTransfer(val) SpecificGrant::TokenTransfer(val)
@@ -106,13 +115,20 @@ async fn check_volume_rate_limits(
) -> QueryResult<Vec<EvalViolation>> { ) -> QueryResult<Vec<EvalViolation>> {
let mut violations = Vec::new(); 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); return Ok(violations);
}; };
let past_transfers = query_relevant_past_transfers(grant.id, longest_window, db).await?; 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 window_start = chrono::Utc::now() - limit.window;
let prospective_cumulative_volume: U256 = past_transfers let prospective_cumulative_volume: U256 = past_transfers
.iter() .iter()
@@ -158,7 +174,7 @@ impl Policy for TokenTransfer {
return Ok(violations); return Ok(violations);
} }
if let Some(allowed) = grant.settings.target if let Some(allowed) = grant.settings.specific.target
&& allowed != meaning.to && allowed != meaning.to
{ {
violations.push(EvalViolation::InvalidTarget { target: meaning.to }); violations.push(EvalViolation::InvalidTarget { target: meaning.to });
@@ -269,9 +285,11 @@ impl Policy for TokenTransfer {
Ok(Some(Grant { Ok(Some(Grant {
id: token_grant.id, id: token_grant.id,
shared_grant_id: token_grant.basic_grant_id, common_settings_id: token_grant.basic_grant_id,
settings: CombinedSettings {
shared: SharedGrantSettings::try_from_model(basic_grant)?, shared: SharedGrantSettings::try_from_model(basic_grant)?,
settings, specific: settings,
},
})) }))
} }
@@ -369,13 +387,15 @@ impl Policy for TokenTransfer {
Ok(Grant { Ok(Grant {
id: specific.id, 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)?, shared: SharedGrantSettings::try_from_model(basic)?,
settings: Settings { specific: Settings {
token_contract: Address::from(token_contract), token_contract: Address::from(token_contract),
target, target,
volume_limits, volume_limits,
}, },
},
}) })
}) })
.collect() .collect()

View File

@@ -11,7 +11,10 @@ use crate::db::{
}; };
use crate::evm::{ use crate::evm::{
abi::IERC20::transferCall, abi::IERC20::transferCall,
policies::{EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings, VolumeRateLimit}, policies::{
CombinedSettings, EvalContext, EvalViolation, Grant, Policy, SharedGrantSettings,
VolumeRateLimit,
},
utils, utils,
}; };
@@ -134,9 +137,11 @@ async fn evaluate_rejects_nonzero_eth_value() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
shared_grant_id: 999, common_settings_id: 999,
settings: CombinedSettings {
shared: shared(), shared: shared(),
settings: make_settings(None, None), specific: make_settings(None, None),
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let mut context = ctx(DAI, calldata); let mut context = ctx(DAI, calldata);
@@ -163,9 +168,11 @@ async fn evaluate_passes_any_recipient_when_no_restriction() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
shared_grant_id: 999, common_settings_id: 999,
settings: CombinedSettings {
shared: shared(), shared: shared(),
settings: make_settings(None, None), specific: make_settings(None, None),
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -183,9 +190,11 @@ async fn evaluate_passes_matching_restricted_recipient() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
shared_grant_id: 999, common_settings_id: 999,
settings: CombinedSettings {
shared: shared(), shared: shared(),
settings: make_settings(Some(RECIPIENT), None), specific: make_settings(Some(RECIPIENT), None),
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -203,9 +212,11 @@ async fn evaluate_rejects_wrong_restricted_recipient() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
shared_grant_id: 999, common_settings_id: 999,
settings: CombinedSettings {
shared: shared(), shared: shared(),
settings: make_settings(Some(RECIPIENT), None), specific: make_settings(Some(RECIPIENT), None),
},
}; };
let calldata = transfer_calldata(OTHER, U256::from(100u64)); let calldata = transfer_calldata(OTHER, U256::from(100u64));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -247,9 +258,11 @@ async fn evaluate_passes_volume_at_exact_limit() {
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
shared_grant_id: basic.id, common_settings_id: basic.id,
settings: CombinedSettings {
shared: shared(), shared: shared(),
settings, specific: settings,
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(100u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(100u64));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -290,9 +303,11 @@ async fn evaluate_rejects_volume_over_limit() {
let grant = Grant { let grant = Grant {
id: grant_id, id: grant_id,
shared_grant_id: basic.id, common_settings_id: basic.id,
settings: CombinedSettings {
shared: shared(), shared: shared(),
settings, specific: settings,
},
}; };
let calldata = transfer_calldata(RECIPIENT, U256::from(1u64)); let calldata = transfer_calldata(RECIPIENT, U256::from(1u64));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -313,9 +328,11 @@ async fn evaluate_no_volume_limits_always_passes() {
let grant = Grant { let grant = Grant {
id: 999, id: 999,
shared_grant_id: 999, common_settings_id: 999,
settings: CombinedSettings {
shared: shared(), 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 calldata = transfer_calldata(RECIPIENT, U256::from(u64::MAX));
let context = ctx(DAI, calldata); let context = ctx(DAI, calldata);
@@ -349,10 +366,13 @@ async fn try_find_grant_roundtrip() {
assert!(found.is_some()); assert!(found.is_some());
let g = found.unwrap(); let g = found.unwrap();
assert_eq!(g.settings.token_contract, DAI); assert_eq!(g.settings.specific.token_contract, DAI);
assert_eq!(g.settings.target, Some(RECIPIENT)); assert_eq!(g.settings.specific.target, Some(RECIPIENT));
assert_eq!(g.settings.volume_limits.len(), 1); assert_eq!(g.settings.specific.volume_limits.len(), 1);
assert_eq!(g.settings.volume_limits[0].max_volume, U256::from(5_000u64)); assert_eq!(
g.settings.specific.volume_limits[0].max_volume,
U256::from(5_000u64)
);
} }
#[tokio::test] #[tokio::test]
@@ -434,9 +454,9 @@ async fn find_all_grants_loads_volume_limits() {
let all = TokenTransfer::find_all_grants(&mut *conn).await.unwrap(); let all = TokenTransfer::find_all_grants(&mut *conn).await.unwrap();
assert_eq!(all.len(), 1); 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!( assert_eq!(
all[0].settings.volume_limits[0].max_volume, all[0].settings.specific.volume_limits[0].max_volume,
U256::from(9_999u64) U256::from(9_999u64)
); );
} }

View File

@@ -108,12 +108,12 @@ impl Convert for VetError {
violations: violations.into_iter().map(Convert::convert).collect(), violations: violations.into_iter().map(Convert::convert).collect(),
}) })
} }
PolicyError::Database(_) => { PolicyError::Database(_)| PolicyError::Integrity(_) => {
return EvmSignTransactionResult::Error(ProtoEvmError::Internal.into()); return EvmSignTransactionResult::Error(ProtoEvmError::Internal.into());
} }
}, },
}; };
EvmSignTransactionResult::EvalError(ProtoTransactionEvalError { kind: Some(kind) }.into()) EvmSignTransactionResult::EvalError(ProtoTransactionEvalError { kind: Some(kind) })
} }
} }

View File

@@ -26,8 +26,8 @@ use crate::{
actors::user_agent::{ actors::user_agent::{
UserAgentSession, UserAgentSession,
session::connection::{ session::connection::{
HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate, HandleGrantDelete, GrantMutationError, HandleEvmWalletCreate, HandleEvmWalletList, HandleGrantCreate,
HandleGrantList, HandleSignTransaction, HandleGrantDelete, HandleGrantList, HandleSignTransaction,
SignTransactionError as SessionSignTransactionError, SignTransactionError as SessionSignTransactionError,
}, },
}, },
@@ -114,10 +114,10 @@ async fn handle_grant_list(
grants: grants grants: grants
.into_iter() .into_iter()
.map(|grant| GrantEntry { .map(|grant| GrantEntry {
id: grant.id, id: grant.common_settings_id,
wallet_access_id: grant.shared.wallet_access_id, wallet_access_id: grant.settings.shared.wallet_access_id,
shared: Some(grant.shared.convert()), shared: Some(grant.settings.shared.convert()),
specific: Some(grant.settings.convert()), specific: Some(grant.settings.specific.convert()),
}) })
.collect(), .collect(),
}), }),
@@ -148,6 +148,9 @@ async fn handle_grant_create(
let result = match actor.ask(HandleGrantCreate { basic, grant }).await { let result = match actor.ask(HandleGrantCreate { basic, grant }).await {
Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id), Ok(grant_id) => EvmGrantCreateResult::GrantId(grant_id),
Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => {
EvmGrantCreateResult::Error(ProtoEvmError::VaultSealed.into())
}
Err(err) => { Err(err) => {
warn!(error = ?err, "Failed to create EVM grant"); warn!(error = ?err, "Failed to create EVM grant");
EvmGrantCreateResult::Error(ProtoEvmError::Internal.into()) EvmGrantCreateResult::Error(ProtoEvmError::Internal.into())
@@ -171,6 +174,9 @@ async fn handle_grant_delete(
.await .await
{ {
Ok(()) => EvmGrantDeleteResult::Ok(()), Ok(()) => EvmGrantDeleteResult::Ok(()),
Err(kameo::error::SendError::HandlerError(GrantMutationError::VaultSealed)) => {
EvmGrantDeleteResult::Error(ProtoEvmError::VaultSealed.into())
}
Err(err) => { Err(err) => {
warn!(error = ?err, "Failed to delete EVM grant"); warn!(error = ?err, "Failed to delete EVM grant");
EvmGrantDeleteResult::Error(ProtoEvmError::Internal.into()) EvmGrantDeleteResult::Error(ProtoEvmError::Internal.into())

View File

@@ -4,8 +4,9 @@ use arbiter_server::{
GlobalActors, GlobalActors,
bootstrap::GetToken, bootstrap::GetToken,
keyholder::Bootstrap, keyholder::Bootstrap,
user_agent::{AuthPublicKey, UserAgentConnection, auth}, user_agent::{AuthPublicKey, UserAgentConnection, UserAgentCredentials, auth},
}, },
crypto::integrity,
db::{self, schema}, db::{self, schema},
safe_cell::{SafeCell, SafeCellHandle as _}, safe_cell::{SafeCell, SafeCellHandle as _},
}; };
@@ -20,6 +21,13 @@ use super::common::ChannelTransport;
pub async fn test_bootstrap_token_auth() { pub async fn test_bootstrap_token_auth() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap(); 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 token = actors.bootstrapper.ask(GetToken).await.unwrap().unwrap();
let (server_transport, mut test_transport) = ChannelTransport::new(); let (server_transport, mut test_transport) = ChannelTransport::new();
@@ -99,18 +107,37 @@ pub async fn test_bootstrap_invalid_token_auth() {
pub async fn test_challenge_auth() { pub async fn test_challenge_auth() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap(); 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 new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec(); let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
{ {
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
insert_into(schema::useragent_client::table) let id: i32 = insert_into(schema::useragent_client::table)
.values(( .values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()), schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32), 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 .await
.unwrap(); .unwrap();
} }
@@ -187,7 +214,6 @@ pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed()
.values(( .values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()), schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32), schema::useragent_client::key_type.eq(1i32),
schema::useragent_client::pubkey_integrity_tag.eq(Some(vec![0u8; 32])),
)) ))
.execute(&mut conn) .execute(&mut conn)
.await .await
@@ -211,7 +237,7 @@ pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed()
assert!(matches!( assert!(matches!(
task.await.unwrap(), task.await.unwrap(),
Err(auth::Error::InvalidChallengeSolution) Err(auth::Error::Internal { .. })
)); ));
} }
@@ -220,18 +246,37 @@ pub async fn test_challenge_auth_rejects_integrity_tag_mismatch_when_unsealed()
pub async fn test_challenge_auth_rejects_invalid_signature() { pub async fn test_challenge_auth_rejects_invalid_signature() {
let db = db::create_test_pool().await; let db = db::create_test_pool().await;
let actors = GlobalActors::spawn(db.clone()).await.unwrap(); 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 new_key = ed25519_dalek::SigningKey::generate(&mut rand::rng());
let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec(); let pubkey_bytes = new_key.verifying_key().to_bytes().to_vec();
{ {
let mut conn = db.get().await.unwrap(); let mut conn = db.get().await.unwrap();
insert_into(schema::useragent_client::table) let id: i32 = insert_into(schema::useragent_client::table)
.values(( .values((
schema::useragent_client::public_key.eq(pubkey_bytes.clone()), schema::useragent_client::public_key.eq(pubkey_bytes.clone()),
schema::useragent_client::key_type.eq(1i32), 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 .await
.unwrap(); .unwrap();
} }

View File

@@ -152,42 +152,3 @@ pub async fn test_unseal_retry_after_invalid_key() {
assert!(matches!(response, Ok(()))); 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))
);
}
}