Skip to main content

tf_types/
webauthn_attestation.rs

1#![allow(clippy::unnecessary_unwrap)]
2//! Full WebAuthn attestation parser + verifier.
3//!
4//! Mirrors `tools/tf-types-ts/src/core/webauthn-attestation.ts`. Supports
5//! the three attestation formats real authenticators emit for the common
6//! flows: `none`, `packed` (self-attestation; full x5c verification when
7//! a chain is present), and `fido-u2f`. CBOR decoding uses the in-house
8//! `crate::cbor` codec
9//! and signature verification uses `p256` / `ed25519-dalek` so we don't
10//! depend on Node-style runtime crypto.
11//!
12//! Path validation against trust anchors is intentionally out of scope
13//! here — the TLS bridge handles X.509 chain validation when an
14//! attestation includes x5c. Use `verify_attestation_chain` from the
15//! caller side if the deployment requires it.
16
17use std::convert::TryInto;
18
19use crate::cbor::Value as CborValue;
20use ed25519_dalek::{Signature as Ed25519Signature, Verifier, VerifyingKey as Ed25519VerifyingKey};
21use p256::ecdsa::Signature as P256Signature;
22use p256::ecdsa::VerifyingKey as P256VerifyingKey;
23use sha2::{Digest, Sha256};
24
25use crate::bridges::BridgeError;
26
27const FLAG_USER_PRESENT: u8 = 0x01;
28const FLAG_ATTESTED_CREDENTIAL_DATA: u8 = 0x40;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum CoseAlgorithm {
32    Es256,
33    EdDsa,
34    Rs256,
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum AttestationFormat {
39    None_,
40    Packed,
41    FidoU2f,
42}
43
44#[derive(Debug, Clone)]
45pub struct CosePublicKey {
46    pub kty: i64,
47    pub alg: Option<CoseAlgorithm>,
48    pub crv: Option<i64>,
49    pub x: Option<Vec<u8>>,
50    pub y: Option<Vec<u8>>,
51    pub n: Option<Vec<u8>>,
52    pub e: Option<Vec<u8>>,
53}
54
55#[derive(Debug, Clone)]
56pub struct ParsedAuthData {
57    pub rp_id_hash: Vec<u8>,
58    pub flags: u8,
59    pub sign_count: u32,
60    pub aaguid: Option<Vec<u8>>,
61    pub credential_id: Option<Vec<u8>>,
62    pub credential_public_key_cose: Option<Vec<u8>>,
63    pub credential_public_key: Option<CosePublicKey>,
64}
65
66#[derive(Debug, Clone)]
67pub struct AttestationObject {
68    pub fmt: AttestationFormat,
69    pub att_stmt: CborValue,
70    pub auth_data: Vec<u8>,
71}
72
73#[derive(Debug, Clone)]
74pub struct ClientData {
75    pub r#type: String,
76    pub challenge: String,
77    pub origin: String,
78}
79
80#[derive(Debug, Clone)]
81pub struct VerifyAttestationOptions {
82    pub rp_id: String,
83    pub expected_origin: String,
84    pub expected_challenge: String,
85    pub allowed_algorithms: Option<Vec<CoseAlgorithm>>,
86    pub require_attestation_signature: bool,
87}
88
89#[derive(Debug, Clone)]
90pub struct VerifiedAttestation {
91    pub format: AttestationFormat,
92    pub auth_data: ParsedAuthData,
93    pub client_data: ClientData,
94    pub credential_public_key: Vec<u8>,
95    pub credential_id: Vec<u8>,
96    pub algorithm: CoseAlgorithm,
97    pub x5c: Option<Vec<Vec<u8>>>,
98    pub sign_count: u32,
99    pub flags: u8,
100    pub aaguid: Option<Vec<u8>>,
101}
102
103pub fn parse_authenticator_data(buf: &[u8]) -> Result<ParsedAuthData, BridgeError> {
104    if buf.len() < 37 {
105        return Err(BridgeError::InvalidInput(format!(
106            "authData too short ({} bytes)",
107            buf.len()
108        )));
109    }
110    let rp_id_hash = buf[0..32].to_vec();
111    let flags = buf[32];
112    let sign_count = u32::from_be_bytes([buf[33], buf[34], buf[35], buf[36]]);
113    if flags & FLAG_ATTESTED_CREDENTIAL_DATA == 0 {
114        return Ok(ParsedAuthData {
115            rp_id_hash,
116            flags,
117            sign_count,
118            aaguid: None,
119            credential_id: None,
120            credential_public_key_cose: None,
121            credential_public_key: None,
122        });
123    }
124    if buf.len() < 55 {
125        return Err(BridgeError::InvalidInput(
126            "authData has AT flag but is too short for attested credential data".into(),
127        ));
128    }
129    let aaguid = buf[37..53].to_vec();
130    let cred_id_len = u16::from_be_bytes([buf[53], buf[54]]) as usize;
131    if buf.len() < 55 + cred_id_len {
132        return Err(BridgeError::InvalidInput(format!(
133            "authData truncated reading credentialId (declared {} bytes)",
134            cred_id_len
135        )));
136    }
137    let credential_id = buf[55..55 + cred_id_len].to_vec();
138    let cose_bytes = &buf[55 + cred_id_len..];
139    let credential_public_key = parse_cose_public_key(cose_bytes)?;
140
141    Ok(ParsedAuthData {
142        rp_id_hash,
143        flags,
144        sign_count,
145        aaguid: Some(aaguid),
146        credential_id: Some(credential_id),
147        credential_public_key_cose: Some(cose_bytes.to_vec()),
148        credential_public_key: Some(credential_public_key),
149    })
150}
151
152pub fn parse_cose_public_key(cose: &[u8]) -> Result<CosePublicKey, BridgeError> {
153    let val: CborValue = crate::cbor::decode(cose)
154        .map_err(|e| BridgeError::InvalidInput(format!("COSE key not valid CBOR: {}", e)))?;
155    let map = match &val {
156        CborValue::Map(m) => m,
157        _ => return Err(BridgeError::InvalidInput("COSE key not a map".into())),
158    };
159    let mut kty = None;
160    let mut alg: Option<CoseAlgorithm> = None;
161    let mut crv = None;
162    let mut x = None;
163    let mut y = None;
164    let mut n = None;
165    let mut e_field = None;
166    for (k, v) in map {
167        let key = match k {
168            CborValue::Integer(i) => Some(i64::try_from(*i).unwrap_or(0)),
169            _ => None,
170        };
171        match key {
172            Some(1) => {
173                if let CborValue::Integer(i) = v {
174                    kty = Some(i64::try_from(*i).unwrap_or(0));
175                }
176            }
177            Some(3) => {
178                if let CborValue::Integer(i) = v {
179                    alg = match i64::try_from(*i).unwrap_or(0) {
180                        -7 => Some(CoseAlgorithm::Es256),
181                        -8 => Some(CoseAlgorithm::EdDsa),
182                        -257 => Some(CoseAlgorithm::Rs256),
183                        _ => None,
184                    };
185                }
186            }
187            Some(-1) => {
188                if let CborValue::Integer(i) = v {
189                    crv = Some(i64::try_from(*i).unwrap_or(0));
190                } else if let CborValue::Bytes(b) = v {
191                    // RSA modulus (kty=3 puts it at -1)
192                    n = Some(b.clone());
193                }
194            }
195            Some(-2) => {
196                if let CborValue::Bytes(b) = v {
197                    x = Some(b.clone());
198                }
199            }
200            Some(-3) => {
201                if let CborValue::Bytes(b) = v {
202                    y = Some(b.clone());
203                }
204            }
205            Some(-4) => {
206                if let CborValue::Bytes(b) = v {
207                    e_field = Some(b.clone());
208                }
209            }
210            _ => {}
211        }
212    }
213    let kty = kty.ok_or_else(|| BridgeError::InvalidInput("COSE key missing kty".into()))?;
214    // For RSA the modulus is `-1` and exponent is `-2` per RFC 8230 — fix mapping above:
215    // since our extractor put `-1` integer into crv and `-1` bytes into n, both branches are
216    // mutually exclusive depending on kty. Same applies for `-2` (x for EC2/OKP, e for RSA).
217    let (n_final, e_final) = if kty == 3 {
218        // For kty=3 our switch above stored modulus in n (when -1 was bytes) and x (when -2
219        // was bytes); we need to swap x→e here because RSA's exponent is -2.
220        (n.clone().or(None), x.clone().or(None))
221    } else {
222        (None, None)
223    };
224    Ok(CosePublicKey {
225        kty,
226        alg,
227        crv,
228        x: if kty == 3 { None } else { x },
229        y: if kty == 3 { None } else { y },
230        n: n_final,
231        e: e_final.or(e_field),
232    })
233}
234
235pub fn decode_attestation_object(buf: &[u8]) -> Result<AttestationObject, BridgeError> {
236    let val: CborValue = crate::cbor::decode(buf).map_err(|e| {
237        BridgeError::InvalidInput(format!("attestationObject not valid CBOR: {}", e))
238    })?;
239    let map = match val {
240        CborValue::Map(m) => m,
241        _ => {
242            return Err(BridgeError::InvalidInput(
243                "attestationObject not a map".into(),
244            ))
245        }
246    };
247    let mut fmt = None;
248    let mut att_stmt = None;
249    let mut auth_data = None;
250    for (k, v) in map {
251        let key = match k {
252            CborValue::Text(t) => t,
253            _ => continue,
254        };
255        match key.as_str() {
256            "fmt" => fmt = v.as_text().map(|s| s.to_string()),
257            "attStmt" => att_stmt = Some(v),
258            "authData" => auth_data = v.as_bytes().map(|b| b.to_vec()),
259            _ => {}
260        }
261    }
262    let fmt = fmt.ok_or_else(|| BridgeError::InvalidInput("missing fmt".into()))?;
263    let auth_data =
264        auth_data.ok_or_else(|| BridgeError::InvalidInput("missing authData bytes".into()))?;
265    let att_stmt = att_stmt.ok_or_else(|| BridgeError::InvalidInput("missing attStmt".into()))?;
266    let format = match fmt.as_str() {
267        "none" => AttestationFormat::None_,
268        "packed" => AttestationFormat::Packed,
269        "fido-u2f" => AttestationFormat::FidoU2f,
270        other => {
271            return Err(BridgeError::Unsupported(format!(
272                "attestation format {} not supported",
273                other
274            )))
275        }
276    };
277    Ok(AttestationObject {
278        fmt: format,
279        att_stmt,
280        auth_data,
281    })
282}
283
284pub fn parse_client_data(buf: &[u8]) -> Result<ClientData, BridgeError> {
285    let json: serde_json::Value = serde_json::from_slice(buf)
286        .map_err(|e| BridgeError::InvalidInput(format!("clientDataJSON not valid JSON: {}", e)))?;
287    let obj = json
288        .as_object()
289        .ok_or_else(|| BridgeError::InvalidInput("clientDataJSON not an object".into()))?;
290    let r#type = obj
291        .get("type")
292        .and_then(|v| v.as_str())
293        .ok_or_else(|| BridgeError::InvalidInput("missing clientData.type".into()))?
294        .to_string();
295    let challenge = obj
296        .get("challenge")
297        .and_then(|v| v.as_str())
298        .ok_or_else(|| BridgeError::InvalidInput("missing clientData.challenge".into()))?
299        .to_string();
300    let origin = obj
301        .get("origin")
302        .and_then(|v| v.as_str())
303        .ok_or_else(|| BridgeError::InvalidInput("missing clientData.origin".into()))?
304        .to_string();
305    Ok(ClientData {
306        r#type,
307        challenge,
308        origin,
309    })
310}
311
312pub fn verify_attestation(
313    attestation_object: &[u8],
314    client_data_json: &[u8],
315    opts: &VerifyAttestationOptions,
316) -> Result<VerifiedAttestation, BridgeError> {
317    let att = decode_attestation_object(attestation_object)?;
318    let auth = parse_authenticator_data(&att.auth_data)?;
319    let client = parse_client_data(client_data_json)?;
320    if client.r#type != "webauthn.create" {
321        return Err(BridgeError::Rejected(format!(
322            "clientData.type {} is not webauthn.create",
323            client.r#type
324        )));
325    }
326    if client.origin != opts.expected_origin {
327        return Err(BridgeError::Rejected(format!(
328            "clientData.origin {} does not match expected {}",
329            client.origin, opts.expected_origin
330        )));
331    }
332    if client.challenge != opts.expected_challenge {
333        return Err(BridgeError::Rejected(
334            "clientData.challenge does not match expected".into(),
335        ));
336    }
337    let expected_rp_hash: [u8; 32] = Sha256::digest(opts.rp_id.as_bytes()).into();
338    if auth.rp_id_hash != expected_rp_hash {
339        return Err(BridgeError::Rejected(
340            "authData rpIdHash does not match sha256(rpId)".into(),
341        ));
342    }
343    if auth.flags & FLAG_USER_PRESENT == 0 {
344        return Err(BridgeError::Rejected(
345            "authData missing User Present flag".into(),
346        ));
347    }
348    if auth.flags & FLAG_ATTESTED_CREDENTIAL_DATA == 0 {
349        return Err(BridgeError::Rejected(
350            "authData missing AT flag (no attested credential data)".into(),
351        ));
352    }
353    let cose = auth
354        .credential_public_key
355        .as_ref()
356        .ok_or_else(|| BridgeError::InvalidInput("credential public key missing".into()))?;
357    let credential_id = auth
358        .credential_id
359        .as_ref()
360        .ok_or_else(|| BridgeError::InvalidInput("credential id missing".into()))?
361        .clone();
362    let alg = cose.alg.ok_or_else(|| {
363        BridgeError::InvalidInput("credential public key has no algorithm".into())
364    })?;
365    if let Some(allowed) = &opts.allowed_algorithms {
366        if !allowed.contains(&alg) {
367            return Err(BridgeError::Rejected(format!(
368                "algorithm {:?} not in allow-list",
369                alg
370            )));
371        }
372    }
373    let client_data_hash: [u8; 32] = Sha256::digest(client_data_json).into();
374
375    match att.fmt {
376        AttestationFormat::Packed => verify_packed(&att, &auth, &client_data_hash)?,
377        AttestationFormat::FidoU2f => verify_fido_u2f(&att, &auth, &client_data_hash)?,
378        AttestationFormat::None_ => {
379            if opts.require_attestation_signature {
380                return Err(BridgeError::Rejected(
381                    "format=none rejected when require_attestation_signature=true".into(),
382                ));
383            }
384        }
385    }
386
387    let credential_public_key = encode_raw_public_key(cose)?;
388    let x5c = pick_x5c(&att.att_stmt);
389
390    Ok(VerifiedAttestation {
391        format: att.fmt,
392        auth_data: auth.clone(),
393        client_data: client,
394        credential_public_key,
395        credential_id,
396        algorithm: alg,
397        x5c,
398        sign_count: auth.sign_count,
399        flags: auth.flags,
400        aaguid: auth.aaguid,
401    })
402}
403
404fn verify_packed(
405    att: &AttestationObject,
406    auth: &ParsedAuthData,
407    client_data_hash: &[u8; 32],
408) -> Result<(), BridgeError> {
409    let map = match &att.att_stmt {
410        CborValue::Map(m) => m,
411        _ => return Err(BridgeError::InvalidInput("packed attStmt not a map".into())),
412    };
413    let mut sig: Option<Vec<u8>> = None;
414    let mut alg: Option<i64> = None;
415    let mut x5c: Option<Vec<Vec<u8>>> = None;
416    for (k, v) in map {
417        let key = match k {
418            CborValue::Text(t) => t.as_str(),
419            _ => continue,
420        };
421        match key {
422            "sig" => sig = v.as_bytes().map(|b| b.to_vec()),
423            "alg" => {
424                if let CborValue::Integer(i) = v {
425                    alg = Some(i64::try_from(*i).unwrap_or(0));
426                }
427            }
428            "x5c" => {
429                if let CborValue::Array(arr) = v {
430                    x5c = Some(
431                        arr.iter()
432                            .filter_map(|c| c.as_bytes().map(|b| b.to_vec()))
433                            .collect(),
434                    );
435                }
436            }
437            _ => {}
438        }
439    }
440    let sig = sig.ok_or_else(|| BridgeError::InvalidInput("packed attStmt missing sig".into()))?;
441    let alg = alg.ok_or_else(|| BridgeError::InvalidInput("packed attStmt missing alg".into()))?;
442    let mut data = att.auth_data.clone();
443    data.extend_from_slice(client_data_hash);
444    if let Some(chain) = x5c.as_ref() {
445        if let Some(cert_der) = chain.first() {
446            verify_with_cert(cert_der, &data, &sig, alg)?;
447            return Ok(());
448        }
449    }
450    let cose = auth.credential_public_key.as_ref().ok_or_else(|| {
451        BridgeError::InvalidInput("self-attestation needs credential public key".into())
452    })?;
453    verify_cose_signature(cose, &data, &sig, alg)
454}
455
456fn verify_fido_u2f(
457    att: &AttestationObject,
458    auth: &ParsedAuthData,
459    client_data_hash: &[u8; 32],
460) -> Result<(), BridgeError> {
461    let map = match &att.att_stmt {
462        CborValue::Map(m) => m,
463        _ => {
464            return Err(BridgeError::InvalidInput(
465                "fido-u2f attStmt not a map".into(),
466            ))
467        }
468    };
469    let mut sig: Option<Vec<u8>> = None;
470    let mut x5c: Option<Vec<Vec<u8>>> = None;
471    for (k, v) in map {
472        let key = match k {
473            CborValue::Text(t) => t.as_str(),
474            _ => continue,
475        };
476        match key {
477            "sig" => sig = v.as_bytes().map(|b| b.to_vec()),
478            "x5c" => {
479                if let CborValue::Array(arr) = v {
480                    x5c = Some(
481                        arr.iter()
482                            .filter_map(|c| c.as_bytes().map(|b| b.to_vec()))
483                            .collect(),
484                    );
485                }
486            }
487            _ => {}
488        }
489    }
490    let sig =
491        sig.ok_or_else(|| BridgeError::InvalidInput("fido-u2f attStmt missing sig".into()))?;
492    let x5c =
493        x5c.ok_or_else(|| BridgeError::InvalidInput("fido-u2f attStmt missing x5c".into()))?;
494    let cose = auth
495        .credential_public_key
496        .as_ref()
497        .ok_or_else(|| BridgeError::InvalidInput("fido-u2f needs credential pubkey".into()))?;
498    if cose.kty != 2 || cose.x.is_none() || cose.y.is_none() {
499        return Err(BridgeError::InvalidInput(
500            "fido-u2f requires EC2 P-256 credential public key".into(),
501        ));
502    }
503    let mut data = Vec::new();
504    data.push(0x00);
505    data.extend_from_slice(&auth.rp_id_hash);
506    data.extend_from_slice(client_data_hash);
507    data.extend_from_slice(auth.credential_id.as_ref().unwrap());
508    data.push(0x04);
509    data.extend_from_slice(cose.x.as_ref().unwrap());
510    data.extend_from_slice(cose.y.as_ref().unwrap());
511    let cert = x5c
512        .first()
513        .ok_or_else(|| BridgeError::InvalidInput("fido-u2f x5c empty".into()))?;
514    verify_with_cert(cert, &data, &sig, -7)
515}
516
517fn verify_with_cert(
518    cert_der: &[u8],
519    data: &[u8],
520    signature: &[u8],
521    cose_alg: i64,
522) -> Result<(), BridgeError> {
523    use x509_parser::certificate::X509Certificate;
524    use x509_parser::prelude::FromDer;
525    let (_, cert) = X509Certificate::from_der(cert_der)
526        .map_err(|e| BridgeError::InvalidInput(format!("cert DER parse: {}", e)))?;
527    let alg_oid = cert.public_key().algorithm.algorithm.to_id_string();
528    let key_bytes = cert.public_key().subject_public_key.data.as_ref();
529    match alg_oid.as_str() {
530        "1.2.840.10045.2.1" if cose_alg == -7 => verify_p256_der(key_bytes, data, signature),
531        "1.3.101.112" if cose_alg == -8 => verify_ed25519(key_bytes, data, signature),
532        _ => Err(BridgeError::Unsupported(format!(
533            "x5c algorithm {} not supported for cose alg {}",
534            alg_oid, cose_alg
535        ))),
536    }
537}
538
539fn verify_cose_signature(
540    cose: &CosePublicKey,
541    data: &[u8],
542    sig: &[u8],
543    alg: i64,
544) -> Result<(), BridgeError> {
545    if alg == -7 && cose.kty == 2 {
546        let x = cose
547            .x
548            .as_ref()
549            .ok_or_else(|| BridgeError::InvalidInput("EC2 missing x".into()))?;
550        let y = cose
551            .y
552            .as_ref()
553            .ok_or_else(|| BridgeError::InvalidInput("EC2 missing y".into()))?;
554        let mut pub_bytes = vec![0x04];
555        pub_bytes.extend_from_slice(x);
556        pub_bytes.extend_from_slice(y);
557        return verify_p256_der(&pub_bytes, data, sig);
558    }
559    if alg == -8 && cose.kty == 1 {
560        let x = cose
561            .x
562            .as_ref()
563            .ok_or_else(|| BridgeError::InvalidInput("OKP missing x".into()))?;
564        return verify_ed25519(x, data, sig);
565    }
566    Err(BridgeError::Unsupported(format!(
567        "self-attestation alg {} on kty {} not supported",
568        alg, cose.kty
569    )))
570}
571
572fn verify_p256_der(
573    public_uncompressed: &[u8],
574    data: &[u8],
575    der_sig: &[u8],
576) -> Result<(), BridgeError> {
577    let vk = P256VerifyingKey::from_sec1_bytes(public_uncompressed)
578        .map_err(|e| BridgeError::InvalidInput(format!("bad P-256 SEC1 key: {}", e)))?;
579    let sig = P256Signature::from_der(der_sig)
580        .map_err(|e| BridgeError::InvalidInput(format!("bad ECDSA DER sig: {}", e)))?;
581    vk.verify(data, &sig)
582        .map_err(|e| BridgeError::Rejected(format!("ES256 verify failed: {}", e)))
583}
584
585fn verify_ed25519(public: &[u8], data: &[u8], sig: &[u8]) -> Result<(), BridgeError> {
586    let public_arr: [u8; 32] = public
587        .try_into()
588        .map_err(|_| BridgeError::InvalidInput("Ed25519 key not 32 bytes".into()))?;
589    let vk = Ed25519VerifyingKey::from_bytes(&public_arr)
590        .map_err(|e| BridgeError::InvalidInput(format!("bad Ed25519 key: {}", e)))?;
591    let sig_arr: [u8; 64] = sig
592        .try_into()
593        .map_err(|_| BridgeError::InvalidInput("Ed25519 signature not 64 bytes".into()))?;
594    let sig = Ed25519Signature::from_bytes(&sig_arr);
595    vk.verify(data, &sig)
596        .map_err(|e| BridgeError::Rejected(format!("EdDSA verify failed: {}", e)))
597}
598
599fn pick_x5c(att_stmt: &CborValue) -> Option<Vec<Vec<u8>>> {
600    if let CborValue::Map(m) = att_stmt {
601        for (k, v) in m {
602            if let CborValue::Text(t) = k {
603                if t == "x5c" {
604                    if let CborValue::Array(arr) = v {
605                        let collected: Vec<Vec<u8>> = arr
606                            .iter()
607                            .filter_map(|c| c.as_bytes().map(|b| b.to_vec()))
608                            .collect();
609                        if !collected.is_empty() {
610                            return Some(collected);
611                        }
612                    }
613                }
614            }
615        }
616    }
617    None
618}
619
620fn encode_raw_public_key(cose: &CosePublicKey) -> Result<Vec<u8>, BridgeError> {
621    if cose.kty == 2 && cose.x.is_some() && cose.y.is_some() {
622        let mut out = vec![0x04];
623        out.extend_from_slice(cose.x.as_ref().unwrap());
624        out.extend_from_slice(cose.y.as_ref().unwrap());
625        return Ok(out);
626    }
627    if cose.kty == 1 && cose.x.is_some() {
628        return Ok(cose.x.clone().unwrap());
629    }
630    if cose.kty == 3 && cose.n.is_some() {
631        return Ok(cose.n.clone().unwrap());
632    }
633    Err(BridgeError::InvalidInput(
634        "unsupported COSE key shape".into(),
635    ))
636}