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) + )); +}