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::signature_error;
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| 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 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 #[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 signature_id: &str,
126 document_id: &DocumentId,
127 webauthn: &WebAuthnSignature,
128 ) -> Result<SignatureVerification> {
129 let engine = base64::engine::general_purpose::STANDARD;
130
131 let credential_id = engine
133 .decode(&webauthn.credential_id)
134 .map_err(|e| signature_error(format!("Invalid credential ID base64: {e}")))?;
135
136 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 let client_data_bytes = engine
148 .decode(&webauthn.client_data_json)
149 .map_err(|e| signature_error(format!("Invalid clientDataJSON base64: {e}")))?;
150
151 let client_data: ClientData = serde_json::from_slice(&client_data_bytes)
153 .map_err(|e| signature_error(format!("Invalid clientDataJSON: {e}")))?;
154
155 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 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 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 let authenticator_data = engine
192 .decode(&webauthn.authenticator_data)
193 .map_err(|e| signature_error(format!("Invalid authenticatorData base64: {e}")))?;
194
195 if authenticator_data.len() < 37 {
197 return Ok(SignatureVerification::invalid(
198 signature_id,
199 "Authenticator data too short",
200 ));
201 }
202
203 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 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 let signature_bytes = engine
229 .decode(&webauthn.signature)
230 .map_err(|e| signature_error(format!("Invalid signature base64: {e}")))?;
231
232 let signature = p256::ecdsa::DerSignature::from_bytes(&signature_bytes)
234 .map_err(|e| signature_error(format!("Invalid ECDSA signature: {e}")))?;
235
236 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 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 let Some(webauthn) = &signature.webauthn else {
260 return Ok(SignatureVerification::invalid(
261 &signature.id,
262 "Not a WebAuthn signature",
263 ));
264 };
265
266 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 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}