use std::time::{SystemTime, UNIX_EPOCH};
use ciborium::Value;
use x509_parser::prelude::*;
use crate::crypto::{
cbor_bytes, cbor_map_entries, verify_cose_signature, CosePublicKey, COSE_ALG_ES256,
COSE_ALG_RS256,
};
use crate::error::WebauthnError;
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum AttestationFormat {
None,
Packed,
FidoU2f,
Unknown,
}
impl AttestationFormat {
pub fn from_fmt(fmt: &str) -> Self {
match fmt {
"none" => AttestationFormat::None,
"packed" => AttestationFormat::Packed,
"fido-u2f" => AttestationFormat::FidoU2f,
_ => AttestationFormat::Unknown,
}
}
pub fn as_fmt(&self) -> &'static str {
match self {
AttestationFormat::None => "none",
AttestationFormat::Packed => "packed",
AttestationFormat::FidoU2f => "fido-u2f",
AttestationFormat::Unknown => "unknown",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub enum TrustLevel {
None,
SelfAttested,
BasicAtt,
AttCa,
}
pub mod warnings {
pub const UNANCHORED_CHAIN: &str =
"attestation chain accepted without a configured trust anchor; \
treat as self-attested";
pub const UNVERIFIED_FORMAT: &str =
"unknown attestation format accepted via allow_unknown_formats; \
statement was not verified";
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
pub struct AttestationResult {
pub format: AttestationFormat,
pub aaguid: Option<[u8; 16]>,
pub trust_level: TrustLevel,
pub warnings: Vec<&'static str>,
}
#[derive(Debug, Clone, Default)]
pub struct AttestationPolicy {
pub trust_anchors: Vec<Vec<u8>>,
pub allow_unknown_formats: bool,
}
impl AttestationPolicy {
pub fn strict() -> Self {
Self::default()
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AttestationStatement {
None,
Packed {
alg: i32,
sig: Vec<u8>,
x5c: Vec<Vec<u8>>,
},
FidoU2f {
sig: Vec<u8>,
cert: Vec<u8>,
},
}
const OID_SIG_SHA256_WITH_RSA: &[u8] = &[0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x0B];
const OID_SIG_ECDSA_WITH_SHA256: &[u8] = &[0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x04, 0x03, 0x02];
const OID_ALG_RSA_ENCRYPTION: &[u8] = &[0x2A, 0x86, 0x48, 0x86, 0xF7, 0x0D, 0x01, 0x01, 0x01];
const OID_ALG_EC_PUBLIC_KEY: &[u8] = &[0x2A, 0x86, 0x48, 0xCE, 0x3D, 0x02, 0x01];
const OID_EXT_BASIC_CONSTRAINTS: &[u8] = &[0x55, 0x1D, 0x13];
const OID_EXT_FIDO_GEN_CE_AAGUID: &[u8] =
&[0x2B, 0x06, 0x01, 0x04, 0x01, 0x82, 0xE5, 0x14, 0x01, 0x04];
const STMT_LABEL_ALG: i64 = 3;
const STMT_LABEL_SIG: i64 = 8;
const STMT_LABEL_X5C: i64 = 33;
pub(crate) fn parse_statement(
format: &AttestationFormat,
att_stmt: Option<&Value>,
) -> Result<AttestationStatement, WebauthnError> {
match format {
AttestationFormat::None => {
match att_stmt {
None => Ok(AttestationStatement::None),
Some(Value::Map(entries)) if entries.is_empty() => Ok(AttestationStatement::None),
Some(_) => Err(WebauthnError::AttestationError(
"attStmt must be an empty map for the 'none' format".to_string(),
)),
}
}
AttestationFormat::Packed => {
let val = att_stmt.ok_or_else(|| {
WebauthnError::AttestationError(
"missing attStmt for the 'packed' format".to_string(),
)
})?;
let entries = cbor_map_entries(val).ok_or_else(|| {
WebauthnError::AttestationError(
"attStmt is not a CBOR map for the 'packed' format".to_string(),
)
})?;
let mut alg: Option<i32> = None;
let mut sig: Option<Vec<u8>> = None;
let mut x5c: Vec<Vec<u8>> = Vec::new();
for (label, v) in &entries {
match *label {
STMT_LABEL_ALG => alg = cbor_alg(v),
STMT_LABEL_SIG => sig = cbor_bytes(v),
STMT_LABEL_X5C => x5c = cbor_cert_array(v)?,
_ => {}
}
}
let alg = alg.ok_or_else(|| {
WebauthnError::AttestationError("packed attStmt missing 'alg'".to_string())
})?;
let sig = sig.ok_or_else(|| {
WebauthnError::AttestationError("packed attStmt missing 'sig'".to_string())
})?;
Ok(AttestationStatement::Packed { alg, sig, x5c })
}
AttestationFormat::FidoU2f => {
let val = att_stmt.ok_or_else(|| {
WebauthnError::AttestationError(
"missing attStmt for the 'fido-u2f' format".to_string(),
)
})?;
let entries = cbor_map_entries(val).ok_or_else(|| {
WebauthnError::AttestationError(
"attStmt is not a CBOR map for the 'fido-u2f' format".to_string(),
)
})?;
let mut sig: Option<Vec<u8>> = None;
let mut x5c: Vec<Vec<u8>> = Vec::new();
for (label, v) in &entries {
match *label {
STMT_LABEL_SIG => sig = cbor_bytes(v),
STMT_LABEL_X5C => x5c = cbor_cert_array(v)?,
_ => {}
}
}
let sig = sig.ok_or_else(|| {
WebauthnError::AttestationError("fido-u2f attStmt missing 'sig'".to_string())
})?;
let [cert] = x5c.as_slice() else {
return Err(WebauthnError::AttestationError(
"fido-u2f attStmt must carry exactly one x5c certificate".to_string(),
));
};
Ok(AttestationStatement::FidoU2f {
sig,
cert: cert.clone(),
})
}
AttestationFormat::Unknown => Err(WebauthnError::AttestationError(
"unknown attestation format cannot be parsed".to_string(),
)),
}
}
fn cbor_alg(val: &Value) -> Option<i32> {
match val {
Value::Integer(i) => (*i).try_into().ok(),
_ => None,
}
}
fn cbor_cert_array(val: &Value) -> Result<Vec<Vec<u8>>, WebauthnError> {
match val {
Value::Array(items) => {
let mut certs = Vec::with_capacity(items.len());
for item in items {
certs.push(cbor_bytes(item).ok_or_else(|| {
WebauthnError::AttestationError(
"x5c contains a non-byte-string certificate".to_string(),
)
})?);
}
Ok(certs)
}
_ => Err(WebauthnError::AttestationError(
"x5c is not a CBOR array".to_string(),
)),
}
}
#[allow(clippy::too_many_arguments)]
pub fn verify_attestation(
fmt: &str,
att_stmt: Option<&Value>,
auth_data: &[u8],
client_data_hash: &[u8],
credential_id: &[u8],
credential_alg: i32,
credential_key: &CosePublicKey,
aaguid: [u8; 16],
policy: &AttestationPolicy,
) -> Result<AttestationResult, WebauthnError> {
let format = AttestationFormat::from_fmt(fmt);
if format == AttestationFormat::Unknown {
if policy.allow_unknown_formats {
return Ok(AttestationResult {
format,
aaguid: Some(aaguid),
trust_level: TrustLevel::None,
warnings: vec![warnings::UNVERIFIED_FORMAT],
});
}
return Err(WebauthnError::AttestationError(format!(
"unsupported attestation format: '{fmt}'"
)));
}
let statement = parse_statement(&format, att_stmt)?;
match statement {
AttestationStatement::None => verify_none_statement(aaguid),
AttestationStatement::Packed { alg, sig, x5c } => {
if x5c.is_empty() {
verify_packed_self(
&alg,
&sig,
auth_data,
client_data_hash,
credential_alg,
credential_key,
aaguid,
)
} else {
verify_packed_x5c(
&alg,
&sig,
auth_data,
client_data_hash,
&x5c,
aaguid,
policy,
)
}
}
AttestationStatement::FidoU2f { sig, cert } => verify_fido_u2f(
&sig,
&cert,
auth_data,
client_data_hash,
credential_id,
credential_alg,
credential_key,
aaguid,
policy,
),
}
}
fn verify_none_statement(aaguid: [u8; 16]) -> Result<AttestationResult, WebauthnError> {
Ok(AttestationResult {
format: AttestationFormat::None,
aaguid: Some(aaguid),
trust_level: TrustLevel::None,
warnings: Vec::new(),
})
}
fn verify_packed_self(
alg: &i32,
sig: &[u8],
auth_data: &[u8],
client_data_hash: &[u8],
credential_alg: i32,
credential_key: &CosePublicKey,
aaguid: [u8; 16],
) -> Result<AttestationResult, WebauthnError> {
if *alg != credential_alg {
return Err(WebauthnError::AttestationError(format!(
"packed self-attestation alg {alg} does not match credential key alg {credential_alg}"
)));
}
let signed = signed_data(auth_data, client_data_hash);
verify_cose_signature(*alg, credential_key, &signed, sig)?;
Ok(AttestationResult {
format: AttestationFormat::Packed,
aaguid: Some(aaguid),
trust_level: TrustLevel::SelfAttested,
warnings: Vec::new(),
})
}
fn verify_packed_x5c(
alg: &i32,
sig: &[u8],
auth_data: &[u8],
client_data_hash: &[u8],
x5c: &[Vec<u8>],
aaguid: [u8; 16],
policy: &AttestationPolicy,
) -> Result<AttestationResult, WebauthnError> {
if *alg != COSE_ALG_ES256 && *alg != COSE_ALG_RS256 {
return Err(WebauthnError::UnsupportedAlgorithm(*alg));
}
let leaf = Cert::from_der(x5c.first().ok_or_else(|| {
WebauthnError::AttestationError("packed attStmt has an empty x5c chain".to_string())
})?)?;
let expected = match *alg {
COSE_ALG_ES256 => KeyKind::Ec2,
_ => KeyKind::Rsa,
};
if leaf.kind != expected {
return Err(WebauthnError::AttestationError(
"packed attestation alg does not match the attestation certificate key type"
.to_string(),
));
}
let signed = signed_data(auth_data, client_data_hash);
verify_signature_with_cert(&leaf, &signed, sig)?;
check_aaguid_extension(&leaf, &aaguid)?;
let (trust_level, warnings) = verify_chain(x5c, policy)?;
Ok(AttestationResult {
format: AttestationFormat::Packed,
aaguid: Some(aaguid),
trust_level,
warnings,
})
}
#[allow(clippy::too_many_arguments)]
fn verify_fido_u2f(
sig: &[u8],
cert_der: &[u8],
auth_data: &[u8],
client_data_hash: &[u8],
credential_id: &[u8],
credential_alg: i32,
credential_key: &CosePublicKey,
aaguid: [u8; 16],
policy: &AttestationPolicy,
) -> Result<AttestationResult, WebauthnError> {
if credential_alg != COSE_ALG_ES256 {
return Err(WebauthnError::AttestationError(format!(
"fido-u2f registration requires an ES256 credential key, got alg {credential_alg}"
)));
}
let CosePublicKey::Ec2 { x, y } = credential_key else {
return Err(WebauthnError::AttestationError(
"fido-u2f registration requires an EC2 credential key".to_string(),
));
};
let cert = Cert::from_der(cert_der)?;
if cert.kind != KeyKind::Ec2 {
return Err(WebauthnError::AttestationError(
"fido-u2f attestation certificate must carry an EC P-256 key".to_string(),
));
}
if x.len() != 32 || y.len() != 32 {
return Err(WebauthnError::AttestationError(
"fido-u2f credential key coordinates must be 32 bytes (P-256)".to_string(),
));
}
let mut public_key_u2f = Vec::with_capacity(65);
public_key_u2f.push(0x04);
public_key_u2f.extend_from_slice(x);
public_key_u2f.extend_from_slice(y);
if auth_data.len() < 32 {
return Err(WebauthnError::AttestationError(
"authenticator data too short for fido-u2f verification".to_string(),
));
}
let mut verification_data = Vec::with_capacity(1 + 32 + 32 + credential_id.len() + 65);
verification_data.push(0x00);
verification_data.extend_from_slice(&auth_data[..32]); verification_data.extend_from_slice(client_data_hash);
verification_data.extend_from_slice(credential_id);
verification_data.extend_from_slice(&public_key_u2f);
verify_signature_with_cert(&cert, &verification_data, sig)?;
let (trust_level, warnings) = verify_chain(&[cert_der.to_vec()], policy)?;
Ok(AttestationResult {
format: AttestationFormat::FidoU2f,
aaguid: Some(aaguid),
trust_level,
warnings,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum KeyKind {
Ec2,
Rsa,
}
enum CertPublicKey {
EcPoint(Vec<u8>),
SpkiDer(Vec<u8>),
}
struct Cert {
tbs: Vec<u8>,
signature: Vec<u8>,
signature_alg_oid: Vec<u8>,
verifier_key: CertPublicKey,
issuer: Vec<u8>,
subject: Vec<u8>,
kind: KeyKind,
not_before: i64,
not_after: i64,
extensions: Vec<(Vec<u8>, Vec<u8>)>,
}
impl Cert {
fn from_der(der: &[u8]) -> Result<Self, WebauthnError> {
let (rem, parsed) = parse_x509_certificate(der).map_err(|e| {
WebauthnError::AttestationError(format!("attestation certificate parse error: {e}"))
})?;
if !rem.is_empty() {
return Err(WebauthnError::AttestationError(
"trailing bytes after attestation certificate".to_string(),
));
}
let tbs = &parsed.tbs_certificate;
if tbs.version() != X509Version::V3 {
return Err(WebauthnError::AttestationError(
"attestation certificate must be X.509 v3".to_string(),
));
}
let spki = tbs.public_key();
let spki_alg = spki.algorithm.algorithm.as_bytes();
let (verifier_key, kind) = if spki_alg == OID_ALG_EC_PUBLIC_KEY {
let point = spki.subject_public_key.data.to_vec();
if point.first() != Some(&0x04) || point.len() != 65 {
return Err(WebauthnError::AttestationError(
"attestation certificate EC key is not an uncompressed P-256 point".to_string(),
));
}
(CertPublicKey::EcPoint(point), KeyKind::Ec2)
} else if spki_alg == OID_ALG_RSA_ENCRYPTION {
(CertPublicKey::SpkiDer(spki.raw.to_vec()), KeyKind::Rsa)
} else {
return Err(WebauthnError::AttestationError(
"attestation certificate uses an unsupported public key algorithm".to_string(),
));
};
let extensions = tbs
.extensions()
.iter()
.map(|ext| (ext.oid.as_bytes().to_vec(), ext.value.to_vec()))
.collect();
Ok(Self {
tbs: tbs.as_ref().to_vec(),
signature: parsed.signature_value.data.to_vec(),
signature_alg_oid: parsed.signature_algorithm.algorithm.as_bytes().to_vec(),
verifier_key,
issuer: tbs.issuer().as_raw().to_vec(),
subject: tbs.subject().as_raw().to_vec(),
kind,
not_before: tbs.validity().not_before.timestamp(),
not_after: tbs.validity().not_after.timestamp(),
extensions,
})
}
fn is_valid_at(&self, now: i64) -> bool {
self.not_before <= now && now <= self.not_after
}
fn verify_signed_by(&self, parent: &Cert) -> Result<(), WebauthnError> {
if self.signature_alg_oid == OID_SIG_ECDSA_WITH_SHA256 {
if parent.kind != KeyKind::Ec2 {
return Err(WebauthnError::AttestationError(
"certificate signature algorithm does not match issuer key type".to_string(),
));
}
let CertPublicKey::EcPoint(point) = &parent.verifier_key else {
return Err(WebauthnError::AttestationError(
"issuer key is not an EC point".to_string(),
));
};
let verifier = ring::signature::UnparsedPublicKey::new(
&ring::signature::ECDSA_P256_SHA256_FIXED,
point,
);
verifier
.verify(&self.tbs, &self.signature)
.map_err(|_| WebauthnError::SignatureVerificationFailed)
} else if self.signature_alg_oid == OID_SIG_SHA256_WITH_RSA {
if parent.kind != KeyKind::Rsa {
return Err(WebauthnError::AttestationError(
"certificate signature algorithm does not match issuer key type".to_string(),
));
}
let CertPublicKey::SpkiDer(spki) = &parent.verifier_key else {
return Err(WebauthnError::AttestationError(
"issuer key is not an RSA SPKI".to_string(),
));
};
let verifier = ring::signature::UnparsedPublicKey::new(
&ring::signature::RSA_PKCS1_2048_8192_SHA256,
spki,
);
verifier
.verify(&self.tbs, &self.signature)
.map_err(|_| WebauthnError::SignatureVerificationFailed)
} else {
Err(WebauthnError::AttestationError(
"certificate uses an unsupported signature algorithm (only ecdsa-with-SHA256 \
and sha256WithRSAEncryption are accepted)"
.to_string(),
))
}
}
fn is_ca(&self) -> Option<bool> {
let (_, value) = self
.extensions
.iter()
.find(|(oid, _)| oid == OID_EXT_BASIC_CONSTRAINTS)?;
parse_basic_constraints(value)
}
}
fn parse_basic_constraints(value: &[u8]) -> Option<bool> {
let content = der_content(value, 0x30)?;
if content.first() == Some(&0x01) && content.len() >= 3 {
return Some(content[2] == 0xFF);
}
Some(false)
}
fn der_content(bytes: &[u8], tag: u8) -> Option<&[u8]> {
if bytes.first() != Some(&tag) {
return None;
}
let first = *bytes.get(1)?;
if first < 0x80 {
bytes.get(2..2 + first as usize)
} else {
let n = (first & 0x7F) as usize;
if n == 0 || n > 4 {
return None;
}
let mut len = 0usize;
for b in bytes.get(2..2 + n)? {
len = (len << 8) | (*b as usize);
}
bytes.get(2 + n..2 + n + len)
}
}
fn check_aaguid_extension(cert: &Cert, aaguid: &[u8; 16]) -> Result<(), WebauthnError> {
let Some((_, value)) = cert
.extensions
.iter()
.find(|(oid, _)| oid == OID_EXT_FIDO_GEN_CE_AAGUID)
else {
return Ok(()); };
let bad = || {
WebauthnError::AttestationError("id-fido-gen-ce-aaguid extension is malformed".to_string())
};
let content = der_content(value, 0x04).ok_or_else(bad)?;
if content.len() != 16 {
return Err(bad());
}
if content != aaguid.as_slice() {
return Err(WebauthnError::AttestationError(
"id-fido-gen-ce-aaguid extension does not match the attested credential data AAGUID"
.to_string(),
));
}
Ok(())
}
fn verify_chain(
x5c: &[Vec<u8>],
policy: &AttestationPolicy,
) -> Result<(TrustLevel, Vec<&'static str>), WebauthnError> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.map_err(|_| {
WebauthnError::AttestationError("system clock before unix epoch".to_string())
})?;
let mut certs = Vec::with_capacity(x5c.len());
for der in x5c {
let cert = Cert::from_der(der)?;
if !cert.is_valid_at(now) {
return Err(WebauthnError::AttestationError(
"attestation certificate is expired or not yet valid".to_string(),
));
}
certs.push(cert);
}
for pair in certs.windows(2) {
let (child, parent) = (&pair[0], &pair[1]);
if child.issuer != parent.subject {
return Err(WebauthnError::AttestationError(
"attestation chain issuer/subject names do not link".to_string(),
));
}
child.verify_signed_by(parent)?;
}
for parent in certs.iter().skip(1) {
if parent.is_ca() != Some(true) {
return Err(WebauthnError::AttestationError(
"attestation chain intermediate lacks basicConstraints cA=true".to_string(),
));
}
}
let root = certs
.last()
.ok_or_else(|| WebauthnError::AttestationError("attestation chain is empty".to_string()))?;
let root_der = x5c
.last()
.ok_or_else(|| WebauthnError::AttestationError("attestation chain is empty".to_string()))?;
for anchor in &policy.trust_anchors {
if root_der == anchor {
return Ok((TrustLevel::AttCa, Vec::new()));
}
let anchor_cert = Cert::from_der(anchor)?;
if root.issuer == anchor_cert.subject && root.verify_signed_by(&anchor_cert).is_ok() {
return Ok((TrustLevel::AttCa, Vec::new()));
}
}
if !policy.trust_anchors.is_empty() {
return Err(WebauthnError::AttestationError(
"attestation chain does not terminate at a configured trust anchor".to_string(),
));
}
let self_signed = root.issuer == root.subject && root.verify_signed_by(root).is_ok();
if !self_signed {
return Err(WebauthnError::AttestationError(
"attestation chain root is not self-signed and no trust anchors are configured"
.to_string(),
));
}
Ok((TrustLevel::BasicAtt, vec![warnings::UNANCHORED_CHAIN]))
}
fn signed_data(auth_data: &[u8], client_data_hash: &[u8]) -> Vec<u8> {
let mut signed = Vec::with_capacity(auth_data.len() + client_data_hash.len());
signed.extend_from_slice(auth_data);
signed.extend_from_slice(client_data_hash);
signed
}
fn verify_signature_with_cert(cert: &Cert, data: &[u8], sig: &[u8]) -> Result<(), WebauthnError> {
match &cert.verifier_key {
CertPublicKey::EcPoint(point) => {
let verifier = ring::signature::UnparsedPublicKey::new(
&ring::signature::ECDSA_P256_SHA256_FIXED,
point,
);
verifier
.verify(data, sig)
.map_err(|_| WebauthnError::SignatureVerificationFailed)
}
CertPublicKey::SpkiDer(spki) => {
let verifier = ring::signature::UnparsedPublicKey::new(
&ring::signature::RSA_PKCS1_2048_8192_SHA256,
spki,
);
verifier
.verify(data, sig)
.map_err(|_| WebauthnError::SignatureVerificationFailed)
}
}
}