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::invalid_manifest;
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| invalid_manifest(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| invalid_manifest(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        document_id: &DocumentId,
126        webauthn: &WebAuthnSignature,
127    ) -> Result<SignatureVerification> {
128        let engine = base64::engine::general_purpose::STANDARD;
129
130        // Decode credential ID
131        let credential_id = engine
132            .decode(&webauthn.credential_id)
133            .map_err(|e| invalid_manifest(format!("Invalid credential ID base64: {e}")))?;
134
135        // Verify credential ID if expected
136        if let Some(ref expected) = self.expected_credential_id {
137            if &credential_id != expected {
138                return Ok(SignatureVerification::invalid("", "Credential ID mismatch"));
139            }
140        }
141
142        // Decode client data JSON
143        let client_data_bytes = engine
144            .decode(&webauthn.client_data_json)
145            .map_err(|e| invalid_manifest(format!("Invalid clientDataJSON base64: {e}")))?;
146
147        // Parse client data
148        let client_data: ClientData = serde_json::from_slice(&client_data_bytes)
149            .map_err(|e| invalid_manifest(format!("Invalid clientDataJSON: {e}")))?;
150
151        // Verify type
152        if client_data.type_ != "webauthn.get" {
153            return Ok(SignatureVerification::invalid(
154                "",
155                format!(
156                    "Invalid type: expected 'webauthn.get', got '{}'",
157                    client_data.type_
158                ),
159            ));
160        }
161
162        // Verify origin
163        if client_data.origin != self.expected_origin {
164            return Ok(SignatureVerification::invalid(
165                "",
166                format!(
167                    "Origin mismatch: expected '{}', got '{}'",
168                    self.expected_origin, client_data.origin
169                ),
170            ));
171        }
172
173        // Verify challenge matches document ID
174        // WebAuthn uses base64url encoding for the challenge
175        let challenge_bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
176            .decode(&client_data.challenge)
177            .map_err(|e| invalid_manifest(format!("Invalid challenge base64url: {e}")))?;
178
179        if challenge_bytes != document_id.digest() {
180            return Ok(SignatureVerification::invalid(
181                "",
182                "Challenge does not match document ID",
183            ));
184        }
185
186        // Decode authenticator data
187        let authenticator_data = engine
188            .decode(&webauthn.authenticator_data)
189            .map_err(|e| invalid_manifest(format!("Invalid authenticatorData base64: {e}")))?;
190
191        // Verify authenticator data is at least 37 bytes (RP ID hash + flags + counter)
192        if authenticator_data.len() < 37 {
193            return Ok(SignatureVerification::invalid(
194                "",
195                "Authenticator data too short",
196            ));
197        }
198
199        // Check user presence flag (bit 0 of flags byte at offset 32)
200        let flags = authenticator_data[32];
201        if flags & 0x01 == 0 {
202            return Ok(SignatureVerification::invalid(
203                "",
204                "User presence flag not set",
205            ));
206        }
207
208        // Decode signature
209        let signature_bytes = engine
210            .decode(&webauthn.signature)
211            .map_err(|e| invalid_manifest(format!("Invalid signature base64: {e}")))?;
212
213        // Parse the signature (DER-encoded ECDSA signature)
214        let signature = p256::ecdsa::DerSignature::from_bytes(&signature_bytes)
215            .map_err(|e| invalid_manifest(format!("Invalid ECDSA signature: {e}")))?;
216
217        // Compute the signed data: authenticator_data || SHA-256(clientDataJSON)
218        let client_data_hash = Sha256::digest(&client_data_bytes);
219        let mut signed_data = authenticator_data.clone();
220        signed_data.extend_from_slice(&client_data_hash);
221
222        // Verify the signature
223        match self.verifying_key.verify(&signed_data, &signature) {
224            Ok(()) => Ok(SignatureVerification::valid("")),
225            Err(e) => Ok(SignatureVerification::invalid(
226                "",
227                format!("Signature verification failed: {e}"),
228            )),
229        }
230    }
231}
232
233impl Verifier for WebAuthnVerifier {
234    fn verify(
235        &self,
236        document_id: &DocumentId,
237        signature: &Signature,
238    ) -> Result<SignatureVerification> {
239        // Check if this is a WebAuthn signature
240        let Some(webauthn) = &signature.webauthn else {
241            return Ok(SignatureVerification::invalid(
242                &signature.id,
243                "Not a WebAuthn signature",
244            ));
245        };
246
247        // Verify the WebAuthn assertion
248        let mut result = self.verify_webauthn(document_id, webauthn)?;
249
250        // Update the signature ID in the result
251        result.signature_id.clone_from(&signature.id);
252
253        Ok(result)
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use crate::security::SignerInfo;
261    use crate::{HashAlgorithm, Hasher};
262
263    #[test]
264    fn test_webauthn_signature_new() {
265        let sig = WebAuthnSignature::new(
266            "Y3JlZGVudGlhbC1pZA==",
267            "YXV0aGVudGljYXRvci1kYXRh",
268            "Y2xpZW50LWRhdGEtanNvbg==",
269            "c2lnbmF0dXJl",
270        );
271
272        assert_eq!(sig.credential_id, "Y3JlZGVudGlhbC1pZA==");
273        assert_eq!(sig.authenticator_data, "YXV0aGVudGljYXRvci1kYXRh");
274        assert_eq!(sig.client_data_json, "Y2xpZW50LWRhdGEtanNvbg==");
275        assert_eq!(sig.signature, "c2lnbmF0dXJl");
276    }
277
278    #[test]
279    fn test_signature_new_webauthn() {
280        let webauthn = WebAuthnSignature::new("Y3JlZA==", "YXV0aA==", "Y2xpZW50", "c2ln");
281
282        let signer = SignerInfo::new("Test User");
283        let sig = Signature::new_webauthn("sig-webauthn-1", signer, webauthn);
284
285        assert!(sig.is_webauthn());
286        assert_eq!(sig.algorithm, super::super::SignatureAlgorithm::ES256);
287        assert!(sig.value.is_empty());
288        assert!(sig.webauthn_data().is_some());
289    }
290
291    #[test]
292    fn test_webauthn_signature_serialization() {
293        let webauthn =
294            WebAuthnSignature::new("credential-id", "auth-data", "client-data", "signature");
295
296        let json = serde_json::to_string(&webauthn).unwrap();
297        assert!(json.contains("\"credentialId\":\"credential-id\""));
298        assert!(json.contains("\"authenticatorData\":\"auth-data\""));
299        assert!(json.contains("\"clientDataJson\":\"client-data\""));
300        assert!(json.contains("\"signature\":\"signature\""));
301
302        let parsed: WebAuthnSignature = serde_json::from_str(&json).unwrap();
303        assert_eq!(parsed.credential_id, "credential-id");
304    }
305
306    #[test]
307    fn test_verifier_rejects_non_webauthn() {
308        // Generate a test key pair
309        use p256::elliptic_curve::Generate;
310        let signing_key = p256::ecdsa::SigningKey::generate();
311        let verifying_key = signing_key.verifying_key();
312        let public_key = verifying_key.to_sec1_bytes();
313
314        let verifier = WebAuthnVerifier::new("https://example.com", &public_key).unwrap();
315
316        let doc_id = Hasher::hash(HashAlgorithm::Sha256, b"test");
317        let signer = SignerInfo::new("Test");
318        let sig = Signature::new(
319            "sig-1",
320            super::super::SignatureAlgorithm::ES256,
321            signer,
322            "base64sig",
323        );
324
325        let result = verifier.verify(&doc_id, &sig).unwrap();
326        assert!(!result.is_valid());
327        assert!(result.error.as_ref().unwrap().contains("Not a WebAuthn"));
328    }
329}