Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions contracts/smart-account/src/auth/signers/webauthn.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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);
}
Expand All @@ -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)
Expand Down
93 changes: 93 additions & 0 deletions contracts/smart-account/src/tests/test_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
72 changes: 72 additions & 0 deletions contracts/smart-account/src/tests/webauthn_signer_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Address>::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::<Error>(
&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)
));
}
Loading