diff --git a/server/Cargo.lock b/server/Cargo.lock index 63b4afa..b8ee5e4 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -707,6 +707,7 @@ dependencies = [ "memsafe", "ml-dsa", "rand 0.10.1", + "strum 0.28.0", "thiserror", "x-wing", ] diff --git a/server/Cargo.toml b/server/Cargo.toml index d270e3e..0cadbce 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -27,6 +27,7 @@ rustls = { version = "0.23.40", features = ["aws-lc-rs", "logging", "prefer-post rustls-pki-types = "1.14.1" sha2 = "0.11" smlang = "0.8.0" +strum = { version = "0.28.0", features = ["derive"] } thiserror = "2.0.18" tokio = { version = "1.52.1", features = ["full"] } tokio-stream = { version = "0.1.18", features = ["full"] } diff --git a/server/crates/arbiter-client/src/auth.rs b/server/crates/arbiter-client/src/auth.rs index eae51e9..6829302 100644 --- a/server/crates/arbiter-client/src/auth.rs +++ b/server/crates/arbiter-client/src/auth.rs @@ -2,7 +2,7 @@ use crate::{ storage::StorageError, transport::{ClientTransport, next_request_id}, }; -use arbiter_crypto::authn::{self, CLIENT_CONTEXT, SigningKey}; +use arbiter_crypto::authn::{self, SigningContext, SigningKey}; use arbiter_proto::{ ClientMetadata, proto::{ @@ -110,7 +110,7 @@ async fn send_auth_challenge_solution( }; let challenge_payload: Vec = challenge.format(); let signature = key - .sign_message(&challenge_payload, CLIENT_CONTEXT) + .sign_message(&challenge_payload, SigningContext::Client) .map_err(|_| AuthError::UnexpectedAuthResponse)? .to_bytes(); diff --git a/server/crates/arbiter-crypto/Cargo.toml b/server/crates/arbiter-crypto/Cargo.toml index 3cbe8ec..2238670 100644 --- a/server/crates/arbiter-crypto/Cargo.toml +++ b/server/crates/arbiter-crypto/Cargo.toml @@ -7,6 +7,7 @@ edition = "2024" ml-dsa = {workspace = true, optional = true } rand = {workspace = true, optional = true} memsafe = {version = "0.4.0", optional = true} +strum = { workspace = true, optional = true } hmac.workspace = true alloy.workspace = true x-wing = { version = "0.1.0-rc.0", features = ["zeroize"] } @@ -18,7 +19,7 @@ workspace = true [features] default = ["authn", "safecell"] -authn = ["dep:ml-dsa", "dep:rand"] +authn = ["dep:ml-dsa", "dep:rand", "dep:strum"] safecell = ["dep:memsafe"] [lib] diff --git a/server/crates/arbiter-crypto/src/authn/v1.rs b/server/crates/arbiter-crypto/src/authn/v1.rs index 6f86936..5e147e8 100644 --- a/server/crates/arbiter-crypto/src/authn/v1.rs +++ b/server/crates/arbiter-crypto/src/authn/v1.rs @@ -5,10 +5,25 @@ use ml_dsa::{ SigningKey as MlDsaSigningKey, VerifyingKey as MlDsaVerifyingKey, signature::Keypair as _, }; use rand::RngExt; +use strum::IntoStaticStr; -pub static CLIENT_CONTEXT: &[u8] = b"arbiter_client"; -pub static OPERATOR_CONTEXT: &[u8] = b"arbiter_operator"; -pub static GOVERNANCE_CONTEXT: &[u8] = b"arbiter_governance_vote"; +/// Domain separation tag mixed into every ML-DSA signature. +#[derive(Debug, Clone, Copy, PartialEq, Eq, IntoStaticStr)] +pub enum SigningContext { + #[strum(serialize = "arbiter_client")] + Client, + #[strum(serialize = "arbiter_operator")] + Operator, + #[strum(serialize = "arbiter_governance_vote")] + GovernanceVote, +} + +impl SigningContext { + #[must_use] + pub fn as_bytes(self) -> &'static [u8] { + <&'static str>::from(self).as_bytes() + } +} const NONCE_SIZE: usize = 32; @@ -86,15 +101,26 @@ impl PublicKey { } #[must_use] - pub fn verify(&self, challenge: &AuthChallenge, context: &[u8], signature: &Signature) -> bool { + pub fn verify( + &self, + challenge: &AuthChallenge, + context: SigningContext, + signature: &Signature, + ) -> bool { let challenge = challenge.format(); self.0 - .verify_with_context(&challenge, context, &signature.0) + .verify_with_context(&challenge, context.as_bytes(), &signature.0) } #[must_use] - pub fn verify_message(&self, message: &[u8], context: &[u8], signature: &Signature) -> bool { - self.0.verify_with_context(message, context, &signature.0) + pub fn verify_message( + &self, + message: &[u8], + context: SigningContext, + signature: &Signature, + ) -> bool { + self.0 + .verify_with_context(message, context.as_bytes(), &signature.0) } } @@ -121,17 +147,21 @@ impl SigningKey { self.0.verifying_key().into() } - pub fn sign_message(&self, message: &[u8], context: &[u8]) -> Result { + pub fn sign_message( + &self, + message: &[u8], + context: SigningContext, + ) -> Result { self.0 .signing_key() - .sign_deterministic(message, context) + .sign_deterministic(message, context.as_bytes()) .map(Into::into) } pub fn sign_challenge( &self, challenge: &AuthChallenge, - context: &[u8], + context: SigningContext, ) -> Result { let challenge = challenge.format(); @@ -198,7 +228,7 @@ mod tests { use crate::authn::AuthChallenge; - use super::{CLIENT_CONTEXT, PublicKey, Signature, SigningKey, OPERATOR_CONTEXT}; + use super::{PublicKey, Signature, SigningContext, SigningKey}; #[test] fn public_key_round_trip_decodes() { @@ -214,7 +244,7 @@ mod tests { fn signature_round_trip_decodes() { let key = SigningKey::generate(); let signature = key - .sign_message(b"challenge", CLIENT_CONTEXT) + .sign_message(b"challenge", SigningContext::Client) .expect("signature should be created"); let decoded = @@ -229,11 +259,11 @@ mod tests { let public_key = key.public_key(); let challenge = AuthChallenge::generate(&mut rand::rng()); let signature = key - .sign_challenge(&challenge, CLIENT_CONTEXT) + .sign_challenge(&challenge, SigningContext::Client) .expect("signature should be created"); - assert!(public_key.verify(&challenge, CLIENT_CONTEXT, &signature)); - assert!(!public_key.verify(&challenge, OPERATOR_CONTEXT, &signature)); + assert!(public_key.verify(&challenge, SigningContext::Client, &signature)); + assert!(!public_key.verify(&challenge, SigningContext::Operator, &signature)); } #[test] @@ -246,13 +276,13 @@ mod tests { let challenge = AuthChallenge::generate(&mut rand::rng()); let signature = restored - .sign_challenge(&challenge, CLIENT_CONTEXT) + .sign_challenge(&challenge, SigningContext::Client) .expect("signature should be created"); assert!( restored .public_key() - .verify(&challenge, CLIENT_CONTEXT, &signature) + .verify(&challenge, SigningContext::Client, &signature) ); } } diff --git a/server/crates/arbiter-server/Cargo.toml b/server/crates/arbiter-server/Cargo.toml index 8e4225f..ee58227 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -37,7 +37,7 @@ kameo.workspace = true chacha20poly1305 = { version = "0.10.1", features = ["std"] } argon2 = { version = "0.5.3", features = ["zeroize"] } restructed = "0.2.2" -strum = { version = "0.28.0", features = ["derive"] } +strum.workspace = true pem = "3.0.6" sha2.workspace = true hmac.workspace = true diff --git a/server/crates/arbiter-server/src/actors/proposal_manager.rs b/server/crates/arbiter-server/src/actors/proposal_manager.rs index 21e69f9..d1b2d7b 100644 --- a/server/crates/arbiter-server/src/actors/proposal_manager.rs +++ b/server/crates/arbiter-server/src/actors/proposal_manager.rs @@ -336,7 +336,7 @@ impl ProposalManager { approve: bool, signature: Vec, ) -> Result { - use arbiter_crypto::authn::{self, GOVERNANCE_CONTEXT}; + use arbiter_crypto::authn::{self, SigningContext}; let mut conn = self.db.get().await?; @@ -391,7 +391,7 @@ impl ProposalManager { let auth_sig = authn::Signature::try_from(signature.as_slice()) .map_err(|()| Error::InvalidSignature)?; - if !pubkey.verify_message(&vote_msg, GOVERNANCE_CONTEXT, &auth_sig) { + if !pubkey.verify_message(&vote_msg, SigningContext::GovernanceVote, &auth_sig) { return Err(Error::InvalidSignature); } @@ -537,7 +537,7 @@ impl ProposalManager { approve: bool, signature: Vec, ) -> Result { - use arbiter_crypto::authn::{self, GOVERNANCE_CONTEXT}; + use arbiter_crypto::authn::{self, SigningContext}; let mut conn = self.db.get().await?; @@ -596,7 +596,7 @@ impl ProposalManager { let auth_sig = authn::Signature::try_from(signature.as_slice()) .map_err(|()| Error::InvalidSignature)?; - if !pubkey.verify_message(&vote_msg, GOVERNANCE_CONTEXT, &auth_sig) { + if !pubkey.verify_message(&vote_msg, SigningContext::GovernanceVote, &auth_sig) { return Err(Error::InvalidSignature); } diff --git a/server/crates/arbiter-server/src/peers/client/auth.rs b/server/crates/arbiter-server/src/peers/client/auth.rs index f488161..c27c2b1 100644 --- a/server/crates/arbiter-server/src/peers/client/auth.rs +++ b/server/crates/arbiter-server/src/peers/client/auth.rs @@ -12,7 +12,7 @@ use crate::{ schema::program_client, }, }; -use arbiter_crypto::authn::{self, AuthChallenge, CLIENT_CONTEXT}; +use arbiter_crypto::authn::{self, AuthChallenge, SigningContext}; use arbiter_proto::{ ClientMetadata, transport::{Bi, expect_message}, @@ -306,7 +306,7 @@ where Error::Transport })?; - if !pubkey.verify(&challenge, CLIENT_CONTEXT, &signature) { + if !pubkey.verify(&challenge, SigningContext::Client, &signature) { error!("Challenge solution verification failed"); return Err(Error::InvalidChallengeSolution); } diff --git a/server/crates/arbiter-server/src/peers/operator/auth/state.rs b/server/crates/arbiter-server/src/peers/operator/auth/state.rs index a7c0ae7..ab74c3e 100644 --- a/server/crates/arbiter-server/src/peers/operator/auth/state.rs +++ b/server/crates/arbiter-server/src/peers/operator/auth/state.rs @@ -7,7 +7,7 @@ use crate::{ db::{DatabasePool, schema::operator_identity}, peers::operator::auth::Outbound, }; -use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT}; +use arbiter_crypto::authn::{self, AuthChallenge, SigningContext}; use arbiter_proto::transport::Bi; use diesel::{ExpressionMethods as _, OptionalExtension as _, QueryDsl}; @@ -141,7 +141,7 @@ where Error::InvalidChallengeSolution })?; - let valid = pubkey.verify(challenge, OPERATOR_CONTEXT, &signature); + let valid = pubkey.verify(challenge, SigningContext::Operator, &signature); if !valid { self.transport diff --git a/server/crates/arbiter-server/tests/client/auth.rs b/server/crates/arbiter-server/tests/client/auth.rs index facc4e5..945e82b 100644 --- a/server/crates/arbiter-server/tests/client/auth.rs +++ b/server/crates/arbiter-server/tests/client/auth.rs @@ -1,5 +1,5 @@ use super::common::ChannelTransport; -use arbiter_crypto::authn::{self, AuthChallenge, CLIENT_CONTEXT}; +use arbiter_crypto::authn::{self, AuthChallenge, SigningContext}; use arbiter_proto::{ ClientMetadata, transport::{Receiver, Sender}, @@ -71,7 +71,7 @@ async fn insert_registered_client( fn sign_client_challenge(key: &SigningKey, challenge: &AuthChallenge) -> authn::Signature { let challenge = challenge.format(); key.signing_key() - .sign_deterministic(&challenge, CLIENT_CONTEXT) + .sign_deterministic(&challenge, SigningContext::Client.as_bytes()) .unwrap() .into() } diff --git a/server/crates/arbiter-server/tests/governance.rs b/server/crates/arbiter-server/tests/governance.rs index 6f6c68e..75aac57 100644 --- a/server/crates/arbiter-server/tests/governance.rs +++ b/server/crates/arbiter-server/tests/governance.rs @@ -1,4 +1,4 @@ -use arbiter_crypto::authn::{self, GOVERNANCE_CONTEXT}; +use arbiter_crypto::authn::{self, SigningContext}; use arbiter_server::{ actors::{ GlobalActors, @@ -159,7 +159,9 @@ async fn single_operator_vote_reaches_quorum() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager @@ -203,7 +205,9 @@ async fn two_operator_first_vote_is_pending() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = key1.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = key1 + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager @@ -246,7 +250,9 @@ async fn duplicate_vote_rejected() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); actors .proposal_manager .ask(CastVote { @@ -259,7 +265,9 @@ async fn duplicate_vote_rejected() { .unwrap(); // Second vote same operator - let sig2 = key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig2 = key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let result = actors .proposal_manager .ask(CastVote { @@ -357,7 +365,9 @@ async fn query_pending_excludes_already_voted() { // Vote on p1 — with 1 operator this reaches quorum (QuorumApproved) let msg = make_vote_message(p1, true); - let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager .ask(CastVote { @@ -418,7 +428,9 @@ async fn expired_proposal_is_hidden_and_unvotable() { // And the write path must refuse it rather than rely on a status flip. let msg = make_vote_message(proposal_id, true); - let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let result = actors .proposal_manager .ask(CastVote { @@ -464,7 +476,9 @@ async fn approve_sdk_client_writes_integrity_envelope() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = op_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = op_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager .ask(CastVote { @@ -516,7 +530,9 @@ async fn grant_wallet_access_on_quorum_approval() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager .ask(CastVote { @@ -605,7 +621,9 @@ async fn approve_persistent_grant_creates_basic_grant_row() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager .ask(CastVote { @@ -720,7 +738,9 @@ async fn approve_one_off_transaction_stores_result() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager .ask(CastVote { @@ -771,7 +791,9 @@ async fn replace_operator_updates_pubkey_and_starts_rekey() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager .ask(CastVote { @@ -828,7 +850,9 @@ async fn update_shamir_parameters_reaches_quorum() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager .ask(CastVote { @@ -874,7 +898,9 @@ async fn key_rotation_requires_full_quorum() { let cast = |op_id, key: &authn::SigningKey| { let actors = actors.clone(); - let sig = key.sign_message(&make_vote_message(proposal_id, true), GOVERNANCE_CONTEXT).unwrap(); + let sig = key + .sign_message(&make_vote_message(proposal_id, true), SigningContext::GovernanceVote) + .unwrap(); async move { actors .proposal_manager @@ -915,7 +941,9 @@ async fn approve_server_update_reaches_quorum() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = signing_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = signing_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager .ask(CastVote { @@ -955,7 +983,9 @@ async fn recovery_vote_rejected_when_sleeping() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = rec_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = rec_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let err = actors .proposal_manager .ask(CastRecoveryVote { @@ -999,7 +1029,9 @@ async fn recovery_vote_blocked_on_non_replace_proposal() { .unwrap(); let msg = make_vote_message(proposal_id, true); - let sig = rec_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = rec_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let err = actors .proposal_manager .ask(CastRecoveryVote { @@ -1103,7 +1135,9 @@ async fn recovery_operator_vote_contributes_to_replace_quorum() { // Ordinary operator approves — still pending (needs recovery too) let msg = make_vote_message(proposal_id, true); - let sig = op_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = op_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager .ask(CastVote { @@ -1117,7 +1151,9 @@ async fn recovery_operator_vote_contributes_to_replace_quorum() { assert_eq!(outcome, VoteOutcome::Pending); // Recovery operator approves — now quorum is reached - let sig = rec_key.sign_message(&msg, GOVERNANCE_CONTEXT).unwrap(); + let sig = rec_key + .sign_message(&msg, SigningContext::GovernanceVote) + .unwrap(); let outcome = actors .proposal_manager .ask(CastRecoveryVote { diff --git a/server/crates/arbiter-server/tests/operator/auth.rs b/server/crates/arbiter-server/tests/operator/auth.rs index 76afc1a..5e12a9a 100644 --- a/server/crates/arbiter-server/tests/operator/auth.rs +++ b/server/crates/arbiter-server/tests/operator/auth.rs @@ -1,5 +1,5 @@ use super::common::ChannelTransport; -use arbiter_crypto::authn::{self, AuthChallenge, OPERATOR_CONTEXT}; +use arbiter_crypto::authn::{self, AuthChallenge, SigningContext}; use arbiter_proto::transport::{Error as TransportError, Receiver, Sender}; use arbiter_server::{ actors::{GlobalActors, bootstrap::GetToken, vault::Bootstrap}, @@ -24,7 +24,7 @@ fn sign_operator_challenge( ) -> authn::Signature { let challenge = challenge.format(); key.signing_key() - .sign_deterministic(&challenge, OPERATOR_CONTEXT) + .sign_deterministic(&challenge, SigningContext::Operator.as_bytes()) .unwrap() .into() }