From ae3d2e051b417be4e03a8fa20368529a9b2147ea Mon Sep 17 00:00:00 2001 From: alberto-crossmint Date: Thu, 16 Apr 2026 22:58:38 +0200 Subject: [PATCH] fix: enforce WebAuthn assertion type and User-Present flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two spec-mandated checks were missing from the WebAuthn verifier (W3C WebAuthn §6.1, §7.2.11): 1. clientDataJSON.type must be "webauthn.get" for an authentication assertion. Without this, a signature produced during a navigator.credentials.create() ceremony (type "webauthn.create") could be replayed against the smart account if its challenge happened to match a transaction hash. 2. The User-Present (UP) bit in authenticator_data flags must be set. Without this, the contract trusted whatever the authenticator reported, accepting assertions where the user never interacted. Both checks are cheap (string compare, single bit test) and run in the existing fail-fast block before the ECDSA verify. Out of scope: clientDataJSON.origin and authenticator_data rpIdHash enforcement. Both require storing per-signer expected values and amount to a WebauthnSigner schema change tracked separately. --- .../src/auth/signers/webauthn.rs | 28 ++++++ .../smart-account/src/tests/test_utils.rs | 93 +++++++++++++++++++ .../src/tests/webauthn_signer_test.rs | 72 ++++++++++++++ 3 files changed, 193 insertions(+) diff --git a/contracts/smart-account/src/auth/signers/webauthn.rs b/contracts/smart-account/src/auth/signers/webauthn.rs index f1eb9b0..5702eec 100644 --- a/contracts/smart-account/src/auth/signers/webauthn.rs +++ b/contracts/smart-account/src/auth/signers/webauthn.rs @@ -5,11 +5,25 @@ use base64ct::{Base64UrlUnpadded, Encoding}; use smart_account_interfaces::WebauthnSigner; use soroban_sdk::{crypto::Hash, Env}; +/// WebAuthn assertion `clientDataJSON` fields the verifier inspects. +/// +/// Per W3C WebAuthn §7.2.11, an authentication assertion MUST set +/// `type` to `"webauthn.get"`. We pin both `type` and `challenge`; other +/// fields (`origin`, `crossOrigin`) are intentionally ignored here — +/// `origin` enforcement requires per-signer expected-origin storage and +/// is tracked for a future schema change. #[derive(serde::Deserialize)] struct ClientDataJson<'a> { + #[serde(rename = "type")] + ty: &'a str, challenge: &'a str, } +const WEBAUTHN_GET_TYPE: &str = "webauthn.get"; + +/// Authenticator-data flag bits (WebAuthn §6.1). +const FLAG_USER_PRESENT: u8 = 0x01; + impl SignatureVerifier for WebauthnSigner { fn verify( &self, @@ -32,6 +46,13 @@ impl SignatureVerifier for WebauthnSigner { return Err(Error::InvalidWebauthnClientDataJson); } + // The User-Present (UP) bit MUST be set for a valid assertion. + // `authenticator_data[32]` is the flags byte. + let flags = authenticator_data.get(32).unwrap_or(0); + if flags & FLAG_USER_PRESENT == 0 { + return Err(Error::InvalidWebauthnClientDataJson); + } + if client_data_json.len() > 1024 { return Err(Error::InvalidWebauthnClientDataJson); } @@ -41,6 +62,13 @@ impl SignatureVerifier for WebauthnSigner { serde_json_core::de::from_slice(client_data_json_buf.as_slice()) .map_err(|_| Error::InvalidWebauthnClientDataJson)?; + // Reject anything that isn't a WebAuthn authentication ceremony + // — this prevents a webauthn.create signature from being replayed + // as a webauthn.get assertion. + if parsed_client_data.ty != WEBAUTHN_GET_TYPE { + return Err(Error::InvalidWebauthnClientDataJson); + } + let mut buf = [0u8; 64]; let expected_challenge = Base64UrlUnpadded::encode(&signature_payload.to_bytes().to_array(), &mut buf) diff --git a/contracts/smart-account/src/tests/test_utils.rs b/contracts/smart-account/src/tests/test_utils.rs index b2e8e20..7e0b18c 100644 --- a/contracts/smart-account/src/tests/test_utils.rs +++ b/contracts/smart-account/src/tests/test_utils.rs @@ -246,6 +246,99 @@ impl WebauthnTestSigner { let proof = self.build_webauthn_proof(env, payload, Some(&wrong)); (signer_key, proof) } + + /// Sign with `type: "webauthn.create"` (registration ceremony) instead of + /// `"webauthn.get"` — for verifying that the assertion verifier rejects + /// non-authentication ceremonies. + pub fn sign_with_wrong_type( + &self, + env: &Env, + payload: &BytesN<32>, + ) -> (SignerKey, SignerProof) { + let signer_key = SignerKey::Webauthn(Bytes::from_array(env, &self.key_id)); + let proof = self.build_webauthn_proof_with_type(env, payload, "webauthn.create"); + (signer_key, proof) + } + + /// Sign with the User-Present (UP) flag bit cleared in `authenticator_data`. + /// Used to verify the verifier rejects assertions without user presence. + pub fn sign_without_user_present( + &self, + env: &Env, + payload: &BytesN<32>, + ) -> (SignerKey, SignerProof) { + let signer_key = SignerKey::Webauthn(Bytes::from_array(env, &self.key_id)); + let proof = self.build_webauthn_proof_with_flags(env, payload, 0x00); + (signer_key, proof) + } + + fn build_webauthn_proof_with_type( + &self, + env: &Env, + payload: &BytesN<32>, + ty: &str, + ) -> SignerProof { + let challenge = Base64UrlUnpadded::encode_string(&payload.to_array()); + let client_data = WebauthnClientData { + ty, + challenge: &challenge, + origin: "https://example.com", + cross_origin: None, + }; + let client_data_json = serde_json::to_vec(&client_data).unwrap(); + let authenticator_data = Self::build_authenticator_data(); + let client_data_hash = Sha256::digest(&client_data_json); + let mut signed_data = authenticator_data.clone(); + signed_data.extend_from_slice(&client_data_hash); + let signature: P256Signature = + p256::ecdsa::signature::Signer::sign(&self.signing_key, &signed_data); + let normalized = normalize_signature(&signature); + SignerProof::Webauthn(WebauthnSignature { + authenticator_data: Bytes::from_slice(env, &authenticator_data), + client_data_json: Bytes::from_slice(env, &client_data_json), + signature: BytesN::from_array( + env, + normalized.to_bytes().as_slice().try_into().unwrap(), + ), + }) + } + + fn build_webauthn_proof_with_flags( + &self, + env: &Env, + payload: &BytesN<32>, + flags: u8, + ) -> SignerProof { + let challenge = Base64UrlUnpadded::encode_string(&payload.to_array()); + let client_data = WebauthnClientData { + ty: "webauthn.get", + challenge: &challenge, + origin: "https://example.com", + cross_origin: None, + }; + let client_data_json = serde_json::to_vec(&client_data).unwrap(); + + // Build authenticator_data with the supplied flags byte. + let mut authenticator_data = Vec::new(); + authenticator_data.extend_from_slice(&Sha256::digest(b"example.com")); + authenticator_data.push(flags); + authenticator_data.extend_from_slice(&42u32.to_be_bytes()); + + let client_data_hash = Sha256::digest(&client_data_json); + let mut signed_data = authenticator_data.clone(); + signed_data.extend_from_slice(&client_data_hash); + let signature: P256Signature = + p256::ecdsa::signature::Signer::sign(&self.signing_key, &signed_data); + let normalized = normalize_signature(&signature); + SignerProof::Webauthn(WebauthnSignature { + authenticator_data: Bytes::from_slice(env, &authenticator_data), + client_data_json: Bytes::from_slice(env, &client_data_json), + signature: BytesN::from_array( + env, + normalized.to_bytes().as_slice().try_into().unwrap(), + ), + }) + } } impl TestSignerTrait for WebauthnTestSigner { diff --git a/contracts/smart-account/src/tests/webauthn_signer_test.rs b/contracts/smart-account/src/tests/webauthn_signer_test.rs index d1b884a..fa3bd38 100644 --- a/contracts/smart-account/src/tests/webauthn_signer_test.rs +++ b/contracts/smart-account/src/tests/webauthn_signer_test.rs @@ -92,3 +92,75 @@ fn test_webauthn_end_to_end_auth() { ) .unwrap(); } + +// ============================================================================ +// Spec compliance: WebAuthn assertions must use type "webauthn.get" (W3C §7.2.11) +// ============================================================================ + +#[test] +fn test_webauthn_wrong_type_rejected() { + let env = setup(); + let signer = WebauthnTestSigner::generate(SignerRole::Admin); + + let payload_hash = env.crypto().sha256(&Bytes::from_array(&env, &[0xAB; 32])); + let (_, wrong_type_proof) = signer.sign_with_wrong_type(&env, &payload_hash.to_bytes()); + + let result = signer + .into_signer(&env) + .verify(&env, &payload_hash, &wrong_type_proof); + assert!(matches!( + result, + Err(Error::InvalidWebauthnClientDataJson) + )); +} + +#[test] +fn test_webauthn_wrong_type_rejected_end_to_end() { + let env = setup(); + let test_signer = WebauthnTestSigner::generate(SignerRole::Admin); + let contract_id = env.register( + SmartAccount, + ( + vec![&env, test_signer.into_signer(&env)], + Vec::
::new(&env), + ), + ); + + let payload = BytesN::random(&env); + let (signer_key, proof) = test_signer.sign_with_wrong_type(&env, &payload); + let auth_payloads = SignatureProofs(map![&env, (signer_key, proof)]); + + match env + .try_invoke_contract_check_auth::( + &contract_id, + &payload, + auth_payloads.into_val(&env), + &vec![&env, get_token_auth_context(&env)], + ) + .unwrap_err() + { + Ok(err) => assert_eq!(err, Error::InvalidWebauthnClientDataJson), + Err(other) => panic!("unexpected host error {:?}", other), + } +} + +// ============================================================================ +// Spec compliance: User-Present (UP) flag must be set (W3C §6.1) +// ============================================================================ + +#[test] +fn test_webauthn_missing_user_present_flag_rejected() { + let env = setup(); + let signer = WebauthnTestSigner::generate(SignerRole::Admin); + + let payload_hash = env.crypto().sha256(&Bytes::from_array(&env, &[0xAB; 32])); + let (_, proof) = signer.sign_without_user_present(&env, &payload_hash.to_bytes()); + + let result = signer + .into_signer(&env) + .verify(&env, &payload_hash, &proof); + assert!(matches!( + result, + Err(Error::InvalidWebauthnClientDataJson) + )); +}