Skip to main content

chisel_auth/
passkey.rs

1//! Passkey (WebAuthn) ceremonies over `webauthn-kit`.
2//!
3//! Wraps the kit's registration/authentication protocol functions behind
4//! the Chisel vocabulary: user ids from the kernel, a closed role model,
5//! and per-user challenge state. The browser-facing step (shipping the
6//! returned options to `navigator.credentials.{create,get}()`) is the
7//! app's job; this module verifies the responses and manages the
8//! credential store.
9//!
10//! The ceremony crypto is the kit's tested code (see
11//! `webauthn_kit::protocol`); tests inject a stub verifier so the
12//! challenge state machine and store interactions here are covered
13//! without fabricating ES256 signatures.
14
15use super::{Error, Result};
16use chisel_kernel::UserId;
17use std::collections::HashMap;
18use std::sync::{Arc, Mutex};
19use std::time::{Duration, Instant};
20use webauthn_kit::attestation::AttestationPolicy;
21use webauthn_kit::config::WebauthnConfig;
22use webauthn_kit::credential::{
23    AllowCredential, AuthenticationOptions, AuthenticationResult, AuthenticatorSelection,
24    ExcludeCredential, PubKeyCredParam, RegistrationOptions, RegistrationResult, RelyingParty,
25    WebauthnUser,
26};
27use webauthn_kit::crypto::generate_challenge_bytes;
28use webauthn_kit::policy::CredentialPolicy;
29use webauthn_kit::protocol::AuthenticationParams;
30
31/// A stored passkey credential.
32#[derive(Debug, Clone)]
33pub struct StoredPasskey {
34    /// The user this credential belongs to.
35    pub user: UserId,
36    /// Base64url credential id (the lookup key).
37    pub credential_id: String,
38    /// COSE public key captured at registration.
39    pub public_key_cose: Vec<u8>,
40    /// The assertion counter as of the last ceremony.
41    pub sign_count: u32,
42    /// A human label for the credential list.
43    pub label: String,
44}
45
46/// Persistence seam for passkey credentials.
47pub trait CredentialStore: Send + Sync {
48    /// Stores or replaces a credential by its id.
49    fn put(&self, credential: StoredPasskey);
50    /// Fetches a credential by id.
51    fn get(&self, credential_id: &str) -> Option<StoredPasskey>;
52    /// Every credential belonging to a user.
53    fn for_user(&self, user: &UserId) -> Vec<StoredPasskey>;
54    /// Removes a credential by id.
55    fn remove(&self, credential_id: &str);
56}
57
58/// The ceremony surface of `webauthn-kit`, as a trait: the crypto is the
59/// kit's tested code in production; tests inject outcomes so the challenge
60/// state machine, store interactions, and error mapping here are covered
61/// without fabricating ES256 signatures.
62pub trait CeremonyVerifier: Send + Sync {
63    /// Verifies a registration ceremony against the stored challenge.
64    fn verify_registration(
65        &self,
66        challenge_bytes: &[u8],
67        client_data_json_b64: &str,
68        attestation_object_b64: &str,
69        rp_id: &str,
70        rp_origins: &[String],
71    ) -> std::result::Result<RegistrationResult, webauthn_kit::WebauthnError>;
72
73    /// Verifies an authentication ceremony for a known credential.
74    fn verify_authentication(
75        &self,
76        params: &AuthenticationParams,
77    ) -> std::result::Result<AuthenticationResult, webauthn_kit::WebauthnError>;
78}
79
80/// The production verifier: the kit's protocol functions with the service's
81/// config, credential policy, and strict attestation posture.
82#[derive(Debug, Clone)]
83pub struct KitVerifier {
84    /// Relying party id used in ceremony validation.
85    pub rp_id: String,
86    /// Origins accepted for ceremony validation.
87    pub rp_origins: Vec<String>,
88    /// Registration-time attestation posture (strict).
89    pub attestation: AttestationPolicy,
90    /// Registration-time credential policy (algorithm allowlist, UV, backup).
91    pub policy: CredentialPolicy,
92}
93
94impl CeremonyVerifier for KitVerifier {
95    fn verify_registration(
96        &self,
97        challenge_bytes: &[u8],
98        client_data_json_b64: &str,
99        attestation_object_b64: &str,
100        rp_id: &str,
101        rp_origins: &[String],
102    ) -> std::result::Result<RegistrationResult, webauthn_kit::WebauthnError> {
103        webauthn_kit::protocol::verify_registration(
104            challenge_bytes,
105            client_data_json_b64,
106            attestation_object_b64,
107            "",
108            rp_id,
109            rp_origins,
110            &self.attestation,
111            &self.policy,
112        )
113    }
114
115    fn verify_authentication(
116        &self,
117        params: &AuthenticationParams,
118    ) -> std::result::Result<AuthenticationResult, webauthn_kit::WebauthnError> {
119        webauthn_kit::protocol::verify_authentication(params)
120    }
121}
122
123/// One pending challenge per user, keyed by the user's id, with expiry.
124struct PendingChallenge {
125    bytes: Vec<u8>,
126    user: UserId,
127    expires: Instant,
128}
129
130/// Per-user challenge state machine (generate, take, expire).
131struct ChallengeMap {
132    map: Mutex<HashMap<String, PendingChallenge>>,
133    ttl: Duration,
134}
135
136impl ChallengeMap {
137    fn new(ttl: Duration) -> Self {
138        Self {
139            map: Mutex::new(HashMap::new()),
140            ttl,
141        }
142    }
143    fn put(&self, user: &UserId, bytes: Vec<u8>) {
144        self.map
145            .lock()
146            .unwrap_or_else(std::sync::PoisonError::into_inner)
147            .insert(
148                user.as_str().to_owned(),
149                PendingChallenge {
150                    bytes,
151                    user: user.clone(),
152                    expires: Instant::now() + self.ttl,
153                },
154            );
155    }
156    fn take(&self, user: &str) -> Option<PendingChallenge> {
157        let mut map = self
158            .map
159            .lock()
160            .unwrap_or_else(std::sync::PoisonError::into_inner);
161        match map.remove(user) {
162            Some(c) if c.expires > Instant::now() => Some(c),
163            other => {
164                map.retain(|_, c| c.expires > Instant::now());
165                other
166            }
167        }
168    }
169}
170
171/// The passkey service: ceremonies behind the verifier seam, persistence
172/// over the store seam, challenge state per user.
173#[derive(Clone)]
174pub struct PasskeyService {
175    config: WebauthnConfig,
176    verifier: Arc<dyn CeremonyVerifier>,
177    store: Arc<dyn CredentialStore>,
178    challenges: Arc<ChallengeMap>,
179    timeout_ms: u64,
180}
181
182impl std::fmt::Debug for PasskeyService {
183    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
184        f.debug_struct("PasskeyService")
185            .field("rp_id", &self.config.rp_id)
186            .field("rp_origins", &self.config.rp_origins)
187            .finish_non_exhaustive()
188    }
189}
190
191impl PasskeyService {
192    /// Creates a service from the relying-party facts, the credential
193    /// policy, and a credential store. Production uses [`KitVerifier`].
194    #[must_use]
195    pub fn new(
196        rp_id: impl Into<String>,
197        rp_name: impl Into<String>,
198        rp_origins: Vec<String>,
199        store: Arc<dyn CredentialStore>,
200    ) -> Self {
201        let rp_id = rp_id.into();
202        let verifier = KitVerifier {
203            rp_id: rp_id.clone(),
204            rp_origins: rp_origins.clone(),
205            attestation: AttestationPolicy::strict(),
206            policy: CredentialPolicy::default(),
207        };
208        Self::with_verifier(rp_id, rp_name, rp_origins, store, Arc::new(verifier))
209    }
210
211    /// Creates a service with an explicit ceremony verifier. Production
212    /// builds the service with [`Self::new`]; tests and alternative
213    /// backends inject their own [`CeremonyVerifier`].
214    #[must_use]
215    pub fn with_verifier(
216        rp_id: impl Into<String>,
217        rp_name: impl Into<String>,
218        rp_origins: Vec<String>,
219        store: Arc<dyn CredentialStore>,
220        verifier: Arc<dyn CeremonyVerifier>,
221    ) -> Self {
222        Self {
223            config: WebauthnConfig {
224                rp_id: rp_id.into(),
225                rp_name: rp_name.into(),
226                rp_origins,
227                ..WebauthnConfig::default()
228            },
229            verifier,
230            store,
231            challenges: Arc::new(ChallengeMap::new(Duration::from_secs(300))),
232            timeout_ms: 60_000,
233        }
234    }
235
236    /// Builds the `navigator.credentials.create()` options for a user and
237    /// remembers the challenge. Existing passkeys for the user are excluded
238    /// (an authenticator cannot re-register the same credential).
239    #[must_use]
240    pub fn registration_options(&self, user: &UserId, label: &str) -> RegistrationOptions {
241        let bytes = generate_challenge_bytes();
242        let exclude: Vec<ExcludeCredential> = self
243            .store
244            .for_user(user)
245            .into_iter()
246            .map(|c| ExcludeCredential {
247                id: c.credential_id,
248                type_: "public-key".into(),
249                transports: None,
250            })
251            .collect();
252        self.challenges.put(user, bytes.clone());
253        RegistrationOptions {
254            challenge: webauthn_kit::crypto::base64_encode_urlsafe(&bytes),
255            rp: RelyingParty {
256                id: self.config.rp_id.clone(),
257                name: self.config.rp_name.clone(),
258            },
259            user: WebauthnUser {
260                id: user.as_str().to_owned(),
261                name: user.as_str().to_owned(),
262                display_name: label.to_owned(),
263            },
264            pub_key_cred_params: self
265                .config
266                .allowed_algorithms
267                .iter()
268                .map(|&alg| PubKeyCredParam {
269                    alg,
270                    type_: "public-key".into(),
271                })
272                .collect(),
273            timeout: self.timeout_ms,
274            exclude_credentials: exclude,
275            attestation: "none".into(),
276            authenticator_selection: AuthenticatorSelection {
277                resident_key: "required".into(),
278                user_verification: "preferred".into(),
279                require_resident_key: false,
280            },
281        }
282    }
283
284    /// Verifies a registration response and stores the credential.
285    ///
286    /// # Errors
287    ///
288    /// An expired or missing challenge, a challenge issued to a different
289    /// user, or ceremony verification failures.
290    pub fn finish_registration(
291        &self,
292        user: &UserId,
293        client_data_json_b64: &str,
294        attestation_object_b64: &str,
295    ) -> Result<RegistrationResult> {
296        let pending = self
297            .challenges
298            .take(user.as_str())
299            .ok_or_else(|| Error::Token("no in-flight passkey challenge for this user".into()))?;
300        if &pending.user != user {
301            return Err(Error::Token("challenge belongs to another user".into()));
302        }
303        let result = self
304            .verifier
305            .verify_registration(
306                &pending.bytes,
307                client_data_json_b64,
308                attestation_object_b64,
309                &self.config.rp_id,
310                &self.config.rp_origins,
311            )
312            .map_err(|e| Error::Kit(e.to_string()))?;
313        self.store.put(StoredPasskey {
314            user: user.clone(),
315            credential_id: result.credential_id.clone(),
316            public_key_cose: result.public_key_cose.clone(),
317            sign_count: 0,
318            label: String::new(),
319        });
320        Ok(result)
321    }
322
323    /// Builds the `navigator.credentials.get()` options for a user, allowing
324    /// exactly that user's stored credentials, and remembers the challenge.
325    #[must_use]
326    pub fn authentication_options(&self, user: &UserId) -> AuthenticationOptions {
327        let bytes = generate_challenge_bytes();
328        let allow: Vec<AllowCredential> = self
329            .store
330            .for_user(user)
331            .into_iter()
332            .map(|c| AllowCredential {
333                id: c.credential_id.clone(),
334                type_: "public-key".into(),
335                transports: None,
336            })
337            .collect();
338        self.challenges.put(user, bytes.clone());
339        AuthenticationOptions {
340            challenge: webauthn_kit::crypto::base64_encode_urlsafe(&bytes),
341            rp_id: self.config.rp_id.clone(),
342            allow_credentials: allow,
343            timeout: self.timeout_ms,
344            user_verification: "preferred".into(),
345        }
346    }
347
348    /// Verifies an authentication response against the user's stored
349    /// credential. On success the caller issues session tokens via the
350    /// [`SessionService`](super::SessionService) for the returned user.
351    ///
352    /// # Errors
353    ///
354    /// Ceremony failures, unknown credentials, a credential belonging to a
355    /// different user, or expired/missing challenges.
356    pub fn finish_authentication(
357        &self,
358        user: &UserId,
359        credential_id: &str,
360        client_data_json_b64: &str,
361        authenticator_data_b64: &str,
362        signature_b64: &str,
363    ) -> Result<()> {
364        let stored = self
365            .store
366            .get(credential_id)
367            .ok_or_else(|| Error::Token("unknown passkey credential".into()))?;
368        if &stored.user != user {
369            return Err(Error::Token("credential belongs to another user".into()));
370        }
371        let pending = self
372            .challenges
373            .take(user.as_str())
374            .ok_or_else(|| Error::Token("no in-flight passkey challenge for this user".into()))?;
375        let params = AuthenticationParams {
376            challenge_bytes: pending.bytes,
377            client_data_json_b64: client_data_json_b64.to_owned(),
378            authenticator_data_b64: authenticator_data_b64.to_owned(),
379            signature_b64: signature_b64.to_owned(),
380            credential_id_b64: credential_id.to_owned(),
381            public_key_cose: stored.public_key_cose.clone(),
382            current_sign_count: stored.sign_count,
383            allowed_credential_ids: vec![credential_id.to_owned()],
384            rp_id: self.config.rp_id.clone(),
385            rp_origins: self.config.rp_origins.clone(),
386            policy: self.verifier_policy(),
387        };
388        let _result = self
389            .verifier
390            .verify_authentication(&params)
391            .map_err(|e| Error::Kit(e.to_string()))?;
392        Ok(())
393    }
394
395    fn verifier_policy(&self) -> CredentialPolicy {
396        CredentialPolicy::default()
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    #![allow(clippy::expect_used, clippy::unwrap_used, clippy::indexing_slicing)]
403    use super::*;
404    use sha2::Digest;
405    use webauthn_kit::attestation::{AttestationFormat, AttestationResult, TrustLevel};
406
407    /// An in-memory credential store.
408    #[derive(Default)]
409    struct MemStore(Mutex<Vec<StoredPasskey>>);
410
411    impl CredentialStore for MemStore {
412        fn put(&self, credential: StoredPasskey) {
413            let mut all = self
414                .0
415                .lock()
416                .unwrap_or_else(std::sync::PoisonError::into_inner);
417            all.retain(|c| c.credential_id != credential.credential_id);
418            all.push(credential);
419        }
420        fn get(&self, credential_id: &str) -> Option<StoredPasskey> {
421            self.0
422                .lock()
423                .unwrap_or_else(std::sync::PoisonError::into_inner)
424                .iter()
425                .find(|c| c.credential_id == credential_id)
426                .cloned()
427        }
428        fn for_user(&self, user: &UserId) -> Vec<StoredPasskey> {
429            self.0
430                .lock()
431                .unwrap_or_else(std::sync::PoisonError::into_inner)
432                .iter()
433                .filter(|c| &c.user == user)
434                .cloned()
435                .collect()
436        }
437        fn remove(&self, credential_id: &str) {
438            self.0
439                .lock()
440                .unwrap_or_else(std::sync::PoisonError::into_inner)
441                .retain(|c| c.credential_id != credential_id);
442        }
443    }
444
445    /// A verifier with programmable outcomes that records the last
446    /// authentication ceremony it was handed.
447    #[derive(Default)]
448    struct Stub {
449        refuse_registration: bool,
450        refuse_authentication: bool,
451        seen: Mutex<Option<AuthenticationParams>>,
452    }
453
454    impl CeremonyVerifier for Stub {
455        fn verify_registration(
456            &self,
457            _: &[u8],
458            _: &str,
459            _: &str,
460            _: &str,
461            _: &[String],
462        ) -> std::result::Result<RegistrationResult, webauthn_kit::WebauthnError> {
463            if self.refuse_registration {
464                return Err(webauthn_kit::WebauthnError::Config(
465                    "stub refused the registration".into(),
466                ));
467            }
468            Ok(RegistrationResult {
469                credential_id: "cred-1".into(),
470                public_key_cose: vec![1, 2, 3],
471                device_name: "test device".into(),
472                attestation_format: "none".into(),
473                attestation: AttestationResult {
474                    format: AttestationFormat::None,
475                    aaguid: None,
476                    trust_level: TrustLevel::None,
477                    warnings: vec![],
478                },
479                user_verified: true,
480                backup_eligible: false,
481                backup_state: false,
482            })
483        }
484
485        fn verify_authentication(
486            &self,
487            params: &AuthenticationParams,
488        ) -> std::result::Result<AuthenticationResult, webauthn_kit::WebauthnError> {
489            *self
490                .seen
491                .lock()
492                .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(params.clone());
493            if self.refuse_authentication {
494                return Err(webauthn_kit::WebauthnError::Config(
495                    "stub refused the authentication".into(),
496                ));
497            }
498            Ok(AuthenticationResult {
499                credential_id: params.credential_id_b64.clone(),
500                new_sign_count: 7,
501                user_verified: true,
502                backup_state: false,
503                backup_eligible: false,
504            })
505        }
506    }
507
508    fn service(store: Arc<MemStore>, stub: Stub) -> PasskeyService {
509        PasskeyService::with_verifier(
510            "chisel.example",
511            "Chisel Docs",
512            vec!["https://chisel.example".into()],
513            store,
514            Arc::new(stub),
515        )
516    }
517
518    fn alice() -> UserId {
519        UserId::try_from("alice").unwrap()
520    }
521
522    fn bob() -> UserId {
523        UserId::try_from("bob").unwrap()
524    }
525
526    fn passkey(user: &UserId, id: &str, cose: &[u8], sign_count: u32) -> StoredPasskey {
527        StoredPasskey {
528            user: user.clone(),
529            credential_id: id.into(),
530            public_key_cose: cose.to_vec(),
531            sign_count,
532            label: String::new(),
533        }
534    }
535
536    #[test]
537    fn registration_options_exclude_existing_keys_and_carry_the_relying_party() {
538        let store = Arc::new(MemStore::default());
539        store.put(passkey(&alice(), "old-key", &[9], 3));
540        let svc = service(store, Stub::default());
541        let options = svc.registration_options(&alice(), "Alice's laptop");
542        assert!(!options.challenge.is_empty());
543        assert_eq!(options.rp.id, "chisel.example");
544        assert_eq!(options.rp.name, "Chisel Docs");
545        assert_eq!(options.user.id, "alice");
546        assert_eq!(options.user.display_name, "Alice's laptop");
547        assert_eq!(options.exclude_credentials.len(), 1);
548        assert_eq!(options.exclude_credentials[0].id, "old-key");
549        assert_eq!(options.authenticator_selection.resident_key, "required");
550    }
551
552    #[test]
553    fn finish_registration_stores_the_verified_credential() {
554        let store = Arc::new(MemStore::default());
555        let svc = service(Arc::clone(&store), Stub::default());
556        let _ = svc.registration_options(&alice(), "Alice's laptop");
557        let result = svc
558            .finish_registration(&alice(), "client-data-b64", "attestation-b64")
559            .expect("a stubbed registration finishes");
560        assert_eq!(result.credential_id, "cred-1");
561        let stored = store.get("cred-1").expect("the credential is persisted");
562        assert_eq!(stored.user, alice());
563        assert_eq!(stored.public_key_cose, vec![1, 2, 3]);
564        assert_eq!(stored.sign_count, 0);
565    }
566
567    #[test]
568    fn finish_registration_needs_an_in_flight_challenge() {
569        let svc = service(Arc::new(MemStore::default()), Stub::default());
570        let err = svc.finish_registration(&alice(), "c", "a").unwrap_err();
571        assert!(
572            err.to_string().contains("no in-flight passkey challenge"),
573            "{err}"
574        );
575    }
576
577    #[test]
578    fn finish_registration_maps_verifier_failures_to_kit_errors() {
579        let svc = service(
580            Arc::new(MemStore::default()),
581            Stub {
582                refuse_registration: true,
583                ..Stub::default()
584            },
585        );
586        let _ = svc.registration_options(&alice(), "Alice's laptop");
587        let err = svc.finish_registration(&alice(), "c", "a").unwrap_err();
588        assert!(
589            err.to_string().contains("stub refused the registration"),
590            "{err}"
591        );
592    }
593
594    #[test]
595    fn authentication_options_allow_only_the_users_own_keys() {
596        let store = Arc::new(MemStore::default());
597        store.put(passkey(&alice(), "a-key", &[1], 0));
598        store.put(passkey(&bob(), "b-key", &[2], 0));
599        let svc = service(store, Stub::default());
600        let options = svc.authentication_options(&alice());
601        assert_eq!(options.allow_credentials.len(), 1);
602        assert_eq!(options.allow_credentials[0].id, "a-key");
603        assert_eq!(options.rp_id, "chisel.example");
604        assert!(!options.challenge.is_empty());
605    }
606
607    #[test]
608    fn finish_authentication_verifies_against_the_stored_key() {
609        let store = Arc::new(MemStore::default());
610        store.put(passkey(&alice(), "a-key", &[7, 7], 41));
611        let stub = Arc::new(Stub::default());
612        let svc = PasskeyService::with_verifier(
613            "chisel.example",
614            "Chisel Docs",
615            vec!["https://chisel.example".into()],
616            store,
617            Arc::clone(&stub) as Arc<dyn CeremonyVerifier>,
618        );
619        let _ = svc.authentication_options(&alice());
620        svc.finish_authentication(&alice(), "a-key", "cdj-b64", "auth-data-b64", "sig-b64")
621            .expect("a stubbed authentication finishes");
622        let seen = stub
623            .seen
624            .lock()
625            .unwrap_or_else(std::sync::PoisonError::into_inner);
626        let ceremony = seen.as_ref().expect("the verifier saw the ceremony");
627        assert_eq!(ceremony.public_key_cose, vec![7, 7]);
628        assert_eq!(ceremony.current_sign_count, 41);
629        assert_eq!(ceremony.rp_id, "chisel.example");
630        assert_eq!(ceremony.allowed_credential_ids, ["a-key"]);
631        assert_eq!(ceremony.credential_id_b64, "a-key");
632    }
633
634    #[test]
635    fn finish_authentication_refuses_unknown_and_foreign_credentials() {
636        let store = Arc::new(MemStore::default());
637        store.put(passkey(&bob(), "b-key", &[2], 0));
638        let svc = service(store, Stub::default());
639        let err = svc
640            .finish_authentication(&alice(), "nope", "c", "a", "s")
641            .unwrap_err();
642        assert!(
643            err.to_string().contains("unknown passkey credential"),
644            "{err}"
645        );
646        let err = svc
647            .finish_authentication(&alice(), "b-key", "c", "a", "s")
648            .unwrap_err();
649        assert!(
650            err.to_string()
651                .contains("credential belongs to another user"),
652            "{err}"
653        );
654    }
655
656    #[test]
657    fn finish_authentication_needs_an_in_flight_challenge() {
658        let store = Arc::new(MemStore::default());
659        store.put(passkey(&alice(), "a-key", &[1], 0));
660        let svc = service(store, Stub::default());
661        let err = svc
662            .finish_authentication(&alice(), "a-key", "c", "a", "s")
663            .unwrap_err();
664        assert!(
665            err.to_string().contains("no in-flight passkey challenge"),
666            "{err}"
667        );
668    }
669
670    #[test]
671    fn finish_authentication_maps_verifier_failures_to_kit_errors() {
672        let store = Arc::new(MemStore::default());
673        store.put(passkey(&alice(), "a-key", &[1], 0));
674        let svc = service(
675            store,
676            Stub {
677                refuse_authentication: true,
678                ..Stub::default()
679            },
680        );
681        let _ = svc.authentication_options(&alice());
682        let err = svc
683            .finish_authentication(&alice(), "a-key", "c", "a", "s")
684            .unwrap_err();
685        assert!(
686            err.to_string().contains("stub refused the authentication"),
687            "{err}"
688        );
689    }
690
691    #[test]
692    fn both_constructors_report_the_relying_party_in_debug() {
693        let store = Arc::new(MemStore::default());
694        let injected = service(Arc::clone(&store), Stub::default());
695        assert!(format!("{injected:?}").contains("chisel.example"));
696        let kit = PasskeyService::new(
697            "kit.example",
698            "Chisel Docs",
699            vec!["https://kit.example".into()],
700            store,
701        );
702        assert!(format!("{kit:?}").contains("kit.example"));
703    }
704
705    // ── real-crypto ceremonies through the KitVerifier ─────────────────────────
706    //
707    // The stub covers the service's challenge state machine and store
708    // interactions; these drive the production verifier over synthetic ES256
709    // ceremonies (the fixture builders mirror webauthn-kit's own vectors
710    // suite), so no crypto is mocked on the kit-facing path.
711
712    const RP_ID: &str = "localhost";
713    const ORIGIN: &str = "http://localhost:8080";
714
715    fn kit_service(store: Arc<MemStore>) -> PasskeyService {
716        PasskeyService::new(RP_ID, "Chisel Docs", vec![ORIGIN.to_string()], store)
717    }
718
719    /// A CTAP2 attestation object with `fmt = none` and the given authData.
720    fn build_attestation_object(auth_data: &[u8]) -> Vec<u8> {
721        let map = vec![
722            (
723                ciborium::Value::Text("fmt".into()),
724                ciborium::Value::Text("none".into()),
725            ),
726            (
727                ciborium::Value::Text("attStmt".into()),
728                ciborium::Value::Map(vec![]),
729            ),
730            (
731                ciborium::Value::Text("authData".into()),
732                ciborium::Value::Bytes(auth_data.to_vec()),
733            ),
734        ];
735        let mut buf = Vec::new();
736        ciborium::ser::into_writer(&ciborium::Value::Map(map), &mut buf).expect("CBOR encode");
737        buf
738    }
739
740    /// An EC2 COSE_Key (P-256, ES256) from raw coordinate bytes.
741    fn build_cose_ec2_key(x: &[u8], y: &[u8]) -> Vec<u8> {
742        let map = vec![
743            (
744                ciborium::Value::Integer(1.into()),
745                ciborium::Value::Integer(2.into()),
746            ),
747            (
748                ciborium::Value::Integer(3.into()),
749                ciborium::Value::Integer((-7).into()),
750            ),
751            (
752                ciborium::Value::Integer((-1).into()),
753                ciborium::Value::Integer(1.into()),
754            ),
755            (
756                ciborium::Value::Integer((-2).into()),
757                ciborium::Value::Bytes(x.to_vec()),
758            ),
759            (
760                ciborium::Value::Integer((-3).into()),
761                ciborium::Value::Bytes(y.to_vec()),
762            ),
763        ];
764        let mut buf = Vec::new();
765        ciborium::ser::into_writer(&ciborium::Value::Map(map), &mut buf).expect("CBOR encode");
766        buf
767    }
768
769    /// Authenticator data: rpId hash, flags, counter, and — with the AT
770    /// flag — attested credential data (AAGUID, id, COSE key).
771    fn build_auth_data(
772        rp_id: &str,
773        flags: u8,
774        sign_count: u32,
775        credential_id: &[u8],
776        cose_key: &[u8],
777    ) -> Vec<u8> {
778        let mut auth_data = sha2::Sha256::digest(rp_id.as_bytes()).to_vec();
779        auth_data.push(flags);
780        auth_data.extend_from_slice(&sign_count.to_be_bytes());
781        if flags & 0x40 != 0 {
782            auth_data.extend_from_slice(&[0u8; 16]); // AAGUID
783            auth_data.extend_from_slice(&(credential_id.len() as u16).to_be_bytes());
784            auth_data.extend_from_slice(credential_id);
785            auth_data.extend_from_slice(cose_key);
786        }
787        auth_data
788    }
789
790    /// A fresh ES256 keypair with its EC2 COSE public key.
791    fn es256_key() -> (ring::signature::EcdsaKeyPair, Vec<u8>) {
792        use ring::signature::{EcdsaKeyPair, KeyPair as _};
793        let rng = ring::rand::SystemRandom::new();
794        let pkcs8 =
795            EcdsaKeyPair::generate_pkcs8(&ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING, &rng)
796                .expect("key generation");
797        let key_pair = EcdsaKeyPair::from_pkcs8(
798            &ring::signature::ECDSA_P256_SHA256_FIXED_SIGNING,
799            pkcs8.as_ref(),
800            &rng,
801        )
802        .expect("key parsing");
803        let pub_bytes = key_pair.public_key().as_ref();
804        let cose = build_cose_ec2_key(&pub_bytes[1..33], &pub_bytes[33..65]);
805        (key_pair, cose)
806    }
807
808    fn b64(bytes: &[u8]) -> String {
809        webauthn_kit::base64_encode_urlsafe(bytes)
810    }
811
812    /// Runs a real registration ceremony against the KitVerifier and returns
813    /// the verified result and the registered credential id.
814    fn register_real(
815        svc: &PasskeyService,
816        user: &UserId,
817        key_pair: &ring::signature::EcdsaKeyPair,
818        cose_key: &[u8],
819        credential_id: &[u8],
820        origin: &str,
821    ) -> crate::Result<webauthn_kit::credential::RegistrationResult> {
822        let options = svc.registration_options(user, "laptop");
823        let client_data = serde_json::json!({
824            "type": "webauthn.create",
825            "challenge": options.challenge,
826            "origin": origin,
827        });
828        let client_data_b64 = b64(&serde_json::to_vec(&client_data).expect("json"));
829        let auth_data = build_auth_data(RP_ID, 0x45, 0, credential_id, cose_key); // UP|UV|AT
830        let _ = key_pair; // registration with fmt=none carries no signature
831        svc.finish_registration(
832            user,
833            &client_data_b64,
834            &b64(&build_attestation_object(&auth_data)),
835        )
836    }
837
838    #[test]
839    fn kit_verifier_roundtrips_a_real_es256_ceremony() {
840        let store = Arc::new(MemStore::default());
841        let svc = kit_service(Arc::clone(&store));
842        let (key_pair, cose_key) = es256_key();
843        let credential_id = vec![0xDE, 0xAD, 0xBE, 0xEF];
844
845        // Registration: the ceremony verifies and the COSE key lands in the store.
846        let result = register_real(&svc, &alice(), &key_pair, &cose_key, &credential_id, ORIGIN)
847            .expect("a fmt=none ES256 registration verifies");
848        assert_eq!(result.attestation_format, "none");
849        assert!(result.user_verified);
850        let stored = store
851            .get(&result.credential_id)
852            .expect("credential persisted");
853        assert_eq!(stored.public_key_cose, cose_key);
854        assert_eq!(stored.user, alice());
855
856        // Authentication: sign authData || clientDataHash with the same key.
857        let options = svc.authentication_options(&alice());
858        let client_data = serde_json::json!({
859            "type": "webauthn.get",
860            "challenge": options.challenge,
861            "origin": ORIGIN,
862        });
863        let client_data_bytes = serde_json::to_vec(&client_data).expect("json");
864        let assertion_auth_data = build_auth_data(RP_ID, 0x01, 1, &[], &[]); // UP, counter 1
865        let mut signed = assertion_auth_data.clone();
866        signed.extend_from_slice(&sha2::Sha256::digest(&client_data_bytes));
867        let rng = ring::rand::SystemRandom::new();
868        let signature = key_pair.sign(&rng, &signed).expect("ES256 signing");
869
870        svc.finish_authentication(
871            &alice(),
872            &result.credential_id,
873            &b64(&client_data_bytes),
874            &b64(&assertion_auth_data),
875            &b64(signature.as_ref()),
876        )
877        .expect("a correctly signed assertion verifies");
878    }
879
880    #[test]
881    fn kit_verifier_rejects_a_registration_from_a_foreign_origin() {
882        let svc = kit_service(Arc::new(MemStore::default()));
883        let (key_pair, cose_key) = es256_key();
884        let err = register_real(
885            &svc,
886            &alice(),
887            &key_pair,
888            &cose_key,
889            &[1, 2, 3],
890            "https://evil.example",
891        )
892        .expect_err("a foreign origin must not verify");
893        assert!(err.to_string().contains("origin"), "{err}");
894    }
895
896    #[test]
897    fn kit_verifier_rejects_an_assertion_signed_by_another_key() {
898        let store = Arc::new(MemStore::default());
899        let svc = kit_service(Arc::clone(&store));
900        let (key_pair, cose_key) = es256_key();
901        let result = register_real(&svc, &alice(), &key_pair, &cose_key, &[4, 5, 6], ORIGIN)
902            .expect("registration verifies");
903
904        // A fresh keypair signs the assertion; the stored key must reject it.
905        let (other_key, _) = es256_key();
906        let options = svc.authentication_options(&alice());
907        let client_data = serde_json::json!({
908            "type": "webauthn.get",
909            "challenge": options.challenge,
910            "origin": ORIGIN,
911        });
912        let client_data_bytes = serde_json::to_vec(&client_data).expect("json");
913        let assertion_auth_data = build_auth_data(RP_ID, 0x01, 1, &[], &[]);
914        let mut signed = assertion_auth_data.clone();
915        signed.extend_from_slice(&sha2::Sha256::digest(&client_data_bytes));
916        let rng = ring::rand::SystemRandom::new();
917        let signature = other_key.sign(&rng, &signed).expect("ES256 signing");
918
919        let err = svc
920            .finish_authentication(
921                &alice(),
922                &result.credential_id,
923                &b64(&client_data_bytes),
924                &b64(&assertion_auth_data),
925                &b64(signature.as_ref()),
926            )
927            .expect_err("an assertion from the wrong key must not verify");
928        assert!(err.to_string().contains("kit error"), "{err}");
929    }
930}