1use base64::Engine;
2use rustauth_core::error::RustAuthError;
3use serde::{Deserialize, Serialize};
4use serde_json::{json, Value};
5use std::collections::BTreeMap;
6use std::str::FromStr;
7use std::time::Duration;
8use url::Url;
9use uuid::Uuid;
10
11use webauthn_rs::prelude::{
12 AttestationFormat, AttestationMetadata, COSEAlgorithm, COSEKey, COSEKeyType,
13 CreationChallengeResponse, Credential, CredentialID, ECDSACurve, EDDSACurve, ParsedAttestation,
14 Passkey, PublicKeyCredential, RegisterPublicKeyCredential, RequestChallengeResponse,
15};
16use webauthn_rs_core::proto::{
17 AttestationConveyancePreference, AuthenticationState, AuthenticatorTransport,
18 RegisteredExtensions, RegistrationState, RequestAuthenticationExtensions,
19 RequestRegistrationExtensions, UserVerificationPolicy,
20};
21use webauthn_rs_core::WebauthnCore;
22
23use crate::options::{
24 PasskeyRegistrationUser, RegistrationWebAuthnOptions, UserVerificationRequirement,
25};
26
27#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct WebAuthnConfig {
29 pub rp_id: String,
30 pub rp_name: String,
31 pub origins: Vec<String>,
32}
33
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct PasskeyRegistrationStart {
36 pub options: Value,
37 pub state: Value,
38}
39
40#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
41pub struct PasskeyAuthenticationStart {
42 pub options: Value,
43 pub state: Value,
44}
45
46#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
47pub struct VerifiedPasskeyCredential {
48 pub credential_id: String,
49 pub public_key: String,
50 pub counter: u32,
51 pub device_type: String,
52 pub backed_up: bool,
53 pub transports: Option<String>,
54 pub aaguid: Option<String>,
55 pub credential: Value,
56}
57
58#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
59pub struct VerifiedAuthentication {
60 pub credential: Option<Value>,
61 pub new_counter: u32,
62}
63
64pub trait PasskeyWebAuthnBackend: Send + Sync {
65 fn start_registration(
66 &self,
67 config: WebAuthnConfig,
68 user: &PasskeyRegistrationUser,
69 exclude_credentials: Vec<Value>,
70 options: RegistrationWebAuthnOptions,
71 ) -> Result<PasskeyRegistrationStart, RustAuthError>;
72
73 fn finish_registration(
74 &self,
75 config: WebAuthnConfig,
76 response: Value,
77 state: Value,
78 ) -> Result<VerifiedPasskeyCredential, RustAuthError> {
79 let _ = (config, response, state);
80 Err(RustAuthError::Api(
81 "passkey registration verification is not implemented".to_owned(),
82 ))
83 }
84
85 fn start_authentication(
86 &self,
87 config: WebAuthnConfig,
88 credentials: Vec<Value>,
89 extensions: Option<Value>,
90 ) -> Result<PasskeyAuthenticationStart, RustAuthError>;
91
92 fn finish_authentication(
93 &self,
94 config: WebAuthnConfig,
95 response: Value,
96 state: Value,
97 credential: Option<Value>,
98 ) -> Result<VerifiedAuthentication, RustAuthError> {
99 let _ = (config, response, state, credential);
100 Err(RustAuthError::Api(
101 "passkey authentication verification is not implemented".to_owned(),
102 ))
103 }
104}
105
106#[derive(Debug, Clone, Copy)]
107pub struct RealPasskeyWebAuthnBackend;
108
109impl PasskeyWebAuthnBackend for RealPasskeyWebAuthnBackend {
110 fn start_registration(
111 &self,
112 config: WebAuthnConfig,
113 user: &PasskeyRegistrationUser,
114 exclude_credentials: Vec<Value>,
115 request_options: RegistrationWebAuthnOptions,
116 ) -> Result<PasskeyRegistrationStart, RustAuthError> {
117 let core = core(&config)?;
118 let exclude = exclude_credentials
119 .into_iter()
120 .map(parse_exclude_credential_id)
121 .collect::<Result<Vec<_>, _>>()?;
122 let user_id = Uuid::new_v4();
123 let display_name = user.display_name.as_deref().unwrap_or(&user.name);
124 let policy =
125 user_verification_policy(request_options.authenticator_selection.user_verification);
126 let builder = core
127 .new_challenge_register_builder(user_id.as_bytes(), &user.name, display_name)
128 .map_err(|error| RustAuthError::Api(error.to_string()))?
129 .attestation(AttestationConveyancePreference::None)
130 .credential_algorithms(COSEAlgorithm::secure_algs())
131 .require_resident_key(false)
132 .authenticator_attachment(None)
133 .user_verification_policy(policy)
134 .reject_synchronised_authenticators(false)
135 .exclude_credentials(Some(exclude))
136 .hints(None)
137 .extensions(Some(RequestRegistrationExtensions::default()));
138 let (options, state) = core
139 .generate_challenge_register(builder)
140 .map_err(|error| RustAuthError::Api(error.to_string()))?;
141 let mut options = option_value(options)?;
142 apply_registration_request_options(&mut options, &request_options);
143 Ok(PasskeyRegistrationStart {
144 options,
145 state: serde_json::to_value(state).map_err(json_error)?,
146 })
147 }
148
149 fn finish_registration(
150 &self,
151 config: WebAuthnConfig,
152 response: Value,
153 state: Value,
154 ) -> Result<VerifiedPasskeyCredential, RustAuthError> {
155 let core = core(&config)?;
156 let response = serde_json::from_value::<RegisterPublicKeyCredential>(response)
157 .map_err(|error| RustAuthError::Api(error.to_string()))?;
158 let state = serde_json::from_value::<RegistrationState>(state).map_err(json_error)?;
159 let credential = core
160 .register_credential(&response, &state, None)
161 .map_err(|error| RustAuthError::Api(error.to_string()))?;
162 credential_output(Passkey::from(credential))
163 }
164
165 fn start_authentication(
166 &self,
167 config: WebAuthnConfig,
168 credentials: Vec<Value>,
169 extensions: Option<Value>,
170 ) -> Result<PasskeyAuthenticationStart, RustAuthError> {
171 let core = core(&config)?;
172 if credentials.is_empty() {
173 let builder = core
174 .new_challenge_authenticate_builder(
175 Vec::new(),
176 Some(UserVerificationPolicy::Preferred),
177 )
178 .map_err(|error| RustAuthError::Api(error.to_string()))?
179 .extensions(Some(RequestAuthenticationExtensions {
180 appid: None,
181 uvm: Some(true),
182 hmac_get_secret: None,
183 }))
184 .allow_backup_eligible_upgrade(false);
185 let (options, state) = core
186 .generate_challenge_authenticate(builder)
187 .map_err(|error| RustAuthError::Api(error.to_string()))?;
188 let mut options = auth_option_value(options)?;
189 apply_authentication_request_options(&mut options, extensions);
190 return Ok(PasskeyAuthenticationStart {
191 options,
192 state: serde_json::to_value(StoredAuthenticationState::Discoverable(state))
193 .map_err(json_error)?,
194 });
195 }
196 let creds = credentials
197 .into_iter()
198 .map(|value| credential_value_to_passkey(value).map(Credential::from))
199 .collect::<Result<Vec<_>, _>>()?;
200 let builder = core
201 .new_challenge_authenticate_builder(creds, Some(UserVerificationPolicy::Preferred))
202 .map_err(|error| RustAuthError::Api(error.to_string()))?
203 .allow_backup_eligible_upgrade(true);
204 let (options, state) = core
205 .generate_challenge_authenticate(builder)
206 .map_err(|error| RustAuthError::Api(error.to_string()))?;
207 let mut options = auth_option_value(options)?;
208 apply_authentication_request_options(&mut options, extensions);
209 Ok(PasskeyAuthenticationStart {
210 options,
211 state: serde_json::to_value(StoredAuthenticationState::Passkey(state))
212 .map_err(json_error)?,
213 })
214 }
215
216 fn finish_authentication(
217 &self,
218 config: WebAuthnConfig,
219 response: Value,
220 state: Value,
221 credential: Option<Value>,
222 ) -> Result<VerifiedAuthentication, RustAuthError> {
223 let core = core(&config)?;
224 let response = serde_json::from_value::<PublicKeyCredential>(response)
225 .map_err(|error| RustAuthError::Api(error.to_string()))?;
226 let state =
227 serde_json::from_value::<StoredAuthenticationState>(state).map_err(json_error)?;
228 let credential = credential.map(credential_value_to_passkey).transpose()?;
229 let result = match state {
230 StoredAuthenticationState::Passkey(state) => core
231 .authenticate_credential(&response, &state)
232 .map_err(|error| RustAuthError::Api(error.to_string()))?,
233 StoredAuthenticationState::Discoverable(mut state) => {
234 let Some(passkey) = credential.as_ref() else {
235 return Err(RustAuthError::Api(
236 "passkey credential is required".to_owned(),
237 ));
238 };
239 state.set_allowed_credentials(vec![Credential::from(passkey.clone())]);
240 core.authenticate_credential(&response, &state)
241 .map_err(|error| RustAuthError::Api(error.to_string()))?
242 }
243 };
244 let updated_credential = credential.and_then(|mut passkey| {
245 passkey
246 .update_credential(&result)
247 .and_then(|changed| changed.then_some(passkey))
248 });
249 Ok(VerifiedAuthentication {
250 credential: updated_credential
251 .map(|passkey| serde_json::to_value(passkey).map_err(json_error))
252 .transpose()?,
253 new_counter: result.counter(),
254 })
255 }
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
259enum StoredAuthenticationState {
260 Passkey(AuthenticationState),
261 Discoverable(AuthenticationState),
262}
263
264fn core(config: &WebAuthnConfig) -> Result<WebauthnCore, RustAuthError> {
272 if config.origins.is_empty() {
273 return Err(RustAuthError::InvalidConfig(
274 "passkey origin is required".to_owned(),
275 ));
276 }
277 let mut origins = Vec::with_capacity(config.origins.len());
278 for origin in &config.origins {
279 let url = Url::parse(origin).map_err(|error| RustAuthError::Api(error.to_string()))?;
280 let valid = url.domain().is_some_and(|domain| {
283 domain == config.rp_id || domain.ends_with(&format!(".{}", config.rp_id))
284 });
285 if !valid {
286 return Err(RustAuthError::Api(format!(
287 "passkey rp_id `{}` is not an effective domain of origin `{origin}`",
288 config.rp_id
289 )));
290 }
291 origins.push(url);
292 }
293 Ok(WebauthnCore::new_unsafe_experts_only(
294 &config.rp_name,
295 &config.rp_id,
296 origins.clone(),
297 Duration::from_secs(300),
298 Some(false),
299 Some(origins_allow_any_port(&origins)),
300 ))
301}
302
303fn user_verification_policy(value: UserVerificationRequirement) -> UserVerificationPolicy {
304 match value {
305 UserVerificationRequirement::Discouraged => UserVerificationPolicy::Discouraged_DO_NOT_USE,
306 UserVerificationRequirement::Preferred => UserVerificationPolicy::Preferred,
307 UserVerificationRequirement::Required => UserVerificationPolicy::Required,
308 }
309}
310
311fn origins_allow_any_port(origins: &[Url]) -> bool {
320 !origins.is_empty() && origins.iter().all(is_loopback_origin)
321}
322
323fn is_loopback_origin(origin: &Url) -> bool {
324 match origin.host() {
325 Some(url::Host::Domain(host)) => host == "localhost" || host.ends_with(".localhost"),
326 Some(url::Host::Ipv4(address)) => address.is_loopback(),
327 Some(url::Host::Ipv6(address)) => address.is_loopback(),
328 None => false,
329 }
330}
331
332fn option_value(options: CreationChallengeResponse) -> Result<Value, RustAuthError> {
333 serde_json::to_value(options)
334 .map(|mut value| value.pointer_mut("/publicKey").cloned().unwrap_or(value))
335 .map_err(json_error)
336}
337
338fn auth_option_value(options: RequestChallengeResponse) -> Result<Value, RustAuthError> {
339 serde_json::to_value(options)
340 .map(|mut value| value.pointer_mut("/publicKey").cloned().unwrap_or(value))
341 .map_err(json_error)
342}
343
344fn apply_registration_request_options(
345 options: &mut Value,
346 request_options: &RegistrationWebAuthnOptions,
347) {
348 options["authenticatorSelection"] = request_options.authenticator_selection.to_json();
349 if let Some(extensions) = &request_options.extensions {
350 options["extensions"] = extensions.clone();
351 }
352}
353
354fn apply_authentication_request_options(options: &mut Value, extensions: Option<Value>) {
355 if let Some(extensions) = extensions {
356 options["extensions"] = extensions;
357 }
358}
359
360fn credential_value_to_passkey(value: Value) -> Result<Passkey, RustAuthError> {
361 serde_json::from_value::<Passkey>(value).map_err(json_error)
362}
363
364fn parse_exclude_credential_id(value: Value) -> Result<CredentialID, RustAuthError> {
365 if let Ok(credential) = serde_json::from_value::<Credential>(value.clone()) {
366 return Ok(credential.cred_id);
367 }
368 let id = value
369 .as_str()
370 .map(str::to_owned)
371 .or_else(|| {
372 value
373 .get("id")
374 .and_then(serde_json::Value::as_str)
375 .map(str::to_owned)
376 })
377 .ok_or_else(|| RustAuthError::Api("invalid passkey exclude credential entry".to_owned()))?;
378 serde_json::from_value(json!(id)).map_err(json_error)
379}
380
381pub(crate) fn legacy_passkey_credential_value(
387 credential_id: &str,
388 public_key: &str,
389 counter: i64,
390 device_type: &str,
391 backed_up: bool,
392 transports: Option<&str>,
393) -> Result<Value, RustAuthError> {
394 let cose_bytes = decode_stored_public_key(public_key)?;
395 let cbor = serde_cbor_2::from_slice::<serde_cbor_2::Value>(&cose_bytes)
396 .map_err(|error| RustAuthError::Api(error.to_string()))?;
397 let cose_key =
398 COSEKey::try_from(&cbor).map_err(|error| RustAuthError::Api(error.to_string()))?;
399 let cred_id: CredentialID = serde_json::from_value(json!(credential_id)).map_err(json_error)?;
400 let transports = transports.map(parse_stored_transports).transpose()?;
401 let counter = u32::try_from(counter)
402 .map_err(|_| RustAuthError::Api("passkey counter exceeds u32 range".to_owned()))?;
403 let credential = Credential {
404 cred_id,
405 cred: cose_key,
406 counter,
407 transports,
408 user_verified: false,
409 backup_eligible: device_type == "multiDevice",
410 backup_state: backed_up,
411 registration_policy: UserVerificationPolicy::Preferred,
412 extensions: RegisteredExtensions::none(),
413 attestation: ParsedAttestation::default(),
414 attestation_format: AttestationFormat::None,
415 };
416 serde_json::to_value(Passkey::from(credential)).map_err(json_error)
417}
418
419fn decode_stored_public_key(public_key: &str) -> Result<Vec<u8>, RustAuthError> {
420 use base64::Engine;
421 if let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(public_key) {
422 return Ok(bytes);
423 }
424 if let Ok(bytes) = base64::engine::general_purpose::URL_SAFE.decode(public_key) {
425 return Ok(bytes);
426 }
427 base64::engine::general_purpose::URL_SAFE_NO_PAD
428 .decode(public_key)
429 .map_err(|error| RustAuthError::Api(error.to_string()))
430}
431
432fn parse_stored_transports(value: &str) -> Result<Vec<AuthenticatorTransport>, RustAuthError> {
433 value
434 .split(',')
435 .map(str::trim)
436 .filter(|part| !part.is_empty())
437 .map(|part| {
438 AuthenticatorTransport::from_str(part)
439 .map_err(|_| RustAuthError::Api(format!("unsupported passkey transport `{part}`")))
440 })
441 .collect()
442}
443
444fn credential_output(passkey: Passkey) -> Result<VerifiedPasskeyCredential, RustAuthError> {
445 let credential = Credential::from(passkey.clone());
446 let aaguid = aaguid_from_attestation_metadata(&credential.attestation.metadata);
447 let credential_id = serde_json::to_value(&credential.cred_id)
448 .and_then(serde_json::from_value::<String>)
449 .unwrap_or_else(|_| format!("{:?}", credential.cred_id));
450 let public_key =
451 base64::engine::general_purpose::STANDARD.encode(cose_public_key_bytes(&credential.cred)?);
452 let transports = credential.transports.as_ref().map(|values| {
453 values
454 .iter()
455 .map(|value| {
456 serde_json::to_value(value)
457 .ok()
458 .and_then(|value| serde_json::from_value::<String>(value).ok())
459 .unwrap_or_else(|| format!("{value:?}").to_ascii_lowercase())
460 })
461 .collect::<Vec<_>>()
462 .join(",")
463 });
464 Ok(VerifiedPasskeyCredential {
465 credential_id,
466 public_key,
467 counter: credential.counter,
468 device_type: if credential.backup_eligible {
469 "multiDevice".to_owned()
470 } else {
471 "singleDevice".to_owned()
472 },
473 backed_up: credential.backup_state,
474 transports,
475 aaguid,
476 credential: serde_json::to_value(passkey).map_err(json_error)?,
477 })
478}
479
480fn aaguid_from_attestation_metadata(metadata: &AttestationMetadata) -> Option<String> {
481 match metadata {
482 AttestationMetadata::Packed { aaguid } | AttestationMetadata::Tpm { aaguid, .. } => {
483 Some(aaguid.to_string())
484 }
485 _ => None,
486 }
487}
488
489fn cose_public_key_bytes(key: &COSEKey) -> Result<Vec<u8>, RustAuthError> {
490 let mut values = BTreeMap::new();
491 values.insert(
492 serde_cbor_2::Value::Integer(1),
493 serde_cbor_2::Value::Integer(cose_key_type_id(&key.key)),
494 );
495 values.insert(
496 serde_cbor_2::Value::Integer(3),
497 serde_cbor_2::Value::Integer(cose_algorithm_id(key.type_)?),
498 );
499 match &key.key {
500 COSEKeyType::EC_EC2(key) => {
501 values.insert(
502 serde_cbor_2::Value::Integer(-1),
503 serde_cbor_2::Value::Integer(ecdsa_curve_id(&key.curve)),
504 );
505 values.insert(
506 serde_cbor_2::Value::Integer(-2),
507 serde_cbor_2::Value::Bytes(key.x.as_ref().to_vec()),
508 );
509 values.insert(
510 serde_cbor_2::Value::Integer(-3),
511 serde_cbor_2::Value::Bytes(key.y.as_ref().to_vec()),
512 );
513 }
514 COSEKeyType::RSA(key) => {
515 values.insert(
516 serde_cbor_2::Value::Integer(-1),
517 serde_cbor_2::Value::Bytes(key.n.as_ref().to_vec()),
518 );
519 values.insert(
520 serde_cbor_2::Value::Integer(-2),
521 serde_cbor_2::Value::Bytes(key.e.to_vec()),
522 );
523 }
524 COSEKeyType::EC_OKP(key) => {
525 values.insert(
526 serde_cbor_2::Value::Integer(-1),
527 serde_cbor_2::Value::Integer(eddsa_curve_id(&key.curve)),
528 );
529 values.insert(
530 serde_cbor_2::Value::Integer(-2),
531 serde_cbor_2::Value::Bytes(key.x.as_ref().to_vec()),
532 );
533 }
534 }
535 serde_cbor_2::to_vec(&serde_cbor_2::Value::Map(values))
536 .map_err(|error| RustAuthError::Api(error.to_string()))
537}
538
539fn cose_key_type_id(key: &COSEKeyType) -> i128 {
540 match key {
541 COSEKeyType::EC_OKP(_) => 1,
542 COSEKeyType::EC_EC2(_) => 2,
543 COSEKeyType::RSA(_) => 3,
544 }
545}
546
547fn cose_algorithm_id(algorithm: COSEAlgorithm) -> Result<i128, RustAuthError> {
548 match algorithm {
549 COSEAlgorithm::ES256 => Ok(-7),
550 COSEAlgorithm::ES384 => Ok(-35),
551 COSEAlgorithm::ES512 => Ok(-36),
552 COSEAlgorithm::RS256 => Ok(-257),
553 COSEAlgorithm::RS384 => Ok(-258),
554 COSEAlgorithm::RS512 => Ok(-259),
555 COSEAlgorithm::PS256 => Ok(-37),
556 COSEAlgorithm::PS384 => Ok(-38),
557 COSEAlgorithm::PS512 => Ok(-39),
558 COSEAlgorithm::EDDSA => Ok(-8),
559 COSEAlgorithm::INSECURE_RS1 => Ok(-65535),
560 COSEAlgorithm::PinUvProtocol => Err(RustAuthError::Api(
561 "passkey public key uses an unsupported COSE algorithm".to_owned(),
562 )),
563 }
564}
565
566fn ecdsa_curve_id(curve: &ECDSACurve) -> i128 {
567 match curve {
568 ECDSACurve::SECP256R1 => 1,
569 ECDSACurve::SECP384R1 => 2,
570 ECDSACurve::SECP521R1 => 3,
571 }
572}
573
574fn eddsa_curve_id(curve: &EDDSACurve) -> i128 {
575 match curve {
576 EDDSACurve::ED25519 => 6,
577 EDDSACurve::ED448 => 7,
578 }
579}
580
581fn json_error(error: serde_json::Error) -> RustAuthError {
582 RustAuthError::Api(error.to_string())
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588 use serde_cbor_2::Value as CborValue;
589 use webauthn_rs::prelude::{AttestationMetadata, Credential};
590
591 fn parse_origins(origins: &[&str]) -> Result<Vec<Url>, url::ParseError> {
592 origins.iter().map(|origin| Url::parse(origin)).collect()
593 }
594
595 #[test]
596 fn production_origins_keep_exact_port_matching() -> Result<(), url::ParseError> {
597 assert!(!origins_allow_any_port(&parse_origins(&[
600 "https://example.com"
601 ])?));
602 assert!(!origins_allow_any_port(&parse_origins(&[
603 "https://auth.example.com:443"
604 ])?));
605 Ok(())
606 }
607
608 #[test]
609 fn loopback_origins_allow_any_port_for_local_dev() -> Result<(), url::ParseError> {
610 for origin in [
613 "http://localhost",
614 "http://localhost:3000",
615 "http://app.localhost:9000",
616 "http://127.0.0.1:5173",
617 "http://[::1]:8080",
618 ] {
619 assert!(
620 origins_allow_any_port(&parse_origins(&[origin])?),
621 "{origin} should allow any port"
622 );
623 }
624 Ok(())
625 }
626
627 #[test]
628 fn mixed_origins_preserve_exact_port_checks() -> Result<(), url::ParseError> {
629 assert!(!origins_allow_any_port(&parse_origins(&[
632 "http://localhost:3000",
633 "https://example.com",
634 ])?));
635 assert!(origins_allow_any_port(&parse_origins(&[
636 "http://localhost:3000",
637 "http://127.0.0.1:5173",
638 ])?));
639 Ok(())
640 }
641
642 #[test]
643 fn webauthn_builds_for_production_and_loopback_configs() -> Result<(), RustAuthError> {
644 let production = WebAuthnConfig {
645 rp_id: "example.com".to_owned(),
646 rp_name: "Example".to_owned(),
647 origins: vec!["https://auth.example.com".to_owned()],
648 };
649 let loopback = WebAuthnConfig {
650 rp_id: "localhost".to_owned(),
651 rp_name: "Example".to_owned(),
652 origins: vec!["http://localhost:3000".to_owned()],
653 };
654 core(&production)?;
655 core(&loopback)?;
656 Ok(())
657 }
658
659 #[test]
660 fn aaguid_from_attestation_metadata_extracts_packed_and_tpm_values() {
661 let packed = Uuid::from_u128(1);
662 let tpm = Uuid::from_u128(2);
663
664 assert_eq!(
665 aaguid_from_attestation_metadata(&AttestationMetadata::Packed { aaguid: packed }),
666 Some(packed.to_string())
667 );
668 assert_eq!(
669 aaguid_from_attestation_metadata(&AttestationMetadata::Tpm {
670 aaguid: tpm,
671 firmware_version: 1,
672 }),
673 Some(tpm.to_string())
674 );
675 assert_eq!(
676 aaguid_from_attestation_metadata(&AttestationMetadata::None),
677 None
678 );
679 }
680
681 #[test]
682 fn credential_output_public_key_is_cose_cbor_base64() -> Result<(), Box<dyn std::error::Error>>
683 {
684 let credential = serde_json::from_value::<Credential>(serde_json::json!({
685 "cred_id": "AQID",
686 "cred": {
687 "type_": "ES256",
688 "key": {
689 "EC_EC2": {
690 "curve": "SECP256R1",
691 "x": [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1],
692 "y": [2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2]
693 }
694 }
695 },
696 "counter": 7,
697 "transports": null,
698 "user_verified": false,
699 "backup_eligible": false,
700 "backup_state": false,
701 "registration_policy": "preferred",
702 "extensions": {
703 "cred_protect": "NotRequested",
704 "hmac_create_secret": "NotRequested"
705 },
706 "attestation": {
707 "data": "None",
708 "metadata": "None"
709 },
710 "attestation_format": "none"
711 }))?;
712 let output = credential_output(credential.into())?;
713 let public_key_bytes =
714 base64::engine::general_purpose::STANDARD.decode(output.public_key)?;
715 let public_key = serde_cbor_2::from_slice::<CborValue>(&public_key_bytes)?;
716 let CborValue::Map(values) = public_key else {
717 return Err("COSE public key must be encoded as a CBOR map".into());
718 };
719
720 assert_eq!(
721 values.get(&CborValue::Integer(1)),
722 Some(&CborValue::Integer(2))
723 );
724 assert_eq!(
725 values.get(&CborValue::Integer(3)),
726 Some(&CborValue::Integer(-7))
727 );
728 assert_eq!(
729 values.get(&CborValue::Integer(-1)),
730 Some(&CborValue::Integer(1))
731 );
732 assert_eq!(
733 values.get(&CborValue::Integer(-2)),
734 Some(&CborValue::Bytes(vec![1; 32]))
735 );
736 assert_eq!(
737 values.get(&CborValue::Integer(-3)),
738 Some(&CborValue::Bytes(vec![2; 32]))
739 );
740 Ok(())
741 }
742
743 fn sample_test_credential() -> Result<Credential, Box<dyn std::error::Error>> {
744 Ok(serde_json::from_value(serde_json::json!({
745 "cred_id": "AQID",
746 "cred": {
747 "type_": "ES256",
748 "key": { "EC_EC2": {
749 "curve": "SECP256R1",
750 "x": [
751 101, 237, 165, 161, 37, 119, 194, 186, 232, 41, 67, 127, 227, 56, 112, 26,
752 16, 170, 163, 117, 225, 187, 91, 93, 225, 8, 222, 67, 156, 8, 85, 29
753 ],
754 "y": [
755 30, 82, 237, 117, 112, 17, 99, 247, 249, 228, 13, 223, 159, 52, 27, 61,
756 201, 186, 134, 10, 247, 224, 202, 124, 167, 233, 238, 205, 0, 132, 209, 156
757 ]
758 } }
759 },
760 "counter": 0,
761 "transports": null,
762 "user_verified": false,
763 "backup_eligible": false,
764 "backup_state": false,
765 "registration_policy": "preferred",
766 "extensions": { "cred_protect": "NotRequested", "hmac_create_secret": "NotRequested" },
767 "attestation": { "data": "None", "metadata": "None" },
768 "attestation_format": "none"
769 }))?)
770 }
771
772 #[test]
773 fn legacy_passkey_credential_value_reconstructs_from_stored_public_key(
774 ) -> Result<(), Box<dyn std::error::Error>> {
775 let output = credential_output(sample_test_credential()?.into())?;
776 let reconstructed = legacy_passkey_credential_value(
777 &output.credential_id,
778 &output.public_key,
779 i64::from(output.counter),
780 &output.device_type,
781 output.backed_up,
782 output.transports.as_deref(),
783 )?;
784 credential_value_to_passkey(reconstructed)?;
785 Ok(())
786 }
787
788 #[test]
789 fn legacy_passkey_credential_value_rejects_invalid_public_key() {
790 let result = legacy_passkey_credential_value(
791 "AQID",
792 "not-valid-cose",
793 0,
794 "singleDevice",
795 false,
796 None,
797 );
798 assert!(result.is_err());
799 }
800}