use super::auth_data::AuthenticatorData;
use super::client_data::{ClientData, TYPE_GET};
use anyhow::{anyhow, Result};
use p256::ecdsa::signature::Verifier;
use sha2::{Digest, Sha256};
#[derive(Debug, Clone)]
pub struct AssertionExpectations {
pub rp_id: String,
pub challenge_b64: String,
pub allowed_origins: Vec<String>,
pub require_user_verification: bool,
}
#[derive(Debug, Clone)]
pub struct AssertionOutcome {
pub sign_count: u32,
pub user_verified: bool,
pub backup_state: bool,
}
const MAX_SIGN_COUNT_GAP: u32 = 1024;
#[allow(clippy::too_many_arguments)]
pub fn verify_assertion(
stored_credential_id: &[u8],
stored_public_key_cose: &[u8],
stored_sign_count: u32,
response_credential_id: &[u8],
authenticator_data: &[u8],
signature: &[u8],
client_data_json: &[u8],
expectations: &AssertionExpectations,
) -> Result<AssertionOutcome> {
let cd = ClientData::parse(client_data_json)?;
cd.verify_expectations(
TYPE_GET,
&expectations.challenge_b64,
&expectations.allowed_origins,
)?;
let ad = AuthenticatorData::parse(authenticator_data)?;
if !ad.user_present() {
return Err(anyhow!("UP flag not set"));
}
if expectations.require_user_verification && !ad.user_verified() {
return Err(anyhow!("UV flag required but not set"));
}
let expected_hash = Sha256::digest(expectations.rp_id.as_bytes());
if ad.rp_id_hash() != expected_hash.as_slice() {
return Err(anyhow!("rpIdHash mismatch for RP {:?}", expectations.rp_id));
}
if response_credential_id.is_empty() || stored_credential_id.is_empty() {
return Err(anyhow!("credential id must not be empty"));
}
if !crate::utils::constant_time_eq(response_credential_id, stored_credential_id) {
return Err(anyhow!("credential_id mismatch"));
}
let verifying_key = super::assertion_legacy::key_from_cose(stored_public_key_cose)?;
let client_data_hash = Sha256::digest(client_data_json);
let mut signed_data = Vec::with_capacity(authenticator_data.len() + 32);
signed_data.extend_from_slice(ad.as_raw());
signed_data.extend_from_slice(&client_data_hash);
let sig = p256::ecdsa::Signature::from_der(signature)
.map_err(|e| anyhow!("invalid DER signature: {e}"))?;
verifying_key
.verify(&signed_data, &sig)
.map_err(|e| anyhow!("signature verification failed: {e}"))?;
let auth_count = ad.sign_count();
let new_count = if auth_count != 0 || stored_sign_count != 0 {
if auth_count <= stored_sign_count {
return Err(anyhow!(
"sign_count not incremented: stored={stored_sign_count}, auth={auth_count}"
));
}
if auth_count.saturating_sub(stored_sign_count) > MAX_SIGN_COUNT_GAP {
return Err(anyhow!(
"sign_count gap too large: stored={stored_sign_count}, auth={auth_count}"
));
}
auth_count
} else {
0
};
Ok(AssertionOutcome {
sign_count: new_count,
user_verified: ad.user_verified(),
backup_state: ad.backup_state(),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn expectations(rp_id: &str) -> AssertionExpectations {
AssertionExpectations {
rp_id: rp_id.to_string(),
challenge_b64: "Zm9v".to_string(),
allowed_origins: vec![format!("https://{rp_id}")],
require_user_verification: false,
}
}
#[test]
fn rejects_wrong_rp_id_before_signature_work() {
use sha2::{Digest, Sha256};
let mut auth = Sha256::digest(b"a.example").to_vec();
auth.push(super::super::auth_data::FLAG_UP);
auth.extend_from_slice(&1u32.to_be_bytes());
let cd = br#"{"type":"webauthn.get","challenge":"Zm9v","origin":"https://b.example"}"#;
let err = verify_assertion(
b"cred",
&[0x01], 0,
b"cred",
&auth,
&[],
cd,
&expectations("b.example"),
)
.unwrap_err();
assert!(format!("{err:#}").contains("rpIdHash"), "{err:#}");
}
#[test]
fn rejects_empty_credential_ids() {
use sha2::{Digest, Sha256};
let mut auth = Sha256::digest(b"a").to_vec();
auth.push(super::super::auth_data::FLAG_UP);
auth.extend_from_slice(&0u32.to_be_bytes());
let err = verify_assertion(
b"",
&[0x01],
0,
b"",
&auth,
&[],
br#"{"type":"webauthn.get","challenge":"Zm9v","origin":"https://a"}"#,
&expectations("a"),
)
.unwrap_err();
assert!(format!("{err:#}").contains("must not be empty"));
}
}