use ciborium::value::Value;
use std::collections::HashSet;
use std::sync::{Arc, Mutex};
use std::time::SystemTime;
use crate::algorithm::{COSE_EDDSA, COSE_ES256, COSE_ES384, COSE_RS256};
use crate::attestation;
use crate::authenticator_data::{self, CoseKey};
use crate::challenge::CHALLENGE_MAX_AGE_SECS;
use crate::client_data;
use crate::credential::{
AuthenticatorAttestationResponse, Challenge, Credential, PublicKey, RegistrationResult,
};
use crate::crypto::sha256;
use crate::error::{Result, WebAuthnError};
#[derive(Debug, Clone)]
pub struct RelyingParty {
pub id: String,
pub allowed_origins: Vec<String>,
pub name: String,
pub require_user_verification: bool,
pub reject_cross_origin: bool,
pub allowed_algorithms: Vec<i64>,
pub require_backup_eligible: bool,
pub reject_backup_eligible: bool,
pub trust_anchors: Vec<Vec<u8>>,
pub used_challenges: Option<Arc<Mutex<HashSet<Vec<u8>>>>>,
}
impl RelyingParty {
pub fn new(id: &str, origin: &str, name: &str) -> Self {
Self {
id: id.to_string(),
allowed_origins: vec![origin.to_string()],
name: name.to_string(),
require_user_verification: false,
reject_cross_origin: false,
allowed_algorithms: vec![],
require_backup_eligible: false,
reject_backup_eligible: false,
trust_anchors: vec![],
used_challenges: None,
}
}
pub fn with_origins(
id: &str,
origins: impl IntoIterator<Item = impl Into<String>>,
name: &str,
) -> Self {
Self {
id: id.to_string(),
allowed_origins: origins.into_iter().map(Into::into).collect(),
name: name.to_string(),
require_user_verification: false,
reject_cross_origin: false,
allowed_algorithms: vec![],
require_backup_eligible: false,
reject_backup_eligible: false,
trust_anchors: vec![],
used_challenges: None,
}
}
pub fn require_user_verification(mut self, required: bool) -> Self {
self.require_user_verification = required;
self
}
pub fn reject_cross_origin(mut self, reject: bool) -> Self {
self.reject_cross_origin = reject;
self
}
pub fn allowed_algorithms(mut self, algs: impl IntoIterator<Item = i64>) -> Self {
self.allowed_algorithms = algs.into_iter().collect();
self
}
pub fn require_backup_eligible(mut self, required: bool) -> Self {
self.require_backup_eligible = required;
self
}
pub fn reject_backup_eligible(mut self, reject: bool) -> Self {
self.reject_backup_eligible = reject;
self
}
pub fn trust_anchors(mut self, roots: impl IntoIterator<Item = Vec<u8>>) -> Self {
self.trust_anchors = roots.into_iter().collect();
self
}
pub fn enforce_single_use_challenges(mut self, enforce: bool) -> Self {
self.used_challenges = if enforce {
Some(Arc::new(Mutex::new(HashSet::new())))
} else {
None
};
self
}
pub fn verify_registration(
&self,
challenge: &Challenge,
response: &AuthenticatorAttestationResponse,
user_id: &[u8],
) -> Result<RegistrationResult> {
verify_registration_inner(self, challenge, response, user_id)
}
}
fn verify_registration_inner(
rp: &RelyingParty,
challenge: &Challenge,
response: &AuthenticatorAttestationResponse,
user_id: &[u8],
) -> Result<RegistrationResult> {
if challenge.is_expired(CHALLENGE_MAX_AGE_SECS) {
return Err(WebAuthnError::ChallengeExpired);
}
let _ = std::str::from_utf8(&response.client_data_json).map_err(|_| {
WebAuthnError::InvalidClientData("clientDataJSON is not valid UTF-8".to_string())
})?;
let parsed_cd = client_data::parse_client_data(&response.client_data_json)?;
client_data::validate_client_data(
&parsed_cd,
"webauthn.create",
&challenge.bytes,
&rp.allowed_origins,
rp.reject_cross_origin,
)?;
if let Some(ref used) = rp.used_challenges {
let mut set = used
.lock()
.expect("used_challenges mutex is poisoned — a previous ceremony panicked");
if set.contains(&challenge.bytes) {
return Err(WebAuthnError::ChallengePreviouslyUsed);
}
set.insert(challenge.bytes.clone());
}
let client_data_hash = sha256(&parsed_cd.raw_json);
let (fmt, auth_data_bytes, att_stmt) = parse_attestation_object(&response.attestation_object)?;
let auth_data = authenticator_data::parse_authenticator_data(&auth_data_bytes)?;
let expected_rp_id_hash = sha256(rp.id.as_bytes());
if auth_data.rp_id_hash != expected_rp_id_hash {
return Err(WebAuthnError::RpIdHashMismatch);
}
if !auth_data.flags.user_present {
return Err(WebAuthnError::UserNotPresent);
}
if rp.reject_backup_eligible && auth_data.flags.backup_eligible {
return Err(WebAuthnError::BackupEligibleNotAllowed);
}
if rp.require_backup_eligible && !auth_data.flags.backup_eligible {
return Err(WebAuthnError::BackupEligibilityRequired);
}
let cred_data = auth_data.attested_credential_data.ok_or_else(|| {
WebAuthnError::InvalidAuthenticatorData(
"attested credential data (AT flag) is required for registration".to_string(),
)
})?;
let cose_alg: i64 = match &cred_data.public_key {
CoseKey::EC2 { alg, .. } => *alg,
CoseKey::OKP { alg, .. } => *alg,
CoseKey::RSA { .. } => COSE_RS256,
};
if !rp.allowed_algorithms.is_empty() && !rp.allowed_algorithms.contains(&cose_alg) {
return Err(WebAuthnError::UnsupportedAlgorithm(cose_alg));
}
let public_key = match cred_data.public_key {
CoseKey::EC2 { alg, x, y, .. } if alg == COSE_ES256 => PublicKey::ES256 { x, y },
CoseKey::EC2 { alg, x, y, .. } if alg == COSE_ES384 => PublicKey::ES384 { x, y },
CoseKey::EC2 { alg, .. } => return Err(WebAuthnError::UnsupportedAlgorithm(alg)),
CoseKey::OKP { alg, x, .. } if alg == COSE_EDDSA => PublicKey::EdDSA(x),
CoseKey::OKP { alg, .. } => return Err(WebAuthnError::UnsupportedAlgorithm(alg)),
CoseKey::RSA { n, e, .. } => PublicKey::RS256 { n, e },
};
let attestation_type = attestation::verify(
&fmt,
&att_stmt,
&auth_data_bytes,
&client_data_hash,
&public_key,
&cred_data.credential_id,
&rp.trust_anchors,
)?;
let backup_eligible = auth_data.flags.backup_eligible;
let backup_state = auth_data.flags.backup_state;
let extensions = auth_data.extensions;
let credential = Credential {
id: cred_data.credential_id,
public_key,
sign_count: auth_data.sign_count,
user_id: user_id.to_vec(),
rp_id: rp.id.clone(),
created_at: SystemTime::now(),
backup_eligible,
backup_state,
};
Ok(RegistrationResult {
credential,
attestation_type,
backup_eligible,
backup_state,
extensions,
})
}
fn parse_attestation_object(data: &[u8]) -> Result<(String, Vec<u8>, Value)> {
let value: Value = ciborium::from_reader(data)
.map_err(|e| WebAuthnError::CborDecodeError(format!("attestation object: {e}")))?;
let map = match value {
Value::Map(m) => m,
_ => {
return Err(WebAuthnError::InvalidAttestationObject(
"attestation object must be a CBOR map".to_string(),
))
}
};
let mut fmt: Option<String> = None;
let mut auth_data: Option<Result<Vec<u8>>> = None;
let mut att_stmt: Option<Value> = None;
for (k, v) in map {
match k {
Value::Text(ref key) if key == "fmt" => {
if let Value::Text(s) = v {
fmt = Some(s);
}
}
Value::Text(ref key) if key == "authData" => {
auth_data = Some(match v {
Value::Bytes(b) => Ok(b),
_ => Err(WebAuthnError::InvalidAttestationObject(
"authData must be CBOR bytes, not another type".to_string(),
)),
});
}
Value::Text(ref key) if key == "attStmt" => {
att_stmt = Some(v);
}
_ => {}
}
}
let fmt = fmt.ok_or_else(|| {
WebAuthnError::InvalidAttestationObject("missing required field: fmt".to_string())
})?;
let auth_data = auth_data.ok_or_else(|| {
WebAuthnError::InvalidAttestationObject("missing required field: authData".to_string())
})??;
let att_stmt = att_stmt.ok_or_else(|| {
WebAuthnError::InvalidAttestationObject("missing required field: attStmt".to_string())
})?;
Ok((fmt, auth_data, att_stmt))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_invalid_attestation_object_cbor() {
let bad_bytes = &[0xFF, 0x00, 0x00];
let result = parse_attestation_object(bad_bytes);
assert!(matches!(result, Err(WebAuthnError::CborDecodeError(_))));
}
#[test]
fn rejects_attestation_object_that_is_not_a_map() {
let integer_cbor = &[0x00u8]; let result = parse_attestation_object(integer_cbor);
assert!(matches!(
result,
Err(WebAuthnError::InvalidAttestationObject(_))
));
}
#[test]
fn rejects_attestation_object_missing_fmt() {
let mut buf = Vec::new();
let v = Value::Map(vec![(
Value::Text("authData".to_string()),
Value::Bytes(vec![0u8; 37]),
)]);
ciborium::into_writer(&v, &mut buf).expect("test setup");
let result = parse_attestation_object(&buf);
assert!(matches!(
result,
Err(WebAuthnError::InvalidAttestationObject(_))
));
}
#[test]
fn rejects_attestation_object_missing_auth_data() {
let mut buf = Vec::new();
let v = Value::Map(vec![
(
Value::Text("fmt".to_string()),
Value::Text("none".to_string()),
),
(Value::Text("attStmt".to_string()), Value::Map(vec![])),
]);
ciborium::into_writer(&v, &mut buf).expect("test setup");
let result = parse_attestation_object(&buf);
assert!(matches!(
result,
Err(WebAuthnError::InvalidAttestationObject(ref m)) if m.contains("authData")
));
}
#[test]
fn rejects_attestation_object_missing_att_stmt() {
let mut buf = Vec::new();
let v = Value::Map(vec![
(
Value::Text("fmt".to_string()),
Value::Text("none".to_string()),
),
(
Value::Text("authData".to_string()),
Value::Bytes(vec![0u8; 37]),
),
]);
ciborium::into_writer(&v, &mut buf).expect("test setup");
let result = parse_attestation_object(&buf);
assert!(matches!(
result,
Err(WebAuthnError::InvalidAttestationObject(ref m)) if m.contains("attStmt")
));
}
#[test]
fn rejects_auth_data_not_bytes() {
let mut buf = Vec::new();
let v = Value::Map(vec![
(
Value::Text("fmt".to_string()),
Value::Text("none".to_string()),
),
(Value::Text("attStmt".to_string()), Value::Map(vec![])),
(
Value::Text("authData".to_string()),
Value::Text("not bytes".to_string()),
),
]);
ciborium::into_writer(&v, &mut buf).expect("test setup");
let result = parse_attestation_object(&buf);
assert!(matches!(
result,
Err(WebAuthnError::InvalidAttestationObject(ref m)) if m.contains("bytes")
));
}
}