feat(auth): limited RSA support for signing
Some checks failed
ci/woodpecker/pr/server-lint Pipeline failed
ci/woodpecker/pr/server-audit Pipeline was successful
ci/woodpecker/pr/server-test Pipeline failed
ci/woodpecker/pr/server-vet Pipeline failed

see server/clippy.toml
This commit is contained in:
2026-03-14 13:57:13 +01:00
parent 42760bbd79
commit 47144bdf81
13 changed files with 192 additions and 19 deletions

View File

@@ -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 {

13
server/.cargo/audit.toml Normal file
View File

@@ -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"]

View File

@@ -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"

84
server/Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

8
server/clippy.toml Normal file
View File

@@ -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." },
]

View File

@@ -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"

View File

@@ -52,6 +52,12 @@ fn parse_pubkey(key_type: ProtoKeyType, pubkey: Vec<u8>) -> Result<AuthPublicKey
.map_err(|_| Error::InvalidAuthPubkeyEncoding)?;
Ok(AuthPublicKey::EcdsaSecp256k1(key))
}
ProtoKeyType::Rsa => {
use rsa::pkcs8::DecodePublicKey as _;
let key = rsa::RsaPublicKey::from_public_key_der(&pubkey)
.map_err(|_| Error::InvalidAuthPubkeyEncoding)?;
Ok(AuthPublicKey::Rsa(key))
}
}
}

View File

@@ -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<u8> {
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::<sha2::Sha256>::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)
}

View File

@@ -36,9 +36,9 @@ pub mod types {
SqliteTimestamp(dt)
}
}
impl Into<chrono::DateTime<Utc>> for SqliteTimestamp {
fn into(self) -> chrono::DateTime<Utc> {
self.0
impl From<SqliteTimestamp> for chrono::DateTime<Utc> {
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<Integer, Sqlite> 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())
}
}
}

View File

@@ -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")

View File

@@ -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

View File

@@ -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::<sha2::Sha256>::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()
}
}
}
}