diff --git a/protobufs/user_agent.proto b/protobufs/user_agent.proto index 55b18fc..fcf508d 100644 --- a/protobufs/user_agent.proto +++ b/protobufs/user_agent.proto @@ -9,6 +9,7 @@ enum KeyType { KEY_TYPE_UNSPECIFIED = 0; KEY_TYPE_ED25519 = 1; KEY_TYPE_ECDSA_SECP256K1 = 2; + KEY_TYPE_RSA = 3; } message AuthChallengeRequest { diff --git a/server/.cargo/audit.toml b/server/.cargo/audit.toml new file mode 100644 index 0000000..a615271 --- /dev/null +++ b/server/.cargo/audit.toml @@ -0,0 +1,13 @@ +[advisories] +# RUSTSEC-2023-0071: Marvin Attack timing side-channel in rsa crate. +# No fixed version is available upstream. +# RSA support is required for Windows Hello / KeyCredentialManager +# (https://learn.microsoft.com/en-us/uwp/api/windows.security.credentials.keycredentialmanager.requestcreateasync), +# which only issues RSA-2048 keys. +# Mitigations in place: +# - Signing uses BlindedSigningKey (PSS+SHA-256), which applies blinding to +# protect the private key from timing recovery during signing. +# - RSA decryption is never performed; we only verify public-key signatures. +# - The attack requires local, high-resolution timing access against the +# signing process, which is not exposed in our threat model. +ignore = ["RUSTSEC-2023-0071"] diff --git a/server/.cargo/config.toml b/server/.cargo/config.toml index 2f90489..47f62d0 100644 --- a/server/.cargo/config.toml +++ b/server/.cargo/config.toml @@ -1,5 +1,2 @@ -[target.'cfg(windows)'.profile.dev] -# Override global Cranelift backend only on Windows. -# Cranelift does not propagate cargo:rustc-link-lib from native dependencies -# (aws-lc-sys etc.) to lld-link, causing undefined symbol errors. +[profile.dev] codegen-backend = "llvm" diff --git a/server/Cargo.lock b/server/Cargo.lock index 9645220..1586320 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -731,9 +731,12 @@ dependencies = [ "rand 0.10.0", "rcgen", "restructed", + "rsa", "rustls", "secrecy", + "sha2 0.10.9", "smlang", + "spki", "strum", "test-log", "thiserror", @@ -764,8 +767,11 @@ dependencies = [ "k256", "kameo", "rand 0.10.0", + "rsa", "rustls-webpki", + "sha2 0.10.9", "smlang", + "spki", "thiserror", "tokio", "tokio-stream", @@ -1733,6 +1739,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ "const-oid", + "pem-rfc7468", "zeroize", ] @@ -2913,6 +2920,9 @@ name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +dependencies = [ + "spin", +] [[package]] name = "leb128fmt" @@ -3146,6 +3156,22 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-bigint-dig" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e661dda6640fad38e827a6d4a310ff4763082116fe217f279885c97f511bb0b7" +dependencies = [ + "lazy_static", + "libm", + "num-integer", + "num-iter", + "num-traits", + "rand 0.8.5", + "smallvec", + "zeroize", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -3161,6 +3187,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3330,6 +3367,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -3389,6 +3435,17 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs1" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" +dependencies = [ + "der", + "pkcs8", + "spki", +] + [[package]] name = "pkcs8" version = "0.10.2" @@ -4036,6 +4093,27 @@ dependencies = [ "rustc-hex", ] +[[package]] +name = "rsa" +version = "0.9.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d" +dependencies = [ + "const-oid", + "digest 0.10.7", + "num-bigint-dig", + "num-integer", + "num-traits", + "pkcs1", + "pkcs8", + "rand_core 0.6.4", + "sha2 0.10.9", + "signature 2.2.0", + "spki", + "subtle", + "zeroize", +] + [[package]] name = "rsqlite-vfs" version = "0.1.0" @@ -4583,6 +4661,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.9.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" + [[package]] name = "spki" version = "0.7.3" diff --git a/server/Cargo.toml b/server/Cargo.toml index bbb9eb9..ea3da11 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -4,6 +4,9 @@ members = [ ] resolver = "3" +[workspace.lints.clippy] +disallowed-methods = "deny" + [workspace.dependencies] tonic = { version = "0.14.3", features = [ @@ -37,3 +40,6 @@ rcgen = { version = "0.14.7", features = [ "zeroize", ], default-features = false } k256 = { version = "0.13.4", features = ["ecdsa", "pkcs8"] } +rsa = { version = "0.9", features = ["sha2"] } +sha2 = "0.10" +spki = "0.7" diff --git a/server/clippy.toml b/server/clippy.toml new file mode 100644 index 0000000..09149fd --- /dev/null +++ b/server/clippy.toml @@ -0,0 +1,8 @@ +disallowed-methods = [ + # RSA decryption is forbidden: the rsa crate has RUSTSEC-2023-0071 (Marvin Attack). + # We only use RSA for Windows Hello (KeyCredentialManager) public-key verification — decryption + # is never required and must not be introduced. + { path = "rsa::RsaPrivateKey::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). Only PSS signing/verification is permitted." }, + { path = "rsa::pkcs1v15::DecryptingKey::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). Only PSS signing/verification is permitted." }, + { path = "rsa::oaep::DecryptingKey::decrypt", reason = "RSA decryption is forbidden (RUSTSEC-2023-0071 Marvin Attack). Only PSS signing/verification is permitted." }, +] diff --git a/server/crates/arbiter-server/Cargo.toml b/server/crates/arbiter-server/Cargo.toml index b93c02e..4629d81 100644 --- a/server/crates/arbiter-server/Cargo.toml +++ b/server/crates/arbiter-server/Cargo.toml @@ -5,6 +5,9 @@ edition = "2024" repository = "https://git.markettakers.org/MarketTakers/arbiter" license = "Apache-2.0" +[lints] +workspace = true + [dependencies] diesel = { version = "2.3.6", features = ["chrono", "returning_clauses_for_sqlite_3_35", "serde_json", "time", "uuid"] } diesel-async = { version = "0.7.4", features = [ @@ -43,6 +46,9 @@ restructed = "0.2.2" strum = { version = "0.27.2", features = ["derive"] } pem = "3.0.6" k256.workspace = true +rsa.workspace = true +sha2.workspace = true +spki.workspace = true alloy.workspace = true arbiter-tokens-registry.path = "../arbiter-tokens-registry" diff --git a/server/crates/arbiter-server/src/actors/user_agent/auth.rs b/server/crates/arbiter-server/src/actors/user_agent/auth.rs index de5f20f..1e0fe20 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/auth.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/auth.rs @@ -52,6 +52,12 @@ fn parse_pubkey(key_type: ProtoKeyType, pubkey: Vec) -> Result { + use rsa::pkcs8::DecodePublicKey as _; + let key = rsa::RsaPublicKey::from_public_key_der(&pubkey) + .map_err(|_| Error::InvalidAuthPubkeyEncoding)?; + Ok(AuthPublicKey::Rsa(key)) + } } } diff --git a/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs b/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs index 04e6d57..13bf6c6 100644 --- a/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs +++ b/server/crates/arbiter-server/src/actors/user_agent/auth/state.rs @@ -11,22 +11,30 @@ use crate::{ db::{models::KeyType, schema}, }; -/// Abstraction over Ed25519 / ECDSA-secp256k1 public keys used during the auth handshake. +/// Abstraction over Ed25519 / ECDSA-secp256k1 / RSA public keys used during the auth handshake. #[derive(Clone)] pub enum AuthPublicKey { Ed25519(ed25519_dalek::VerifyingKey), /// Compressed SEC1 public key; signature bytes are raw 64-byte (r||s). EcdsaSecp256k1(k256::ecdsa::VerifyingKey), + /// RSA-2048+ public key (Windows Hello / KeyCredentialManager); signature bytes are PSS+SHA-256. + Rsa(rsa::RsaPublicKey), } impl AuthPublicKey { /// Canonical bytes stored in DB and echoed back in the challenge. - /// Ed25519: raw 32 bytes. ECDSA: SEC1 compressed 33 bytes. + /// Ed25519: raw 32 bytes. ECDSA: SEC1 compressed 33 bytes. RSA: DER-encoded SPKI. pub fn to_stored_bytes(&self) -> Vec { match self { AuthPublicKey::Ed25519(k) => k.to_bytes().to_vec(), // SEC1 compressed (33 bytes) is the natural compact format for secp256k1 AuthPublicKey::EcdsaSecp256k1(k) => k.to_encoded_point(true).as_bytes().to_vec(), + AuthPublicKey::Rsa(k) => { + use rsa::pkcs8::EncodePublicKey as _; + k.to_public_key_der() + .expect("rsa SPKI encoding is infallible") + .to_vec() + } } } @@ -34,6 +42,7 @@ impl AuthPublicKey { match self { AuthPublicKey::Ed25519(_) => KeyType::Ed25519, AuthPublicKey::EcdsaSecp256k1(_) => KeyType::EcdsaSecp256k1, + AuthPublicKey::Rsa(_) => KeyType::Rsa, } } } @@ -161,6 +170,15 @@ impl AuthStateMachineContext for AuthContext<'_> { })?; vk.verify(&formatted, &sig).is_ok() } + AuthPublicKey::Rsa(pk) => { + use rsa::signature::Verifier as _; + let verifying_key = rsa::pss::VerifyingKey::::new(pk.clone()); + let sig = rsa::pss::Signature::try_from(solution.as_slice()).map_err(|_| { + error!(?solution, "Invalid RSA signature bytes"); + Error::InvalidChallengeSolution + })?; + verifying_key.verify(&formatted, &sig).is_ok() + } }; Ok(valid) @@ -266,6 +284,13 @@ impl AuthStateMachineContext for AuthContext<'_> { .expect("ecdsa key was already validated in parse_auth_event"), ) } + crate::db::models::KeyType::Rsa => { + use rsa::pkcs8::DecodePublicKey as _; + AuthPublicKey::Rsa( + rsa::RsaPublicKey::from_public_key_der(&bytes) + .expect("rsa key was already validated in parse_auth_event"), + ) + } }; Ok(rebuilt) } diff --git a/server/crates/arbiter-server/src/db/models.rs b/server/crates/arbiter-server/src/db/models.rs index 3f0de44..ddf7773 100644 --- a/server/crates/arbiter-server/src/db/models.rs +++ b/server/crates/arbiter-server/src/db/models.rs @@ -36,9 +36,9 @@ pub mod types { SqliteTimestamp(dt) } } - impl Into> for SqliteTimestamp { - fn into(self) -> chrono::DateTime { - self.0 + impl From for chrono::DateTime { + fn from(ts: SqliteTimestamp) -> Self { + ts.0 } } @@ -75,12 +75,13 @@ pub mod types { /// Key algorithm stored in the `useragent_client.key_type` column. /// Values must stay stable — they are persisted in the database. - #[derive(Debug, Clone, Copy, PartialEq, Eq, FromSqlRow, AsExpression)] + #[derive(Debug, Clone, Copy, PartialEq, Eq, FromSqlRow, AsExpression, strum::FromRepr)] #[diesel(sql_type = Integer)] #[repr(i32)] pub enum KeyType { Ed25519 = 1, EcdsaSecp256k1 = 2, + Rsa = 3, } impl ToSql for KeyType { @@ -100,11 +101,9 @@ pub mod types { let Some(SqliteType::Long) = bytes.value_type() else { return Err("Expected Integer for KeyType".into()); }; - match bytes.read_long() { - 1 => Ok(KeyType::Ed25519), - 2 => Ok(KeyType::EcdsaSecp256k1), - other => Err(format!("Unknown KeyType discriminant: {other}").into()), - } + let discriminant = bytes.read_long(); + KeyType::from_repr(discriminant as i32) + .ok_or_else(|| format!("Unknown KeyType discriminant: {discriminant}").into()) } } } diff --git a/server/crates/arbiter-server/src/lib.rs b/server/crates/arbiter-server/src/lib.rs index c996035..d712992 100644 --- a/server/crates/arbiter-server/src/lib.rs +++ b/server/crates/arbiter-server/src/lib.rs @@ -15,8 +15,8 @@ use tracing::info; use crate::{ actors::{ - client::{self, ClientError, ClientConnection as ClientConnectionProps, connect_client}, - user_agent::{self, UserAgentConnection, TransportResponseError, connect_user_agent}, + client::{self, ClientConnection as ClientConnectionProps, ClientError, connect_client}, + user_agent::{self, TransportResponseError, UserAgentConnection, connect_user_agent}, }, context::ServerContext, }; @@ -89,7 +89,8 @@ fn client_auth_error_status(value: &client::auth::Error) -> Status { fn user_agent_error_status(value: TransportResponseError) -> Status { match value { - TransportResponseError::MissingRequestPayload | TransportResponseError::UnexpectedRequestPayload => { + TransportResponseError::MissingRequestPayload + | TransportResponseError::UnexpectedRequestPayload => { Status::invalid_argument("Expected message with payload") } TransportResponseError::InvalidStateForUnsealEncryptedKey => { @@ -99,7 +100,9 @@ fn user_agent_error_status(value: TransportResponseError) -> Status { Status::invalid_argument("client_pubkey must be 32 bytes") } TransportResponseError::StateTransitionFailed => Status::internal("State machine error"), - TransportResponseError::KeyHolderActorUnreachable => Status::internal("Vault is not available"), + TransportResponseError::KeyHolderActorUnreachable => { + Status::internal("Vault is not available") + } TransportResponseError::Auth(ref err) => auth_error_status(err), TransportResponseError::ConnectionRegistrationFailed => { Status::internal("Failed registering connection") diff --git a/server/crates/arbiter-useragent/Cargo.toml b/server/crates/arbiter-useragent/Cargo.toml index a2042eb..4b7337a 100644 --- a/server/crates/arbiter-useragent/Cargo.toml +++ b/server/crates/arbiter-useragent/Cargo.toml @@ -4,6 +4,9 @@ version = "0.1.0" edition = "2024" license = "Apache-2.0" +[lints] +workspace = true + [dependencies] arbiter-proto.path = "../arbiter-proto" kameo.workspace = true @@ -15,6 +18,9 @@ ed25519-dalek.workspace = true smlang.workspace = true x25519-dalek.workspace = true k256.workspace = true +rsa.workspace = true +sha2.workspace = true +spki.workspace = true rand.workspace = true thiserror.workspace = true tokio-stream.workspace = true diff --git a/server/crates/arbiter-useragent/src/lib.rs b/server/crates/arbiter-useragent/src/lib.rs index 1be9f3a..5da9d68 100644 --- a/server/crates/arbiter-useragent/src/lib.rs +++ b/server/crates/arbiter-useragent/src/lib.rs @@ -18,6 +18,8 @@ pub enum SigningKeyEnum { Ed25519(ed25519_dalek::SigningKey), /// secp256k1 ECDSA; public key is sent as SEC1 compressed 33 bytes; signature is raw 64-byte (r||s). EcdsaSecp256k1(k256::ecdsa::SigningKey), + /// RSA for Windows Hello (KeyCredentialManager); public key is DER SPKI; signature is PSS+SHA-256. + Rsa(rsa::RsaPrivateKey), } impl SigningKeyEnum { @@ -29,6 +31,13 @@ impl SigningKeyEnum { SigningKeyEnum::EcdsaSecp256k1(k) => { k.verifying_key().to_encoded_point(true).as_bytes().to_vec() } + SigningKeyEnum::Rsa(k) => { + use rsa::pkcs8::EncodePublicKey as _; + k.to_public_key() + .to_public_key_der() + .expect("rsa SPKI encoding is infallible") + .to_vec() + } } } @@ -37,6 +46,7 @@ impl SigningKeyEnum { match self { SigningKeyEnum::Ed25519(_) => ProtoKeyType::Ed25519, SigningKeyEnum::EcdsaSecp256k1(_) => ProtoKeyType::EcdsaSecp256k1, + SigningKeyEnum::Rsa(_) => ProtoKeyType::Rsa, } } @@ -52,6 +62,15 @@ impl SigningKeyEnum { let sig: k256::ecdsa::Signature = k.sign(msg); sig.to_bytes().to_vec() } + SigningKeyEnum::Rsa(k) => { + use rsa::signature::RandomizedSigner as _; + let signing_key = rsa::pss::BlindedSigningKey::::new(k.clone()); + // Use rand_core OsRng from the rsa crate's re-exported rand_core (0.6.x), + // which is the version rsa's signature API expects. + let sig = signing_key.sign_with_rng(&mut rsa::rand_core::OsRng, msg); + use rsa::signature::SignatureEncoding as _; + sig.to_vec() + } } } }