Skip to main content

cdx_core/security/
webauthn.rs

1//! WebAuthn/FIDO2 signature verification.
2//!
3//! WebAuthn signatures are created client-side (in browsers or native apps)
4//! using hardware security keys or platform authenticators. This module
5//! provides verification of WebAuthn assertion responses.
6//!
7//! # Verification Process
8//!
9//! 1. Decode the base64-encoded fields from [`WebAuthnSignature`]
10//! 2. Parse the client data JSON and verify the challenge matches the document ID
11//! 3. Verify the origin matches the expected relying party
12//! 4. Verify the signature over authenticator data + SHA-256(client data JSON)
13//!
14//! # Example
15//!
16//! ```ignore
17//! use cdx_core::security::{WebAuthnVerifier, WebAuthnSignature, Signature};
18//!
19//! let verifier = WebAuthnVerifier::new(
20//!     "https://example.com",  // expected origin
21//!     public_key_bytes,       // credential public key
22//! )?;
23//!
24//! let result = verifier.verify(&document_id, &signature)?;
25//! ```
26
27use base64::Engine;
28use p256::ecdsa::{signature::Verifier as _, VerifyingKey};
29use serde::Deserialize;
30use sha2::{Digest, Sha256};
31
32use super::signature::{Signature, SignatureVerification, WebAuthnSignature};
33use super::Verifier;
34use crate::error::signature_error;
35use crate::{DocumentId, Result};
36
37/// WebAuthn client data structure.
38///
39/// Parsed from the `clientDataJSON` field of a WebAuthn assertion.
40#[derive(Debug, Deserialize)]
41#[serde(rename_all = "camelCase")]
42struct ClientData {
43    /// The type of operation (should be "webauthn.get" for assertions).
44    #[serde(rename = "type")]
45    type_: String,
46
47    /// The challenge, base64url-encoded.
48    challenge: String,
49
50    /// The origin of the request.
51    origin: String,
52
53    /// Cross-origin flag (optional, required by WebAuthn spec for deserialization).
54    #[serde(default)]
55    #[allow(dead_code)]
56    cross_origin: Option<bool>,
57}
58
59/// WebAuthn signature verifier.
60///
61/// Verifies WebAuthn assertion responses using the credential's public key.
62/// The document ID is used as the challenge for verification.
63pub struct WebAuthnVerifier {
64    /// Expected origin (e.g., `https://example.com`).
65    expected_origin: String,
66
67    /// The credential's public key for signature verification.
68    verifying_key: VerifyingKey,
69
70    /// Expected credential ID (optional, for additional validation).
71    expected_credential_id: Option<Vec<u8>>,
72}
73
74impl WebAuthnVerifier {
75    /// Create a new WebAuthn verifier.
76    ///
77    /// # Arguments
78    ///
79    /// * `expected_origin` - The expected origin (e.g., `https://example.com`)
80    /// * `public_key` - The credential's public key in uncompressed SEC1 format (65 bytes)
81    ///   or compressed format (33 bytes)
82    ///
83    /// # Errors
84    ///
85    /// Returns an error if the public key cannot be parsed.
86    pub fn new(expected_origin: impl Into<String>, public_key: &[u8]) -> Result<Self> {
87        let verifying_key = VerifyingKey::from_sec1_bytes(public_key)
88            .map_err(|e| signature_error(format!("Invalid WebAuthn public key: {e}")))?;
89
90        Ok(Self {
91            expected_origin: expected_origin.into(),
92            verifying_key,
93            expected_credential_id: None,
94        })
95    }
96
97    /// Create a verifier from a PEM-encoded public key.
98    ///
99    /// # Errors
100    ///
101    /// Returns an error if the PEM cannot be parsed.
102    pub fn from_pem(expected_origin: impl Into<String>, pem: &str) -> Result<Self> {
103        use p256::pkcs8::DecodePublicKey;
104
105        let verifying_key = VerifyingKey::from_public_key_pem(pem)
106            .map_err(|e| signature_error(format!("Invalid WebAuthn public key PEM: {e}")))?;
107
108        Ok(Self {
109            expected_origin: expected_origin.into(),
110            verifying_key,
111            expected_credential_id: None,
112        })
113    }
114
115    /// Set the expected credential ID for additional validation.
116    #[must_use]
117    pub fn with_credential_id(mut self, credential_id: Vec<u8>) -> Self {
118        self.expected_credential_id = Some(credential_id);
119        self
120    }
121
122    /// Verify a WebAuthn signature.
123    fn verify_webauthn(
124        &self,
125        signature_id: &str,
126        document_id: &DocumentId,
127        webauthn: &WebAuthnSignature,
128    ) -> Result<SignatureVerification> {
129        let engine = base64::engine::general_purpose::STANDARD;
130
131        // Decode credential ID
132        let credential_id = engine
133            .decode(&webauthn.credential_id)
134            .map_err(|e| signature_error(format!("Invalid credential ID base64: {e}")))?;
135
136        // Verify credential ID if expected
137        if let Some(ref expected) = self.expected_credential_id {
138            if &credential_id != expected {
139                return Ok(SignatureVerification::invalid(
140                    signature_id,
141                    "Credential ID mismatch",
142                ));
143            }
144        }
145
146        // Decode client data JSON
147        let client_data_bytes = engine
148            .decode(&webauthn.client_data_json)
149            .map_err(|e| signature_error(format!("Invalid clientDataJSON base64: {e}")))?;
150
151        // Parse client data
152        let client_data: ClientData = serde_json::from_slice(&client_data_bytes)
153            .map_err(|e| signature_error(format!("Invalid clientDataJSON: {e}")))?;
154
155        // Verify type
156        if client_data.type_ != "webauthn.get" {
157            return Ok(SignatureVerification::invalid(
158                signature_id,
159                format!(
160                    "Invalid type: expected 'webauthn.get', got '{}'",
161                    client_data.type_
162                ),
163            ));
164        }
165
166        // Verify origin
167        if client_data.origin != self.expected_origin {
168            return Ok(SignatureVerification::invalid(
169                signature_id,
170                format!(
171                    "Origin mismatch: expected '{}', got '{}'",
172                    self.expected_origin, client_data.origin
173                ),
174            ));
175        }
176
177        // Verify challenge matches document ID
178        // WebAuthn uses base64url encoding for the challenge
179        let challenge_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
180            .decode(&client_data.challenge)
181            .map_err(|e| signature_error(format!("Invalid challenge base64url: {e}")))?;
182
183        if challenge_bytes != document_id.digest() {
184            return Ok(SignatureVerification::invalid(
185                signature_id,
186                "Challenge does not match document ID",
187            ));
188        }
189
190        // Decode authenticator data
191        let authenticator_data = engine
192            .decode(&webauthn.authenticator_data)
193            .map_err(|e| signature_error(format!("Invalid authenticatorData base64: {e}")))?;
194
195        // Verify authenticator data is at least 37 bytes (RP ID hash + flags + counter)
196        if authenticator_data.len() < 37 {
197            return Ok(SignatureVerification::invalid(
198                signature_id,
199                "Authenticator data too short",
200            ));
201        }
202
203        // Verify RP ID hash (first 32 bytes of authenticator data)
204        // Per WebAuthn spec section 7.2 step 13: rpIdHash must equal SHA-256(rp_id)
205        let expected_rp_id = self
206            .expected_origin
207            .strip_prefix("https://")
208            .or_else(|| self.expected_origin.strip_prefix("http://"))
209            .unwrap_or(&self.expected_origin);
210        let expected_rp_id_hash = Sha256::digest(expected_rp_id.as_bytes());
211        if authenticator_data[..32] != expected_rp_id_hash[..] {
212            return Ok(SignatureVerification::invalid(
213                signature_id,
214                "RP ID hash mismatch in authenticator data",
215            ));
216        }
217
218        // Check user presence flag (bit 0 of flags byte at offset 32)
219        let flags = authenticator_data[32];
220        if flags & 0x01 == 0 {
221            return Ok(SignatureVerification::invalid(
222                signature_id,
223                "User presence flag not set",
224            ));
225        }
226
227        // Decode signature
228        let signature_bytes = engine
229            .decode(&webauthn.signature)
230            .map_err(|e| signature_error(format!("Invalid signature base64: {e}")))?;
231
232        // Parse the signature (DER-encoded ECDSA signature)
233        let signature = p256::ecdsa::DerSignature::from_bytes(&signature_bytes)
234            .map_err(|e| signature_error(format!("Invalid ECDSA signature: {e}")))?;
235
236        // Compute the signed data: authenticator_data || SHA-256(clientDataJSON)
237        let client_data_hash = Sha256::digest(&client_data_bytes);
238        let mut signed_data = authenticator_data.clone();
239        signed_data.extend_from_slice(&client_data_hash);
240
241        // Verify the signature
242        match self.verifying_key.verify(&signed_data, &signature) {
243            Ok(()) => Ok(SignatureVerification::valid(signature_id)),
244            Err(e) => Ok(SignatureVerification::invalid(
245                signature_id,
246                format!("Signature verification failed: {e}"),
247            )),
248        }
249    }
250}
251
252impl Verifier for WebAuthnVerifier {
253    fn verify(
254        &self,
255        document_id: &DocumentId,
256        signature: &Signature,
257    ) -> Result<SignatureVerification> {
258        // Check if this is a WebAuthn signature
259        let Some(webauthn) = &signature.webauthn else {
260            return Ok(SignatureVerification::invalid(
261                &signature.id,
262                "Not a WebAuthn signature",
263            ));
264        };
265
266        // Verify the WebAuthn assertion
267        self.verify_webauthn(&signature.id, document_id, webauthn)
268    }
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::security::SignerInfo;
275    use crate::{HashAlgorithm, Hasher};
276
277    #[test]
278    fn test_webauthn_signature_new() {
279        let sig = WebAuthnSignature::new(
280            "Y3JlZGVudGlhbC1pZA==",
281            "YXV0aGVudGljYXRvci1kYXRh",
282            "Y2xpZW50LWRhdGEtanNvbg==",
283            "c2lnbmF0dXJl",
284        );
285
286        assert_eq!(sig.credential_id, "Y3JlZGVudGlhbC1pZA==");
287        assert_eq!(sig.authenticator_data, "YXV0aGVudGljYXRvci1kYXRh");
288        assert_eq!(sig.client_data_json, "Y2xpZW50LWRhdGEtanNvbg==");
289        assert_eq!(sig.signature, "c2lnbmF0dXJl");
290    }
291
292    #[test]
293    fn test_signature_new_webauthn() {
294        let webauthn = WebAuthnSignature::new("Y3JlZA==", "YXV0aA==", "Y2xpZW50", "c2ln");
295
296        let signer = SignerInfo::new("Test User");
297        let sig = Signature::new_webauthn("sig-webauthn-1", signer, webauthn);
298
299        assert!(sig.is_webauthn());
300        assert_eq!(sig.algorithm, super::super::SignatureAlgorithm::ES256);
301        assert!(sig.value.is_empty());
302        assert!(sig.webauthn_data().is_some());
303    }
304
305    #[test]
306    fn test_webauthn_signature_serialization() {
307        let webauthn =
308            WebAuthnSignature::new("credential-id", "auth-data", "client-data", "signature");
309
310        let json = serde_json::to_string(&webauthn).unwrap();
311        assert!(json.contains("\"credentialId\":\"credential-id\""));
312        assert!(json.contains("\"authenticatorData\":\"auth-data\""));
313        assert!(json.contains("\"clientDataJson\":\"client-data\""));
314        assert!(json.contains("\"signature\":\"signature\""));
315
316        let parsed: WebAuthnSignature = serde_json::from_str(&json).unwrap();
317        assert_eq!(parsed.credential_id, "credential-id");
318    }
319
320    #[test]
321    fn test_verifier_rejects_non_webauthn() {
322        // Generate a test key pair
323        use p256::elliptic_curve::Generate;
324        let signing_key = p256::ecdsa::SigningKey::generate();
325        let verifying_key = signing_key.verifying_key();
326        let public_key = verifying_key.to_sec1_bytes();
327
328        let verifier = WebAuthnVerifier::new("https://example.com", &public_key).unwrap();
329
330        let doc_id = Hasher::hash(HashAlgorithm::Sha256, b"test");
331        let signer = SignerInfo::new("Test");
332        let sig = Signature::new(
333            "sig-1",
334            super::super::SignatureAlgorithm::ES256,
335            signer,
336            "base64sig",
337        );
338
339        let result = verifier.verify(&doc_id, &sig).unwrap();
340        assert!(!result.is_valid());
341        assert!(result.error.as_ref().unwrap().contains("Not a WebAuthn"));
342    }
343}