cdx_core/security/
webauthn.rs1use 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#[derive(Debug, Deserialize)]
41#[serde(rename_all = "camelCase")]
42struct ClientData {
43 #[serde(rename = "type")]
45 type_: String,
46
47 challenge: String,
49
50 origin: String,
52
53 #[serde(default)]
55 #[allow(dead_code)]
56 cross_origin: Option<bool>,
57}
58
59pub struct WebAuthnVerifier {
64 expected_origin: String,
66
67 verifying_key: VerifyingKey,
69
70 expected_credential_id: Option<Vec<u8>>,
72}
73
74impl WebAuthnVerifier {
75 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 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 #[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 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 let credential_id = engine
132 .decode(&webauthn.credential_id)
133 .map_err(|e| invalid_manifest(format!("Invalid credential ID base64: {e}")))?;
134
135 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 let client_data_bytes = engine
144 .decode(&webauthn.client_data_json)
145 .map_err(|e| invalid_manifest(format!("Invalid clientDataJSON base64: {e}")))?;
146
147 let client_data: ClientData = serde_json::from_slice(&client_data_bytes)
149 .map_err(|e| invalid_manifest(format!("Invalid clientDataJSON: {e}")))?;
150
151 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 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 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 let authenticator_data = engine
188 .decode(&webauthn.authenticator_data)
189 .map_err(|e| invalid_manifest(format!("Invalid authenticatorData base64: {e}")))?;
190
191 if authenticator_data.len() < 37 {
193 return Ok(SignatureVerification::invalid(
194 "",
195 "Authenticator data too short",
196 ));
197 }
198
199 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 let signature_bytes = engine
210 .decode(&webauthn.signature)
211 .map_err(|e| invalid_manifest(format!("Invalid signature base64: {e}")))?;
212
213 let signature = p256::ecdsa::DerSignature::from_bytes(&signature_bytes)
215 .map_err(|e| invalid_manifest(format!("Invalid ECDSA signature: {e}")))?;
216
217 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 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 let Some(webauthn) = &signature.webauthn else {
241 return Ok(SignatureVerification::invalid(
242 &signature.id,
243 "Not a WebAuthn signature",
244 ));
245 };
246
247 let mut result = self.verify_webauthn(document_id, webauthn)?;
249
250 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 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}