diff --git a/protobufs/client/auth.proto b/protobufs/client/auth.proto index f3d7d2d..382cd23 100644 --- a/protobufs/client/auth.proto +++ b/protobufs/client/auth.proto @@ -10,8 +10,8 @@ message AuthChallengeRequest { } message AuthChallenge { - bytes pubkey = 1; - int32 nonce = 2; + uint64 timestamp_nanos = 1; + bytes random = 2; } message AuthChallengeSolution { diff --git a/protobufs/user_agent/auth.proto b/protobufs/user_agent/auth.proto index 989e339..291084e 100644 --- a/protobufs/user_agent/auth.proto +++ b/protobufs/user_agent/auth.proto @@ -8,7 +8,8 @@ message AuthChallengeRequest { } message AuthChallenge { - int32 nonce = 1; + uint64 timestamp_nanos = 1; + bytes random = 2; } message AuthChallengeSolution { diff --git a/server/Cargo.lock b/server/Cargo.lock index 7ec7471..4b908ba 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -683,6 +683,7 @@ dependencies = [ "arbiter-crypto", "arbiter-proto", "async-trait", + "chrono", "http", "rand 0.10.1", "rustls-webpki", @@ -696,7 +697,7 @@ dependencies = [ name = "arbiter-crypto" version = "0.1.0" dependencies = [ - "base64", + "chrono", "memsafe", "ml-dsa", "rand 0.10.1", diff --git a/server/crates/arbiter-client/Cargo.toml b/server/crates/arbiter-client/Cargo.toml index 30f5d14..b2d3810 100644 --- a/server/crates/arbiter-client/Cargo.toml +++ b/server/crates/arbiter-client/Cargo.toml @@ -24,3 +24,4 @@ http = "1.4.0" rustls-webpki = { version = "0.103.10", features = ["aws-lc-rs"] } async-trait.workspace = true rand.workspace = true +chrono.workspace = true diff --git a/server/crates/arbiter-client/src/auth.rs b/server/crates/arbiter-client/src/auth.rs index e6068e5..d9d4dbb 100644 --- a/server/crates/arbiter-client/src/auth.rs +++ b/server/crates/arbiter-client/src/auth.rs @@ -1,4 +1,4 @@ -use arbiter_crypto::authn::{CLIENT_CONTEXT, SigningKey, format_challenge}; +use arbiter_crypto::authn::{self, CLIENT_CONTEXT, SigningKey}; use arbiter_proto::{ ClientMetadata, proto::{ @@ -15,6 +15,7 @@ use arbiter_proto::{ shared::ClientInfo as ProtoClientInfo, }, }; +use chrono::DateTime; use crate::{ storage::StorageError, @@ -23,6 +24,8 @@ use crate::{ #[derive(Debug, thiserror::Error)] pub enum AuthError { + #[error("Server sent invalid auth challenge")] + InvalidChallenge, #[error("Auth challenge was not returned by server")] MissingAuthChallenge, @@ -98,7 +101,15 @@ async fn send_auth_challenge_solution( key: &SigningKey, challenge: AuthChallenge, ) -> std::result::Result<(), AuthError> { - let challenge_payload = format_challenge(challenge.nonce, &challenge.pubkey); + let timestamp = DateTime::from_timestamp_nanos(challenge.timestamp_nanos as i64); + let challenge = authn::AuthChallenge { + nonce: *challenge + .random + .as_array() + .ok_or(AuthError::InvalidChallenge)?, + timestamp, + }; + let challenge_payload: Vec = challenge.format(); let signature = key .sign_message(&challenge_payload, CLIENT_CONTEXT) .map_err(|_| AuthError::UnexpectedAuthResponse)? diff --git a/server/crates/arbiter-crypto/Cargo.toml b/server/crates/arbiter-crypto/Cargo.toml index 238b3db..4f4c178 100644 --- a/server/crates/arbiter-crypto/Cargo.toml +++ b/server/crates/arbiter-crypto/Cargo.toml @@ -6,14 +6,14 @@ edition = "2024" [dependencies] ml-dsa = {workspace = true, optional = true } rand = {workspace = true, optional = true} -base64 = {workspace = true, optional = true } memsafe = {version = "0.4.0", optional = true} x-wing = { version = "0.1.0-rc.0", features = ["zeroize"] } +chrono.workspace = true [lints] workspace = true [features] default = ["authn", "safecell"] -authn = ["dep:ml-dsa", "dep:rand", "dep:base64"] +authn = ["dep:ml-dsa", "dep:rand"] safecell = ["dep:memsafe"] diff --git a/server/crates/arbiter-crypto/src/authn/v1.rs b/server/crates/arbiter-crypto/src/authn/v1.rs index ff65104..f192e76 100644 --- a/server/crates/arbiter-crypto/src/authn/v1.rs +++ b/server/crates/arbiter-crypto/src/authn/v1.rs @@ -1,17 +1,48 @@ use std::hash::Hash; -use base64::{Engine as _, prelude::BASE64_STANDARD}; +use chrono::{DateTime, Utc}; use ml_dsa::{ EncodedVerifyingKey, Error, KeyGen, MlDsa87, Seed, Signature as MlDsaSignature, SigningKey as MlDsaSigningKey, VerifyingKey as MlDsaVerifyingKey, signature::Keypair as _, }; +use rand::RngExt; pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client"; pub static USERAGENT_CONTEXT: &[u8] = b"arbiter_user_agent"; -pub fn format_challenge(nonce: i32, pubkey: &[u8]) -> Vec { - let concat_form = format!("{}:{}", nonce, BASE64_STANDARD.encode(pubkey)); - concat_form.into_bytes() +const NONCE_SIZE: usize = 32; + +#[derive(Debug, Clone)] +pub struct AuthChallenge { + pub nonce: [u8; NONCE_SIZE], + pub timestamp: DateTime, +} + +impl AuthChallenge { + pub fn generate(rng: &mut impl rand::CryptoRng) -> Self { + let timestamp = Utc::now(); + let nonce = { + let mut array = [0; NONCE_SIZE]; + rng.fill(&mut array); + array + }; + + Self { nonce, timestamp } + } + + pub fn format(&self) -> Vec { + { + let mut buffer = Vec::from(self.nonce); + + let stamp = self + .timestamp + .timestamp_nanos_opt() + .expect("We would be long dead by the time this triggers :)"); + buffer.extend_from_slice(stamp.to_be_bytes().as_slice()); + + buffer + } + } } pub type KeyParams = MlDsa87; @@ -36,12 +67,10 @@ impl PublicKey { self.0.encode().to_vec() } - pub fn verify(&self, nonce: i32, context: &[u8], signature: &Signature) -> bool { - self.0.verify_with_context( - &format_challenge(nonce, &self.to_bytes()), - context, - &signature.0, - ) + pub fn verify(&self, challenge: &AuthChallenge, context: &[u8], signature: &Signature) -> bool { + let challenge = challenge.format(); + self.0 + .verify_with_context(&challenge, context, &signature.0) } } @@ -75,11 +104,14 @@ impl SigningKey { .map(Into::into) } - pub fn sign_challenge(&self, nonce: i32, context: &[u8]) -> Result { - self.sign_message( - &format_challenge(nonce, &self.public_key().to_bytes()), - context, - ) + pub fn sign_challenge( + &self, + challenge: &AuthChallenge, + context: &[u8], + ) -> Result { + let challenge = challenge.format(); + + self.sign_message(&challenge, context) } } @@ -140,6 +172,8 @@ impl TryFrom<&'_ [u8]> for Signature { mod tests { use ml_dsa::{KeyGen, MlDsa87, signature::Keypair as _}; + use crate::authn::AuthChallenge; + use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, USERAGENT_CONTEXT}; #[test] @@ -169,13 +203,13 @@ mod tests { fn challenge_verification_uses_context_and_canonical_key_bytes() { let key = SigningKey::generate(); let public_key = key.public_key(); - let nonce = 17; + let challenge = AuthChallenge::generate(&mut rand::rng()); let signature = key - .sign_challenge(nonce, CLIENT_CONTEXT) + .sign_challenge(&challenge, CLIENT_CONTEXT) .expect("signature should be created"); - assert!(public_key.verify(nonce, CLIENT_CONTEXT, &signature)); - assert!(!public_key.verify(nonce, USERAGENT_CONTEXT, &signature)); + assert!(public_key.verify(&challenge, CLIENT_CONTEXT, &signature)); + assert!(!public_key.verify(&challenge, USERAGENT_CONTEXT, &signature)); } #[test] @@ -185,10 +219,16 @@ mod tests { assert_eq!(restored.public_key(), original.public_key()); + let challenge = AuthChallenge::generate(&mut rand::rng()); + let signature = restored - .sign_challenge(9, CLIENT_CONTEXT) + .sign_challenge(&challenge, CLIENT_CONTEXT) .expect("signature should be created"); - assert!(restored.public_key().verify(9, CLIENT_CONTEXT, &signature)); + assert!( + restored + .public_key() + .verify(&challenge, CLIENT_CONTEXT, &signature) + ); } } diff --git a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql index bb33278..dfc64d3 100644 --- a/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql +++ b/server/crates/arbiter-server/migrations/2026-02-14-171124-0000_init/up.sql @@ -45,9 +45,7 @@ insert into arbiter_settings (id) values (1) on conflict do nothing; 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, - key_type integer not null default(1), created_at integer not null default(unixepoch ('now')), updated_at integer not null default(unixepoch ('now')) ) STRICT; diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index dba14dc..00d16d8 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -195,7 +195,6 @@ pub struct ProgramClientMetadataHistory { #[diesel(table_name = schema::program_client, check_for_backend(Sqlite))] pub struct ProgramClient { pub id: i32, - pub nonce: i32, pub public_key: Vec, pub metadata_id: i32, pub created_at: SqliteTimestamp, @@ -206,7 +205,6 @@ pub struct ProgramClient { #[diesel(table_name = schema::useragent_client, check_for_backend(Sqlite))] pub struct UseragentClient { pub id: i32, - pub nonce: i32, pub public_key: Vec, pub created_at: SqliteTimestamp, pub updated_at: SqliteTimestamp, diff --git a/server/crates/arbiter-server/src/db/schema.rs b/server/crates/arbiter-server/src/db/schema.rs index c9b980c..f02c036 100644 --- a/server/crates/arbiter-server/src/db/schema.rs +++ b/server/crates/arbiter-server/src/db/schema.rs @@ -155,7 +155,6 @@ diesel::table! { diesel::table! { program_client (id) { id -> Integer, - nonce -> Integer, public_key -> Binary, metadata_id -> Integer, created_at -> Integer, @@ -189,7 +188,6 @@ diesel::table! { diesel::table! { useragent_client (id) { id -> Integer, - nonce -> Integer, public_key -> Binary, key_type -> Integer, created_at -> Integer, diff --git a/server/crates/arbiter-server/src/grpc/client/auth.rs b/server/crates/arbiter-server/src/grpc/client/auth.rs index 000a9db..fc16dbf 100644 --- a/server/crates/arbiter-server/src/grpc/client/auth.rs +++ b/server/crates/arbiter-server/src/grpc/client/auth.rs @@ -44,10 +44,14 @@ impl<'a> AuthTransportAdapter<'a> { fn response_to_proto(response: auth::Outbound) -> AuthResponsePayload { match response { - auth::Outbound::AuthChallenge { pubkey, nonce } => { + auth::Outbound::AuthChallenge { challenge } => { AuthResponsePayload::Challenge(ProtoAuthChallenge { - pubkey: pubkey.to_bytes(), - nonce, + timestamp_nanos: challenge + .timestamp + .timestamp_nanos_opt() + .expect("timestamp within range") + as u64, + random: challenge.nonce.to_vec(), }) } auth::Outbound::AuthSuccess => { diff --git a/server/crates/arbiter-server/src/grpc/user_agent/auth.rs b/server/crates/arbiter-server/src/grpc/user_agent/auth.rs index 53ee57d..f9e625e 100644 --- a/server/crates/arbiter-server/src/grpc/user_agent/auth.rs +++ b/server/crates/arbiter-server/src/grpc/user_agent/auth.rs @@ -19,7 +19,7 @@ use tracing::warn; use crate::{ grpc::request_tracker::RequestTracker, - peers::user_agent::{AuthCredentials, UserAgentConnection, auth}, + peers::user_agent::{Credentials, UserAgentConnection, auth}, }; pub struct AuthTransportAdapter<'a> { @@ -77,8 +77,15 @@ impl Sender> for AuthTransportAdapter<'_> { ) -> Result<(), TransportError> { use auth::{Error, Outbound}; let payload = match item { - Ok(Outbound::AuthChallenge { nonce }) => { - AuthResponsePayload::Challenge(ProtoAuthChallenge { nonce }) + Ok(Outbound::AuthChallenge { challenge }) => { + AuthResponsePayload::Challenge(ProtoAuthChallenge { + timestamp_nanos: challenge + .timestamp + .timestamp_nanos_opt() + .expect("timestamp within range") + as u64, + random: challenge.nonce.to_vec(), + }) } Ok(Outbound::AuthSuccess) => { AuthResponsePayload::Result(ProtoAuthResult::Success.into()) @@ -183,7 +190,7 @@ pub async fn start( conn: &mut UserAgentConnection, bi: &mut GrpcBi, request_tracker: &mut RequestTracker, -) -> Result { +) -> Result { let mut transport = AuthTransportAdapter::new(bi, request_tracker); auth::authenticate(conn, &mut transport).await } diff --git a/server/crates/arbiter-server/src/peers/client/auth.rs b/server/crates/arbiter-server/src/peers/client/auth.rs index 2152ace..98f6bd4 100644 --- a/server/crates/arbiter-server/src/peers/client/auth.rs +++ b/server/crates/arbiter-server/src/peers/client/auth.rs @@ -1,4 +1,4 @@ -use arbiter_crypto::authn::{self, CLIENT_CONTEXT}; +use arbiter_crypto::authn::{self, AuthChallenge, CLIENT_CONTEXT}; use arbiter_proto::{ ClientMetadata, transport::{Bi, expect_message}, @@ -74,19 +74,14 @@ pub enum Inbound { #[derive(Debug, Clone)] pub enum Outbound { - AuthChallenge { - pubkey: authn::PublicKey, - nonce: i32, - }, + AuthChallenge { challenge: AuthChallenge }, AuthSuccess, } -/// Returns the current nonce and client ID for a registered client. -/// Returns `None` if the pubkey is not registered. -async fn get_current_nonce_and_id( +async fn get_client_id( db: &db::DatabasePool, pubkey: &authn::PublicKey, -) -> Result, Error> { +) -> Result, Error> { let pubkey_bytes = pubkey.to_bytes(); let mut conn = db.get().await.map_err(|e| { error!(error = ?e, "Database pool error"); @@ -94,8 +89,8 @@ async fn get_current_nonce_and_id( })?; program_client::table .filter(program_client::public_key.eq(&pubkey_bytes)) - .select((program_client::id, program_client::nonce)) - .first::<(i32, i32)>(&mut conn) + .select(program_client::id) + .first::(&mut conn) .await .optional() .map_err(|e| { @@ -114,7 +109,7 @@ async fn verify_integrity( Error::DatabasePoolUnavailable })?; - let (id, nonce) = get_current_nonce_and_id(db, pubkey).await?.ok_or_else(|| { + let id = get_client_id(db, pubkey).await?.ok_or_else(|| { error!("Client not found during integrity verification"); Error::DatabaseOperationFailed })?; @@ -124,7 +119,6 @@ async fn verify_integrity( vault, &ClientCredentials { pubkey: pubkey.clone(), - nonce, }, id, ) @@ -142,53 +136,6 @@ async fn verify_integrity( Ok(()) } -/// 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, - vault: &ActorRef, - pubkey: &authn::PublicKey, -) -> Result { - let pubkey_bytes = pubkey.to_bytes(); - let pubkey = pubkey.clone(); - - let mut conn = db.get().await.map_err(|e| { - error!(error = ?e, "Database pool error"); - Error::DatabasePoolUnavailable - })?; - - conn.exclusive_transaction(|conn| { - let vault = vault.clone(); - let pubkey = pubkey.clone(); - Box::pin(async move { - let (id, new_nonce): (i32, i32) = update(program_client::table) - .filter(program_client::public_key.eq(&pubkey_bytes)) - .set(program_client::nonce.eq(program_client::nonce + 1)) - .returning((program_client::id, program_client::nonce)) - .get_result(conn) - .await?; - - integrity::sign_entity( - conn, - &vault, - &ClientCredentials { - pubkey: pubkey.clone(), - nonce: new_nonce, - }, - id, - ) - .await - .map_err(|e| { - error!(?e, "Integrity sign failed after nonce update"); - Error::DatabaseOperationFailed - })?; - - Ok(new_nonce) - }) - }) - .await -} - async fn approve_new_client(actors: &GlobalActors, profile: ClientProfile) -> Result<(), Error> { let result = actors .flow_coordinator @@ -228,8 +175,6 @@ async fn insert_client( let vault = vault.clone(); let pubkey = pubkey.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), @@ -244,7 +189,6 @@ async fn insert_client( .values(( program_client::public_key.eq(pubkey.to_bytes()), program_client::metadata_id.eq(metadata_id), - program_client::nonce.eq(NONCE_START), )) .on_conflict_do_nothing() .returning(program_client::id) @@ -256,7 +200,6 @@ async fn insert_client( &vault, &ClientCredentials { pubkey: pubkey.clone(), - nonce: NONCE_START, }, client_id, ) @@ -346,15 +289,14 @@ async fn sync_client_metadata( async fn challenge_client( transport: &mut T, pubkey: authn::PublicKey, - nonce: i32, + challenge: AuthChallenge, ) -> Result<(), Error> where T: Bi> + ?Sized, { transport .send(Ok(Outbound::AuthChallenge { - pubkey: pubkey.clone(), - nonce, + challenge: challenge.clone(), })) .await .map_err(|e| { @@ -372,7 +314,7 @@ where Error::Transport })?; - if !pubkey.verify(nonce, CLIENT_CONTEXT, &signature) { + if !pubkey.verify(&challenge, CLIENT_CONTEXT, &signature) { error!("Challenge solution verification failed"); return Err(Error::InvalidChallengeSolution); } @@ -388,8 +330,8 @@ where return Err(Error::Transport); }; - let client_id = match get_current_nonce_and_id(&props.db, &pubkey).await? { - Some((id, _)) => { + let client_id = match get_client_id(&props.db, &pubkey).await? { + Some(id) => { verify_integrity(&props.db, &props.actors.vault, &pubkey).await?; id } @@ -407,8 +349,9 @@ where }; sync_client_metadata(&props.db, client_id, &metadata).await?; - let challenge_nonce = create_nonce(&props.db, &props.actors.vault, &pubkey).await?; - challenge_client(transport, pubkey, challenge_nonce).await?; + + let challenge = AuthChallenge::generate(&mut rand::rng()); + challenge_client(transport, pubkey, challenge).await?; transport .send(Ok(Outbound::AuthSuccess)) diff --git a/server/crates/arbiter-server/src/peers/client/mod.rs b/server/crates/arbiter-server/src/peers/client/mod.rs index 79e79ff..f705752 100644 --- a/server/crates/arbiter-server/src/peers/client/mod.rs +++ b/server/crates/arbiter-server/src/peers/client/mod.rs @@ -18,7 +18,6 @@ pub struct ClientProfile { pub struct ClientCredentials { pub pubkey: authn::PublicKey, - pub nonce: i32, } impl Integrable for ClientCredentials { @@ -28,7 +27,6 @@ impl Integrable for ClientCredentials { impl Hashable for ClientCredentials { fn hash(&self, hasher: &mut H) { hasher.update(self.pubkey.to_bytes()); - self.nonce.hash(hasher); } } diff --git a/server/crates/arbiter-server/src/peers/user_agent/auth/mod.rs b/server/crates/arbiter-server/src/peers/user_agent/auth/mod.rs index 97a510a..8332b5b 100644 --- a/server/crates/arbiter-server/src/peers/user_agent/auth/mod.rs +++ b/server/crates/arbiter-server/src/peers/user_agent/auth/mod.rs @@ -1,11 +1,12 @@ -use arbiter_crypto::authn; +use arbiter_crypto::authn::{self, AuthChallenge}; use arbiter_proto::transport::Bi; use tracing::error; mod state; use state::*; -use super::{AuthCredentials, UserAgentConnection}; +use super::Credentials; +use super::UserAgentConnection; #[derive(Debug, Clone)] pub enum Inbound { @@ -44,7 +45,7 @@ impl From for Error { #[derive(Debug, Clone)] pub enum Outbound { - AuthChallenge { nonce: i32 }, + AuthChallenge { challenge: AuthChallenge }, AuthSuccess, } @@ -52,12 +53,11 @@ fn parse_auth_event(payload: Inbound) -> AuthEvents { match payload { Inbound::AuthChallengeRequest { pubkey, - bootstrap_token: None, - } => AuthEvents::AuthRequest(ChallengeRequest { pubkey }), - Inbound::AuthChallengeRequest { + bootstrap_token, + } => AuthEvents::AuthRequest(ChallengeRequest { pubkey, - bootstrap_token: Some(token), - } => AuthEvents::BootstrapAuthRequest(BootstrapAuthRequest { pubkey, token }), + bootstrap_token, + }), Inbound::AuthChallengeSolution { signature } => { AuthEvents::ReceivedSolution(ChallengeSolution { solution: signature, @@ -69,14 +69,13 @@ fn parse_auth_event(payload: Inbound) -> AuthEvents { pub async fn authenticate( props: &mut UserAgentConnection, transport: &mut T, -) -> Result +) -> Result where T: Bi> + Send + ?Sized, { let mut state = AuthStateMachine::new(AuthContext::new(props, transport)); loop { - // `state` holds a mutable reference to `props` so we can't access it directly here let Some(payload) = state.context_mut().transport.recv().await else { return Err(Error::Transport); }; diff --git a/server/crates/arbiter-server/src/peers/user_agent/auth/state.rs b/server/crates/arbiter-server/src/peers/user_agent/auth/state.rs index bb48291..04f8b2e 100644 --- a/server/crates/arbiter-server/src/peers/user_agent/auth/state.rs +++ b/server/crates/arbiter-server/src/peers/user_agent/auth/state.rs @@ -1,32 +1,26 @@ -use super::super::{AuthCredentials, Credentials, UserAgentConnection}; -use arbiter_crypto::authn::{self, USERAGENT_CONTEXT}; +use super::super::{Credentials, UserAgentConnection}; +use arbiter_crypto::authn::{self, AuthChallenge, USERAGENT_CONTEXT}; use arbiter_proto::transport::Bi; -use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl, sqlite::Sqlite, update}; -use diesel_async::{AsyncConnection, RunQueryDsl}; -use kameo::actor::ActorRef; +use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl}; +use diesel_async::RunQueryDsl; use tracing::error; use super::Error; -use crate::peers::user_agent::auth::Outbound; use crate::{ - actors::{bootstrap::ConsumeToken, vault::Vault}, - crypto::integrity, + actors::bootstrap::ConsumeToken, db::{DatabasePool, schema::useragent_client}, + peers::user_agent::auth::Outbound, }; pub struct ChallengeRequest { pub pubkey: authn::PublicKey, -} - -pub struct BootstrapAuthRequest { - pub pubkey: authn::PublicKey, - pub token: String, + pub bootstrap_token: Option, } pub struct ChallengeContext { - pub id: i32, - pub challenge_nonce: i32, - pub key: authn::PublicKey, + pub challenge: AuthChallenge, + pub pubkey: authn::PublicKey, + pub bootstrap_token: Option, } pub struct ChallengeSolution { @@ -38,119 +32,28 @@ smlang::statemachine!( custom_error: true, transitions: { *Init + AuthRequest(ChallengeRequest) / async prepare_challenge = SentChallenge(ChallengeContext), - Init + BootstrapAuthRequest(BootstrapAuthRequest) / async verify_bootstrap_token = AuthOk(AuthCredentials), - SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(AuthCredentials), + SentChallenge(ChallengeContext) + ReceivedSolution(ChallengeSolution) / async verify_solution = AuthOk(Credentials), } ); -const NONCE_START: i32 = 1; - -/// Returns the current nonce, ready to use for the challenge nonce. -async fn get_current_nonce_and_id( - db: &DatabasePool, - key: &authn::PublicKey, -) -> Result<(i32, i32), Error> { - let mut db_conn = db.get().await.map_err(|e| { +async fn get_client_id(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result, Error> { + let mut conn = db.get().await.map_err(|e| { error!(error = ?e, "Database pool error"); Error::internal("Database unavailable") })?; - db_conn - .exclusive_transaction(|conn| { - Box::pin(async move { - useragent_client::table - .filter(useragent_client::public_key.eq(key.to_bytes())) - .select((useragent_client::id, useragent_client::nonce)) - .first::<(i32, i32)>(conn) - .await - }) - }) + + useragent_client::table + .filter(useragent_client::public_key.eq(pubkey.to_bytes())) + .select(useragent_client::id) + .first::(&mut conn) .await .optional() .map_err(|e| { error!(error = ?e, "Database error"); Error::internal("Database operation failed") - })? - .ok_or_else(|| { - error!(?key, "Public key not found in database"); - Error::UnregisteredPublicKey }) } -async fn verify_integrity( - db: &DatabasePool, - vault: &ActorRef, - pubkey: &authn::PublicKey, -) -> 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, - vault, - &AuthCredentials { - creds: Credentials { - id, - pubkey: pubkey.clone(), - }, - new_nonce: nonce, - }, - id, - ) - .await - .map_err(|e| { - error!(?e, "Integrity verification failed"); - Error::internal("Integrity verification failed") - })?; - - Ok(()) -} - -async fn compute_current_nonce( - conn: &mut impl AsyncConnection, - pubkey: &authn::PublicKey, -) -> Result<(i32, i32), Error> { - update(useragent_client::table) - .filter(useragent_client::public_key.eq(pubkey.to_bytes())) - .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 incrementing nonce"); - Error::internal("Database operation failed") - }) -} - -async fn resign_credentials( - conn: &mut impl AsyncConnection, - vault: &ActorRef, - id: i32, - pubkey: &authn::PublicKey, - new_nonce: i32, -) -> Result<(), Error> { - integrity::sign_entity( - conn, - vault, - &AuthCredentials { - creds: Credentials { - id, - pubkey: pubkey.clone(), - }, - new_nonce, - }, - id, - ) - .await - .map_err(|e| { - error!(?e, "Integrity signature update failed"); - Error::internal("Database error") - }) -} - async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result { let pubkey_bytes = pubkey.to_bytes(); let mut conn = db.get().await.map_err(|e| { @@ -159,10 +62,7 @@ async fn register_key(db: &DatabasePool, pubkey: &authn::PublicKey) -> Result Result { - let is_signing = integrity::is_signing_available(&self.conn.actors.vault) - .await - .unwrap_or(false); - - if is_signing { - verify_integrity(&self.conn.db, &self.conn.actors.vault, &pubkey).await?; + // Verify pubkey is registered (unless bootstrapping) + if bootstrap_token.is_none() { + let id = get_client_id(&self.conn.db, &pubkey).await?; + if id.is_none() { + return Err(Error::UnregisteredPublicKey); + } } - let vault = self.conn.actors.vault.clone(); - let mut conn = self.conn.db.get().await.map_err(|e| { - error!(error = ?e, "Database pool error"); - Error::internal("Database unavailable") - })?; - - let (id, nonce) = conn - .exclusive_transaction(|conn| { - let pubkey = pubkey.clone(); - let vault = vault.clone(); - Box::pin(async move { - let (id, new_nonce) = compute_current_nonce(conn, &pubkey).await?; - if is_signing { - resign_credentials(conn, &vault, id, &pubkey, new_nonce).await?; - } - Result::<_, Error>::Ok((id, new_nonce)) - }) - }) - .await?; + let challenge = AuthChallenge::generate(&mut rand::rng()); self.transport - .send(Ok(Outbound::AuthChallenge { nonce })) + .send(Ok(Outbound::AuthChallenge { + challenge: challenge.clone(), + })) .await .map_err(|e| { error!(?e, "Failed to send auth challenge"); @@ -232,98 +119,78 @@ where })?; Ok(ChallengeContext { - id, - challenge_nonce: nonce, - key: pubkey, + challenge, + pubkey, + bootstrap_token, }) } - #[allow(missing_docs)] - #[allow(clippy::result_unit_err)] - async fn verify_bootstrap_token( - &mut self, - BootstrapAuthRequest { pubkey, token }: BootstrapAuthRequest, - ) -> Result { - let token_ok: bool = self - .conn - .actors - .bootstrapper - .ask(ConsumeToken { - token: token.clone(), - }) - .await - .map_err(|e| { - error!(?e, "Failed to consume bootstrap token"); - Error::internal("Failed to consume bootstrap token") - })?; - - if !token_ok { - error!("Invalid bootstrap token provided"); - return Err(Error::InvalidBootstrapToken); - } - - match token_ok { - true => { - let id = register_key(&self.conn.db, &pubkey).await?; - self.transport - .send(Ok(Outbound::AuthSuccess)) - .await - .map_err(|_| Error::Transport)?; - Ok(AuthCredentials { - creds: Credentials { id, pubkey }, - new_nonce: NONCE_START, - }) - } - 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)] async fn verify_solution( &mut self, ChallengeContext { - id, - challenge_nonce, - key, + challenge, + pubkey, + bootstrap_token, }: &ChallengeContext, ChallengeSolution { solution }: ChallengeSolution, - ) -> Result { + ) -> Result { let signature = authn::Signature::try_from(solution.as_slice()).map_err(|_| { error!("Failed to decode signature in challenge solution"); Error::InvalidChallengeSolution })?; - let valid = key.verify(*challenge_nonce, USERAGENT_CONTEXT, &signature); + let valid = pubkey.verify(challenge, USERAGENT_CONTEXT, &signature); - match valid { - true => { - self.transport - .send(Ok(Outbound::AuthSuccess)) - .await - .map_err(|_| Error::Transport)?; - Ok(AuthCredentials { - creds: Credentials { - id: *id, - pubkey: key.clone(), - }, - new_nonce: *challenge_nonce, - }) - } - false => { - self.transport - .send(Err(Error::InvalidChallengeSolution)) - .await - .map_err(|_| Error::Transport)?; - Err(Error::InvalidChallengeSolution) - } + if !valid { + self.transport + .send(Err(Error::InvalidChallengeSolution)) + .await + .map_err(|_| Error::Transport)?; + return Err(Error::InvalidChallengeSolution); } + + // Resolve client id: bootstrap (consume token + register) or lookup + let id = match bootstrap_token { + Some(token) => { + let token_ok: bool = self + .conn + .actors + .bootstrapper + .ask(ConsumeToken { + token: token.clone(), + }) + .await + .map_err(|e| { + error!(?e, "Failed to consume bootstrap token"); + Error::internal("Failed to consume bootstrap token") + })?; + + if !token_ok { + error!("Invalid bootstrap token provided"); + self.transport + .send(Err(Error::InvalidBootstrapToken)) + .await + .map_err(|_| Error::Transport)?; + return Err(Error::InvalidBootstrapToken); + } + + register_key(&self.conn.db, pubkey).await? + } + None => get_client_id(&self.conn.db, pubkey) + .await? + .ok_or(Error::UnregisteredPublicKey)?, + }; + + self.transport + .send(Ok(Outbound::AuthSuccess)) + .await + .map_err(|_| Error::Transport)?; + + Ok(Credentials { + id, + pubkey: pubkey.clone(), + }) } } diff --git a/server/crates/arbiter-server/src/peers/user_agent/mod.rs b/server/crates/arbiter-server/src/peers/user_agent/mod.rs index a8e813c..91a6d75 100644 --- a/server/crates/arbiter-server/src/peers/user_agent/mod.rs +++ b/server/crates/arbiter-server/src/peers/user_agent/mod.rs @@ -1,7 +1,10 @@ use crate::{ - actors::GlobalActors, - crypto::integrity::{self, Integrable}, - db::{self, DatabaseError}, + actors::{ + GlobalActors, + vault::{GetState, Vault}, + }, + crypto::integrity::{self, AttestationStatus, Integrable}, + db::{self, DatabaseError, DatabasePool}, peers::client::ClientProfile, }; use arbiter_crypto::authn; @@ -11,7 +14,7 @@ pub use auth::authenticate; use kameo::actor::{ActorRef, Spawn as _}; pub use session::UserAgentSession; use tokio::sync::oneshot; -use tracing::warn; +use tracing::{error, warn}; use vault_gate::VaultGate; use crate::crypto::integrity::hashing::Hashable; @@ -20,24 +23,11 @@ pub mod auth; pub mod session; pub mod vault_gate; -#[derive(Debug, Clone, Hash)] +#[derive(Debug, Clone)] pub struct Credentials { pub id: i32, pub pubkey: authn::PublicKey, } -impl Hashable for Credentials { - fn hash(&self, hasher: &mut H) { - self.id.hash(hasher); - self.pubkey.hash(hasher); - } -} - -#[derive(Debug, Clone)] -pub struct AuthCredentials { - pub creds: Credentials, - // denotes new nonce, not current - pub new_nonce: i32, -} impl Hashable for authn::PublicKey { fn hash(&self, hasher: &mut H) { @@ -45,14 +35,14 @@ impl Hashable for authn::PublicKey { } } -impl Hashable for AuthCredentials { +impl Hashable for Credentials { fn hash(&self, hasher: &mut H) { - self.creds.hash(hasher); - self.new_nonce.hash(hasher); + self.id.hash(hasher); + self.pubkey.hash(hasher); } } -impl Integrable for AuthCredentials { +impl Integrable for Credentials { const KIND: &'static str = "useragent_credentials"; } @@ -95,38 +85,44 @@ impl From for Error { } } -pub async fn start( - props: &mut UserAgentConnection, - mut transport: T, - oob_sender: Box>, -) -> Result, Error> -where - T: Bi> + Send, - T: Bi> + Send, -{ - let auth_creds = authenticate(props, &mut transport).await?; - - let creds = match integrity::is_signing_available(&props.actors.vault) +async fn verify_integrity( + db: &DatabasePool, + vault: &ActorRef, + credentials: &Credentials, +) -> Result<(), Error> { + let mut conn = db + .get() .await - .map_err(|_| Error::Internal("Integrity verification failed".into()))? - { - // credentials were checked by `auth` stage - true => auth_creds.creds, - false => run_vault_gate(props, &mut transport, auth_creds).await?, - }; + .map_err(|_| Error::Internal("DB unavailable".into()))?; + match integrity::verify_entity(&mut conn, &vault, credentials, credentials.id).await { + Ok(AttestationStatus::Attested) => Ok(()), + Ok(AttestationStatus::Unavailable) => { + Err(Error::Internal("Vault sealed during promotion".into())) + } + Err(e) => { + error!(?e, "Integrity verification failed during unseal promotion"); + Err(Error::Internal("Integrity check failed".into())) + } + } +} - Ok(UserAgentSession::spawn(UserAgentSession::new( - props.clone(), - creds, - oob_sender, - ))) +async fn should_run_gate(vault: &ActorRef) -> Result { + let vault_state = vault + .ask(GetState {}) + .await + .map_err(|_| Error::Internal("Failed to contact the vault".into()))?; + + Ok(!matches!( + vault_state, + crate::actors::vault::VaultState::Unsealed + )) } async fn run_vault_gate( props: &UserAgentConnection, transport: &mut T, - auth_creds: AuthCredentials, -) -> Result + auth_creds: Credentials, +) -> Result<(), Error> where T: Bi> + Send + ?Sized, { @@ -175,3 +171,29 @@ where gate.kill(); result } + +pub async fn start( + props: &mut UserAgentConnection, + mut transport: T, + oob_sender: Box>, +) -> Result, Error> +where + T: Bi> + Send, + T: Bi> + Send, +{ + let creds = authenticate(props, &mut transport).await?; + + // should run vault gate only if sealed / unbootstrapped + if should_run_gate(&props.actors.vault).await? { + run_vault_gate(props, &mut transport, creds.clone()).await?; + } + + // checking the integrity + verify_integrity(&props.db, &props.actors.vault, &creds).await?; + + Ok(UserAgentSession::spawn(UserAgentSession::new( + props.clone(), + creds, + oob_sender, + ))) +} diff --git a/server/crates/arbiter-server/src/peers/user_agent/session/handlers.rs b/server/crates/arbiter-server/src/peers/user_agent/session/handlers.rs index 53b65e0..1fcbd74 100644 --- a/server/crates/arbiter-server/src/peers/user_agent/session/handlers.rs +++ b/server/crates/arbiter-server/src/peers/user_agent/session/handlers.rs @@ -1,27 +1,22 @@ -use std::sync::Mutex; use alloy::{consensus::TxEip1559, primitives::Address, signers::Signature}; use arbiter_crypto::{ authn, - safecell::{SafeCell, SafeCellHandle as _}, + safecell::SafeCellHandle as _, }; -use chacha20poly1305::{AeadInPlace, XChaCha20Poly1305, XNonce, aead::KeyInit}; +use chacha20poly1305::aead::KeyInit; use diesel::{ExpressionMethods as _, QueryDsl as _, SelectableHelper}; use diesel_async::{AsyncConnection, RunQueryDsl}; use kameo::error::SendError; use kameo::messages; use kameo::prelude::Context; -use tracing::{error, info}; -use x25519_dalek::{EphemeralSecret, PublicKey}; +use tracing::error; use crate::actors::flow_coordinator::client_connect_approval::ClientApprovalAnswer; -use crate::actors::{ - evm::{ +use crate::actors::evm::{ ClientSignTransaction, Generate, ListWallets, SignTransactionError as EvmSignError, UseragentCreateGrant, UseragentListGrants, - }, - vault::{self, Bootstrap, TryUnseal}, -}; + }; use crate::db::models::{ EvmWalletAccess, NewEvmWalletAccess, ProgramClient, ProgramClientMetadata, }; diff --git a/server/crates/arbiter-server/src/peers/user_agent/session/mod.rs b/server/crates/arbiter-server/src/peers/user_agent/session/mod.rs index 7356d04..d603bff 100644 --- a/server/crates/arbiter-server/src/peers/user_agent/session/mod.rs +++ b/server/crates/arbiter-server/src/peers/user_agent/session/mod.rs @@ -1,12 +1,9 @@ use arbiter_crypto::authn; -use diesel::{ExpressionMethods, QueryDsl}; -use diesel_async::{RunQueryDsl}; -use kameo_actors::message_bus::Register; use std::{borrow::Cow, collections::HashMap}; use arbiter_proto::transport::Sender; -use kameo::{Actor, actor::ActorRef, messages, prelude::Message}; +use kameo::{Actor, actor::ActorRef, messages}; use thiserror::Error; use tracing::error; @@ -14,8 +11,8 @@ use crate::{ actors::{ flow_coordinator::client_connect_approval::ClientApprovalController, useragent_registry::ConnectUseragent, - vault::events, - }, crypto::integrity, db::schema::useragent_client, peers::{client::ClientProfile, user_agent::{AuthCredentials, Credentials}} + }, + peers::{client::ClientProfile, user_agent::Credentials}, }; use super::{OutOfBand, UserAgentConnection}; diff --git a/server/crates/arbiter-server/src/peers/user_agent/vault_gate/mod.rs b/server/crates/arbiter-server/src/peers/user_agent/vault_gate/mod.rs index dfd461c..28ce4d0 100644 --- a/server/crates/arbiter-server/src/peers/user_agent/vault_gate/mod.rs +++ b/server/crates/arbiter-server/src/peers/user_agent/vault_gate/mod.rs @@ -1,22 +1,24 @@ use arbiter_crypto::safecell::{SafeCell, SafeCellHandle as _}; +use arbiter_proto::transport::Bi; use chacha20poly1305::{AeadInPlace, KeyInit as _, XChaCha20Poly1305, XNonce}; use kameo::{Actor, error::SendError, messages, prelude::Message}; use kameo_actors::message_bus::Register; use tokio::sync::oneshot; -use tracing::{error, info}; +use tracing::{error, info, warn}; use x25519_dalek::{EphemeralSecret, PublicKey, SharedSecret}; pub mod state; use state::*; -use super::{AuthCredentials, Credentials}; +use super::Credentials; use crate::{ actors::{ GlobalActors, vault::{self, Bootstrap, GetState, TryUnseal, VaultState, events}, }, - crypto::integrity::{self, AttestationStatus}, + crypto::integrity::{self}, db::DatabasePool, + peers::user_agent::UserAgentConnection, }; #[derive(Debug, thiserror::Error)] @@ -43,8 +45,8 @@ pub struct HandshakeResponse { } pub struct VaultGate { - pub auth_creds: AuthCredentials, - pub promotion_tx: Option>>, + pub auth_creds: Credentials, + pub promotion_tx: Option>>, pub state: State, pub actors: GlobalActors, pub db: DatabasePool, @@ -52,10 +54,10 @@ pub struct VaultGate { impl VaultGate { pub fn new( - auth_creds: AuthCredentials, + auth_creds: Credentials, actors: GlobalActors, db: DatabasePool, - promotion_tx: oneshot::Sender>, + promotion_tx: oneshot::Sender>, ) -> Self { Self { auth_creds, @@ -260,14 +262,14 @@ impl Message for VaultGate { &mut conn, &self.actors.vault, &self.auth_creds, - self.auth_creds.creds.id, + self.auth_creds.id, ) .await .map_err(|e| { error!(?e, "Failed to sign integrity envelope on bootstrap"); Error::internal("Integrity sign failed") })?; - Ok(self.auth_creds.creds.clone()) + Ok(()) } .await; @@ -286,34 +288,8 @@ impl Message for VaultGate { _: events::Unsealed, ctx: &mut kameo::prelude::Context, ) -> Self::Reply { - let result = async { - let mut conn = self - .db - .get() - .await - .map_err(|_| Error::internal("DB unavailable"))?; - match integrity::verify_entity( - &mut conn, - &self.actors.vault, - &self.auth_creds, - self.auth_creds.creds.id, - ) - .await - { - Ok(AttestationStatus::Attested) => Ok(self.auth_creds.creds.clone()), - Ok(AttestationStatus::Unavailable) => { - Err(Error::internal("Vault sealed during promotion")) - } - Err(e) => { - error!(?e, "Integrity verification failed during unseal promotion"); - Err(Error::InvalidKey) - } - } - } - .await; - if let Some(tx) = self.promotion_tx.take() { - let _ = tx.send(result); + let _ = tx.send(Ok(())); } ctx.stop(); } diff --git a/server/crates/arbiter-server/src/peers/user_agent/vault_gate/state.rs b/server/crates/arbiter-server/src/peers/user_agent/vault_gate/state.rs index 214f877..cc38dff 100644 --- a/server/crates/arbiter-server/src/peers/user_agent/vault_gate/state.rs +++ b/server/crates/arbiter-server/src/peers/user_agent/vault_gate/state.rs @@ -1,6 +1,5 @@ -use std::sync::Mutex; -use x25519_dalek::{EphemeralSecret, PublicKey, SharedSecret}; +use x25519_dalek::{PublicKey, SharedSecret}; pub struct Handshake { client_pubkey: PublicKey, diff --git a/server/crates/arbiter-server/tests/client/auth.rs b/server/crates/arbiter-server/tests/client/auth.rs index 71f989c..a6b0773 100644 --- a/server/crates/arbiter-server/tests/client/auth.rs +++ b/server/crates/arbiter-server/tests/client/auth.rs @@ -1,5 +1,5 @@ use arbiter_crypto::{ - authn::{self, CLIENT_CONTEXT, format_challenge}, + authn::{self, AuthChallenge, CLIENT_CONTEXT}, safecell::{SafeCell, SafeCellHandle as _}, }; use arbiter_proto::ClientMetadata; @@ -66,12 +66,8 @@ async fn insert_registered_client( .unwrap(); } -fn sign_client_challenge( - key: &SigningKey, - nonce: i32, - pubkey: &authn::PublicKey, -) -> authn::Signature { - let challenge = format_challenge(nonce, &pubkey.to_bytes()); +fn sign_client_challenge(key: &SigningKey, challenge: &AuthChallenge) -> authn::Signature { + let challenge = challenge.format(); key.signing_key() .sign_deterministic(&challenge, CLIENT_CONTEXT) .unwrap() diff --git a/server/crates/arbiter-server/tests/user_agent/auth.rs b/server/crates/arbiter-server/tests/user_agent/auth.rs index cfc4b53..d461aa3 100644 --- a/server/crates/arbiter-server/tests/user_agent/auth.rs +++ b/server/crates/arbiter-server/tests/user_agent/auth.rs @@ -8,7 +8,7 @@ use arbiter_server::{ actors::{GlobalActors, bootstrap::GetToken, vault::Bootstrap}, crypto::integrity, db::{self, schema}, - peers::user_agent::{AuthCredentials, Credentials, UserAgentConnection, auth}, + peers::user_agent::{Credentials, Credentials, UserAgentConnection, auth}, }; use diesel::{ExpressionMethods as _, QueryDsl, insert_into}; use diesel_async::RunQueryDsl; @@ -144,7 +144,7 @@ pub async fn test_challenge_auth() { integrity::sign_entity( &mut conn, &actors.vault, - &AuthCredentials { + &Credentials { creds: Credentials { id, pubkey: new_key.verifying_key().into(), @@ -285,7 +285,7 @@ pub async fn test_challenge_auth_rejects_invalid_signature() { integrity::sign_entity( &mut conn, &actors.vault, - &AuthCredentials { + &Credentials { creds: Credentials { id, pubkey: new_key.verifying_key().into(), diff --git a/server/crates/arbiter-server/tests/user_agent/unseal.rs b/server/crates/arbiter-server/tests/user_agent/unseal.rs index b63de98..ae7ee7a 100644 --- a/server/crates/arbiter-server/tests/user_agent/unseal.rs +++ b/server/crates/arbiter-server/tests/user_agent/unseal.rs @@ -9,8 +9,10 @@ use arbiter_server::{ }, db, peers::user_agent::{ - AuthCredentials, Credentials, - vault_gate::{Error as VaultGateError, HandleHandshake, HandleUnsealEncryptedKey, VaultGate}, + Credentials, + vault_gate::{ + Error as VaultGateError, HandleHandshake, HandleUnsealEncryptedKey, VaultGate, + }, }, }; @@ -21,7 +23,11 @@ use x25519_dalek::{EphemeralSecret, PublicKey}; async fn setup_sealed_gate( seal_key: &[u8], -) -> (db::DatabasePool, kameo::actor::ActorRef, oneshot::Receiver>) { +) -> ( + db::DatabasePool, + kameo::actor::ActorRef, + oneshot::Receiver>, +) { let db = db::create_test_pool().await; let actors = GlobalActors::spawn(db.clone()).await.unwrap(); @@ -36,10 +42,7 @@ async fn setup_sealed_gate( let (promotion_tx, promotion_rx) = oneshot::channel(); let pubkey = authn::SigningKey::generate().public_key(); - let auth_creds = AuthCredentials { - creds: Credentials { id: 1, pubkey }, - new_nonce: 1, - }; + let auth_creds = Credentials { id: 1, pubkey }; let gate = VaultGate::spawn(VaultGate::new(auth_creds, actors, db.clone(), promotion_tx)); (db, gate, promotion_rx)