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