gunnar-grpc 1.5.0

The tonic service implementations and the event bus behind gunnar's gRPC control plane.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
//! **WebAuthn, decided by gunnar** (Stage L, 2026-10-04: "passkeys + invites +
//! passkey↔principal pin → gunnar-server").
//!
//! A person's passkeys are rows in gunnar's identity table
//! (`Access.AddCredential`), and a web login is an assertion gunnar verifies
//! itself (`Access.WebLogin`): a console relays what the browser answered and
//! decides nothing. This module is the whole of the cryptography that takes,
//! and it is pure RustCrypto — `p256` ECDSA over SHA-256, the ES256 every
//! authenticator speaks — with no OpenSSL anywhere.
//!
//! # The encodings, once
//!
//! * A **public key** is P-256, SEC1 uncompressed: `0x04 || X || Y`, 65 bytes.
//!   That is a COSE EC2 key's `x` and `y` with the prefix a SEC1 reader wants.
//! * A **credential id** is the authenticator's raw bytes on the wire, and
//!   base64url without padding as a map key (what a browser shows as `id`).

use base64ct::{Base64UrlUnpadded, Encoding as _};

/// The length of a P-256 public key, SEC1 uncompressed.
pub const PUBLIC_KEY_LEN: usize = 65;

/// The longest credential id WebAuthn allows (§5.8.3, 1023 bytes).
pub const MAX_CREDENTIAL_ID_LEN: usize = 1023;

/// Base64url without padding — a credential id as a key, a challenge as
/// `clientDataJSON` carries it.
pub fn b64url(bytes: &[u8]) -> String {
    Base64UrlUnpadded::encode_string(bytes)
}

/// The inverse of [`b64url`]; `None` for text that is not base64url.
pub fn from_b64url(text: &str) -> Option<Vec<u8>> {
    Base64UrlUnpadded::decode_vec(text).ok()
}

/// **Is this a credential id gunnar will store?** Non-empty and within
/// WebAuthn's own bound.
pub fn check_credential_id(id: &[u8]) -> Result<(), String> {
    if id.is_empty() {
        return Err("`credential_id` is required".to_owned());
    }
    if id.len() > MAX_CREDENTIAL_ID_LEN {
        return Err(format!(
            "a credential id is at most {MAX_CREDENTIAL_ID_LEN} bytes and this one is {}",
            id.len()
        ));
    }
    Ok(())
}

/// **Is this a P-256 public key a login could ever be verified with?**
/// SEC1 uncompressed, 65 bytes, and a point on the curve — refused at
/// REGISTRATION rather than at the first login, where a bad key would fail
/// with nothing to say why.
pub fn check_public_key(sec1: &[u8]) -> Result<(), String> {
    if sec1.len() != PUBLIC_KEY_LEN || sec1[0] != 0x04 {
        return Err(format!(
            "`public_key` is P-256, SEC1 uncompressed: 0x04 || X || Y, {PUBLIC_KEY_LEN} bytes \
             (this is {} bytes{})",
            sec1.len(),
            if sec1.first() == Some(&0x04) { "" } else { ", not starting 0x04" }
        ));
    }
    p256::ecdsa::VerifyingKey::from_sec1_bytes(sec1)
        .map(|_| ())
        .map_err(|_| "`public_key` is not a point on P-256".to_owned())
}

/// `n` bytes from the operating system's generator.
pub fn random_bytes(n: usize) -> Vec<u8> {
    use rand::Rng as _;
    let mut bytes = vec![0u8; n];
    rand::rand_core::UnwrapErr(rand::rngs::SysRng).fill_bytes(&mut bytes);
    bytes
}

/// Lowercase hex.
pub fn hex(bytes: &[u8]) -> String {
    bytes.iter().map(|b| format!("{b:02x}")).collect()
}

/// Lowercase hex SHA-256 — what an invite's secret is kept as.
pub fn sha256_hex(bytes: &[u8]) -> String {
    use sha2::Digest as _;
    hex(&sha2::Sha256::digest(bytes))
}

// ─── Challenges ──────────────────────────────────────────────────────────────

/// How long a challenge may be answered: five minutes.
pub const CHALLENGE_TTL_MS: i64 = 5 * 60_000;
/// The most challenges outstanding at once. Issuing is a server admin's (the
/// relay's) act, so this bounds a misbehaving relay, not the internet.
pub const MAX_OUTSTANDING_CHALLENGES: usize = 10_000;
/// The length of a challenge, in bytes.
pub const CHALLENGE_LEN: usize = 32;

/// **The challenges this process has issued and not yet seen answered.**
/// In memory, on purpose: a challenge lives five minutes, and one issued
/// before a restart is one nobody can still be answering.
///
/// Its `Debug` says how many are outstanding and never which (stinky row 23):
/// an outstanding challenge is answerable, so printing one is leaking one.
#[derive(Default)]
pub struct Challenges {
    issued: std::sync::Mutex<std::collections::HashMap<Vec<u8>, i64>>,
}

impl std::fmt::Debug for Challenges {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let outstanding = self.issued.lock().unwrap_or_else(|p| p.into_inner()).len();
        f.debug_struct("Challenges").field("outstanding", &outstanding).finish()
    }
}

impl Challenges {
    /// A fresh challenge and when it expires; refused when too many are
    /// outstanding.
    pub fn issue(&self, now: i64) -> Result<(Vec<u8>, i64), String> {
        let mut issued = self.issued.lock().unwrap_or_else(|p| p.into_inner());
        issued.retain(|_, expires| *expires > now);
        if issued.len() >= MAX_OUTSTANDING_CHALLENGES {
            return Err(format!(
                "{MAX_OUTSTANDING_CHALLENGES} web challenges are outstanding; answer or let some \
                 expire first"
            ));
        }
        let challenge = random_bytes(CHALLENGE_LEN);
        let expires = now + CHALLENGE_TTL_MS;
        issued.insert(challenge.clone(), expires);
        Ok((challenge, expires))
    }

    /// **Spend a challenge**: true when it was issued here, has not expired
    /// and was not spent before. It is gone afterwards whatever the answer.
    pub fn take(&self, challenge: &[u8], now: i64) -> bool {
        let mut issued = self.issued.lock().unwrap_or_else(|p| p.into_inner());
        matches!(issued.remove(challenge), Some(expires) if expires > now)
    }
}

// ─── The ceremony ────────────────────────────────────────────────────────────

/// `clientDataJSON`, the three fields gunnar checks. Its `Debug` redacts the
/// challenge (stinky row 23).
#[derive(Clone, PartialEq, Eq)]
pub struct ClientData {
    /// `webauthn.get` or `webauthn.create`.
    pub kind: String,
    /// The challenge, decoded from base64url.
    pub challenge: Vec<u8>,
    /// The origin the browser saw.
    pub origin: String,
}

impl std::fmt::Debug for ClientData {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("ClientData")
            .field("kind", &self.kind)
            .field("challenge", &format_args!("<redacted {} bytes>", self.challenge.len()))
            .field("origin", &self.origin)
            .finish()
    }
}

/// Parse `clientDataJSON`.
pub fn client_data(json: &[u8]) -> Result<ClientData, String> {
    let value: serde_json::Value =
        serde_json::from_slice(json).map_err(|_| "clientDataJSON is not JSON".to_owned())?;
    let field = |name: &str| {
        value
            .get(name)
            .and_then(|v| v.as_str())
            .map(str::to_owned)
            .ok_or_else(|| format!("clientDataJSON has no `{name}`"))
    };
    let challenge = field("challenge")?;
    Ok(ClientData {
        kind: field("type")?,
        challenge: from_b64url(&challenge)
            .ok_or_else(|| "clientDataJSON's challenge is not base64url".to_owned())?,
        origin: field("origin")?,
    })
}

/// **Does `origin` belong to the relying party `rp_id`?** `https://` with a
/// host that is `rp_id` or a subdomain of it — or, for development,
/// `http://localhost` (a browser's secure context) when `rp_id` is
/// `localhost`. A port is allowed; a path is not.
pub fn origin_fits_rp_id(origin: &str, rp_id: &str) -> bool {
    let rp_id = rp_id.trim().to_ascii_lowercase();
    if rp_id.is_empty() || rp_id.contains('/') || rp_id.contains(':') {
        return false;
    }
    let (scheme, rest) = match origin.split_once("://") {
        Some(parts) => parts,
        None => return false,
    };
    if rest.contains('/') || rest.is_empty() {
        return false;
    }
    let host = match rest.rsplit_once(':') {
        Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host,
        Some(_) => return false,
        None => rest,
    }
    .to_ascii_lowercase();
    let host_fits = host == rp_id || host.ends_with(&format!(".{rp_id}"));
    match scheme {
        "https" => host_fits,
        "http" => host_fits && (host == "localhost" || host.ends_with(".localhost")),
        _ => false,
    }
}

/// **A relying party this server answers for**: its `rp_id` and the exact
/// origins a browser may be at for it (`--web-relying-party`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RelyingParty {
    /// The WebAuthn relying-party id (`gunnar.rs`).
    pub rp_id: String,
    /// The origins allowed for it, exactly (`https://gunnar.rs`).
    pub origins: Vec<String>,
}

impl std::str::FromStr for RelyingParty {
    type Err = String;
    /// `<rp_id>=<origin>[,<origin>…]`.
    fn from_str(s: &str) -> Result<Self, String> {
        let (rp_id, origins) = s.split_once('=').ok_or_else(|| format!("{s:?} is not <rp_id>=<origin>[,<origin>…]"))?;
        let rp_id = rp_id.trim().to_ascii_lowercase();
        let origins: Vec<String> = origins.split(',').map(|o| o.trim().to_owned()).filter(|o| !o.is_empty()).collect();
        if rp_id.is_empty() || origins.is_empty() {
            return Err(format!("{s:?} names no relying party or no origin"));
        }
        if let Some(bad) = origins.iter().find(|o| !origin_fits_rp_id(o, &rp_id)) {
            return Err(format!("the origin {bad:?} does not belong to the relying party {rp_id:?}"));
        }
        Ok(RelyingParty { rp_id, origins })
    }
}

/// **Is `(rp_id, origin)`, as the relay states them, one this server answers
/// for?** An empty list is the relay's word, as before.
pub fn check_relying_party(parties: &[RelyingParty], rp_id: &str, origin: &str) -> Result<(), String> {
    if parties.is_empty() {
        return Ok(());
    }
    let rp_id = rp_id.trim();
    let Some(party) = parties.iter().find(|p| p.rp_id.eq_ignore_ascii_case(rp_id)) else {
        return Err(format!("this server is not the relying party {rp_id:?} (--web-relying-party)"));
    };
    if !party.origins.iter().any(|o| o == origin.trim()) {
        return Err(format!("the origin {origin:?} is not one this server answers for as {:?}", party.rp_id));
    }
    Ok(())
}

/// Check a ceremony's client data against what the relay says the ceremony
/// was: its type, and an origin that is the one asked and fits the relying
/// party. (The challenge is the caller's to spend.)
pub fn check_ceremony(
    data: &ClientData,
    want_kind: &str,
    origin: &str,
    rp_id: &str,
) -> Result<(), String> {
    if data.kind != want_kind {
        return Err(format!(
            "this is a {:?} ceremony, and {want_kind:?} was asked for",
            data.kind
        ));
    }
    if data.origin != origin {
        return Err(format!(
            "the browser was at {:?}, not at {origin:?}",
            data.origin
        ));
    }
    if !origin_fits_rp_id(origin, rp_id) {
        return Err(format!(
            "the origin {origin:?} does not belong to the relying party {rp_id:?}"
        ));
    }
    Ok(())
}

/// What a verified assertion tells the caller.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Verified {
    /// The counter the authenticator presented — to be stored back.
    pub sign_count: u32,
    /// Whether the authenticator verified the USER (PIN, biometric).
    pub user_verified: bool,
}

const FLAG_UP: u8 = 0x01;
const FLAG_UV: u8 = 0x04;

/// **Verify an assertion's authenticator data and signature** against the
/// stored key and counter (the client data was checked by
/// [`check_ceremony`]): rpIdHash == SHA-256(rp_id); user presence (and user
/// verification when required); the ES256 signature over
/// `authenticatorData || SHA-256(clientDataJSON)`; and the counter, when the
/// stored and the presented one are both non-zero, strictly increasing (a
/// synced passkey reports 0 for ever and must not be locked out by it).
pub fn verify_assertion(
    public_key: &[u8],
    stored_count: u32,
    client_data_json: &[u8],
    authenticator_data: &[u8],
    signature_der: &[u8],
    rp_id: &str,
    require_user_verification: bool,
) -> Result<Verified, String> {
    use p256::ecdsa::signature::Verifier as _;
    use sha2::Digest as _;
    if authenticator_data.len() < 37 {
        return Err("authenticatorData is shorter than 37 bytes".to_owned());
    }
    if authenticator_data[..32] != sha2::Sha256::digest(rp_id.as_bytes())[..] {
        return Err(format!(
            "authenticatorData was made for another relying party than {rp_id:?}"
        ));
    }
    let flags = authenticator_data[32];
    if flags & FLAG_UP == 0 {
        return Err("the authenticator reports no user presence".to_owned());
    }
    let user_verified = flags & FLAG_UV != 0;
    if require_user_verification && !user_verified {
        return Err("user verification was required and the authenticator did not verify the user".to_owned());
    }
    let key = p256::ecdsa::VerifyingKey::from_sec1_bytes(public_key)
        .map_err(|_| "the stored key is not a P-256 point".to_owned())?;
    let signature = p256::ecdsa::Signature::from_der(signature_der)
        .map_err(|_| "the signature is not ASN.1 DER ECDSA".to_owned())?;
    let mut message = authenticator_data.to_vec();
    message.extend_from_slice(&sha2::Sha256::digest(client_data_json));
    key.verify(&message, &signature)
        .map_err(|_| "the signature does not verify with this passkey".to_owned())?;
    let sign_count = u32::from_be_bytes([
        authenticator_data[33],
        authenticator_data[34],
        authenticator_data[35],
        authenticator_data[36],
    ]);
    if stored_count != 0 && sign_count != 0 && sign_count <= stored_count {
        return Err(format!(
            "the signature counter went from {stored_count} to {sign_count}: a cloned \
             authenticator, or a replay"
        ));
    }
    Ok(Verified {
        sign_count,
        user_verified,
    })
}

/// What a creation ceremony's `attestationObject` says, once
/// [`verify_attestation`] has bound it to the request's fields.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Attested {
    /// The counter in its authenticatorData — the one stored.
    pub sign_count: u32,
    /// Whether the authenticator verified the USER (PIN, biometric).
    pub user_verified: bool,
    /// The attestation format (`none`, `packed`, …).
    pub format: String,
    /// The attestation statement's signature was verified with the
    /// credential's own key (a `packed` self-attestation).
    pub self_signed: bool,
}

/// **Bind a registration to what the authenticator attested** (stinky row 13,
/// 2026-10-10). The relay used to hand gunnar a bare `public_key`; now it
/// relays the browser's `attestationObject` too, and gunnar reads the
/// authenticatorData in it itself:
///
/// * `{fmt, attStmt, authData}`, well-formed CBOR, nothing after it;
/// * rpIdHash == SHA-256(`rp_id`), user presence set, attested credential
///   data present;
/// * its credential id == `credential_id`, its COSE key an ES256 P-256 EC2 key
///   whose point == `public_key` — the relay cannot name another key than the
///   authenticator made;
/// * `packed` with no certificate (self-attestation): the ES256 signature over
///   `authData || SHA-256(clientDataJSON)` verifies with that same key — the
///   authenticator holds it, for THIS ceremony;
/// * `none`: an empty statement.
///
/// What it cannot do: gunnar holds no attestation trust anchors, so a `none`
/// object (the browsers' default, and all a synced passkey sends) or a
/// certificate-signed format is checked for agreement only — whoever can
/// forge a whole creation ceremony can still forge its attestation object.
/// What it does close is the relay that swaps one field.
pub fn verify_attestation(
    attestation_object: &[u8],
    client_data_json: &[u8],
    rp_id: &str,
    credential_id: &[u8],
    public_key: &[u8],
) -> Result<Attested, String> {
    use sha2::Digest as _;
    if attestation_object.is_empty() {
        return Err("no attestation object: relay the browser's `attestationObject`".to_owned());
    }
    if attestation_object.len() > MAX_ATTESTATION_LEN {
        return Err(format!("the attestation object is over {MAX_ATTESTATION_LEN} bytes"));
    }
    let mut at = 0;
    let top = cbor::read(attestation_object, &mut at, 0)?;
    if at != attestation_object.len() {
        return Err("bytes after the attestation object".to_owned());
    }
    let top = top.map().ok_or("the attestation object is not a CBOR map")?;
    let format = cbor::field(top, "fmt")
        .and_then(cbor::Item::text)
        .ok_or("the attestation object has no `fmt`")?
        .to_owned();
    let statement = cbor::field(top, "attStmt")
        .and_then(cbor::Item::map)
        .ok_or("the attestation object has no `attStmt` map")?;
    let auth_data = cbor::field(top, "authData")
        .and_then(cbor::Item::bytes)
        .ok_or("the attestation object has no `authData`")?;

    if auth_data.len() < 37 {
        return Err("authenticatorData is shorter than 37 bytes".to_owned());
    }
    if auth_data[..32] != sha2::Sha256::digest(rp_id.as_bytes())[..] {
        return Err(format!("the authenticator made this credential for another relying party than {rp_id:?}"));
    }
    let flags = auth_data[32];
    if flags & FLAG_UP == 0 {
        return Err("the authenticator reports no user presence".to_owned());
    }
    if flags & FLAG_AT == 0 || auth_data.len() < 55 {
        return Err("authenticatorData carries no attested credential data".to_owned());
    }
    let sign_count = u32::from_be_bytes([auth_data[33], auth_data[34], auth_data[35], auth_data[36]]);
    let id_len = usize::from(u16::from_be_bytes([auth_data[53], auth_data[54]]));
    let attested_id = auth_data
        .get(55..55 + id_len)
        .ok_or("authenticatorData is cut short inside its credential id")?;
    if attested_id != credential_id {
        return Err("the attested credential id is not the relayed `credential_id`".to_owned());
    }
    let mut at = 55 + id_len;
    let cose = cbor::read(auth_data, &mut at, 0)?;
    if flags & FLAG_ED == 0 && at != auth_data.len() {
        return Err("bytes after the credential key, and no extensions flagged".to_owned());
    }
    let attested_key = cose_es256_point(&cose)?;
    if attested_key != public_key {
        return Err("the attested key is not the relayed `public_key`".to_owned());
    }

    let self_signed = match format.as_str() {
        "none" => {
            if !statement.is_empty() {
                return Err("a `none` attestation with a statement".to_owned());
            }
            false
        }
        "packed" if cbor::field(statement, "x5c").is_none() => {
            use p256::ecdsa::signature::Verifier as _;
            if cbor::field(statement, "alg").and_then(cbor::Item::int) != Some(-7) {
                return Err("a packed self-attestation that is not ES256".to_owned());
            }
            let sig = cbor::field(statement, "sig")
                .and_then(cbor::Item::bytes)
                .ok_or("a packed attestation with no `sig`")?;
            let key = p256::ecdsa::VerifyingKey::from_sec1_bytes(&attested_key)
                .map_err(|_| "the attested key is not a P-256 point".to_owned())?;
            let signature = p256::ecdsa::Signature::from_der(sig)
                .map_err(|_| "the attestation signature is not ASN.1 DER ECDSA".to_owned())?;
            let mut message = auth_data.to_vec();
            message.extend_from_slice(&sha2::Sha256::digest(client_data_json));
            key.verify(&message, &signature)
                .map_err(|_| "the self-attestation does not verify with the attested key, over this ceremony".to_owned())?;
            true
        }
        _ => false,
    };
    Ok(Attested { sign_count, user_verified: flags & FLAG_UV != 0, format, self_signed })
}

/// An attestation object is a few hundred bytes; a certificate chain a few KiB.
pub const MAX_ATTESTATION_LEN: usize = 16 * 1024;

/// A COSE_Key (RFC 9053) for ES256 on P-256: kty 2 (EC2), alg -7, crv 1, and
/// 32-byte x and y — as `0x04 || x || y`.
fn cose_es256_point(cose: &cbor::Item<'_>) -> Result<Vec<u8>, String> {
    let map = cose.map().ok_or("the credential key is not a COSE map")?;
    let get = |label: i64| map.iter().find(|(k, _)| k.int() == Some(label)).map(|(_, v)| v);
    if get(1).and_then(cbor::Item::int) != Some(2) {
        return Err("the credential key is not an EC2 key".to_owned());
    }
    if get(3).and_then(cbor::Item::int) != Some(-7) {
        return Err("the credential key is not ES256".to_owned());
    }
    if get(-1).and_then(cbor::Item::int) != Some(1) {
        return Err("the credential key is not on P-256".to_owned());
    }
    let (Some(x), Some(y)) = (get(-2).and_then(cbor::Item::bytes), get(-3).and_then(cbor::Item::bytes)) else {
        return Err("the credential key has no x or y".to_owned());
    };
    if x.len() != 32 || y.len() != 32 {
        return Err("the credential key's x and y are not 32 bytes each".to_owned());
    }
    let mut point = Vec::with_capacity(PUBLIC_KEY_LEN);
    point.push(0x04);
    point.extend_from_slice(x);
    point.extend_from_slice(y);
    Ok(point)
}

/// **A `none` attestation object** — what a browser answers a creation
/// ceremony with by default: `{fmt: "none", attStmt: {}, authData}`, the
/// authData carrying user presence, the counter, and `credential_id` with its
/// P-256 key (`public_key`, SEC1 uncompressed) as a COSE EC2 key. For a test's
/// authenticator and for tools that drive the ceremony without a browser;
/// gunnar itself only ever reads one.
pub fn attestation_none(rp_id: &str, credential_id: &[u8], public_key: &[u8], sign_count: u32) -> Vec<u8> {
    let auth_data = attested_auth_data(rp_id, FLAG_UP | FLAG_AT, sign_count, credential_id, public_key);
    attestation_object("none", &[], &auth_data)
}

/// `rpIdHash | flags | signCount | aaguid(0) | credIdLen | credId | COSE key`.
#[doc(hidden)]
pub fn attested_auth_data(rp_id: &str, flags: u8, sign_count: u32, credential_id: &[u8], public_key: &[u8]) -> Vec<u8> {
    use sha2::Digest as _;
    let mut out = sha2::Sha256::digest(rp_id.as_bytes()).to_vec();
    out.push(flags);
    out.extend_from_slice(&sign_count.to_be_bytes());
    out.extend_from_slice(&[0u8; 16]);
    out.extend_from_slice(&u16::try_from(credential_id.len()).unwrap_or(u16::MAX).to_be_bytes());
    out.extend_from_slice(credential_id);
    let (x, y) = if public_key.len() == PUBLIC_KEY_LEN { (&public_key[1..33], &public_key[33..]) } else { (&[][..], &[][..]) };
    cbor::map_head(&mut out, 5);
    cbor::int(&mut out, 1);
    cbor::int(&mut out, 2);
    cbor::int(&mut out, 3);
    cbor::int(&mut out, -7);
    cbor::int(&mut out, -1);
    cbor::int(&mut out, 1);
    cbor::int(&mut out, -2);
    cbor::bytes(&mut out, x);
    cbor::int(&mut out, -3);
    cbor::bytes(&mut out, y);
    out
}

/// `{fmt, attStmt, authData}` with `att_stmt` as `(text key, value)` pairs —
/// `alg` an integer, anything else bytes.
#[doc(hidden)]
pub fn attestation_object(fmt: &str, att_stmt: &[(&str, AttStmtValue<'_>)], auth_data: &[u8]) -> Vec<u8> {
    let mut out = Vec::new();
    cbor::map_head(&mut out, 3);
    cbor::text(&mut out, "fmt");
    cbor::text(&mut out, fmt);
    cbor::text(&mut out, "attStmt");
    cbor::map_head(&mut out, att_stmt.len() as u64);
    for (k, v) in att_stmt {
        cbor::text(&mut out, k);
        match v {
            AttStmtValue::Int(i) => cbor::int(&mut out, *i),
            AttStmtValue::Bytes(b) => cbor::bytes(&mut out, b),
            AttStmtValue::Certs(certs) => {
                cbor::array_head(&mut out, certs.len() as u64);
                for c in *certs {
                    cbor::bytes(&mut out, c);
                }
            }
        }
    }
    cbor::text(&mut out, "authData");
    cbor::bytes(&mut out, auth_data);
    out
}

/// One value in an attestation statement, for [`attestation_object`].
#[doc(hidden)]
#[derive(Debug, Clone, Copy)]
pub enum AttStmtValue<'a> {
    Int(i64),
    Bytes(&'a [u8]),
    Certs(&'a [&'a [u8]]),
}

const FLAG_AT: u8 = 0x40;
const FLAG_ED: u8 = 0x80;

/// The few CBOR items WebAuthn's attestation object is made of (RFC 8949):
/// unsigned and negative integers, byte and text strings, arrays, maps, and the
/// simple values. Definite lengths only — CTAP2's canonical form — and a
/// nesting limit, because the bytes come from a relay.
mod cbor {
    const MAX_DEPTH: usize = 8;

    /// One decoded item, borrowing its strings from the input.
    #[derive(Debug, Clone, PartialEq)]
    pub(super) enum Item<'a> {
        Int(i128),
        Bytes(&'a [u8]),
        Text(&'a str),
        Array(Vec<Item<'a>>),
        Map(Vec<(Item<'a>, Item<'a>)>),
        Simple(u8),
    }

    impl<'a> Item<'a> {
        pub(super) fn int(&self) -> Option<i64> {
            match self {
                Item::Int(i) => i64::try_from(*i).ok(),
                _ => None,
            }
        }
        pub(super) fn bytes(&self) -> Option<&'a [u8]> {
            match self {
                Item::Bytes(b) => Some(b),
                _ => None,
            }
        }
        pub(super) fn text(&self) -> Option<&'a str> {
            match self {
                Item::Text(t) => Some(t),
                _ => None,
            }
        }
        pub(super) fn map(&self) -> Option<&[(Item<'a>, Item<'a>)]> {
            match self {
                Item::Map(m) => Some(m),
                _ => None,
            }
        }
    }

    /// The value under a text key.
    pub(super) fn field<'m, 'a>(map: &'m [(Item<'a>, Item<'a>)], key: &str) -> Option<&'m Item<'a>> {
        map.iter().find(|(k, _)| k.text() == Some(key)).map(|(_, v)| v)
    }

    fn take<'a>(buf: &'a [u8], at: &mut usize, n: usize) -> Result<&'a [u8], String> {
        let end = at.checked_add(n).filter(|end| *end <= buf.len()).ok_or("CBOR cut short")?;
        let out = &buf[*at..end];
        *at = end;
        Ok(out)
    }

    /// One item at `*at`, advancing past it.
    pub(super) fn read<'a>(buf: &'a [u8], at: &mut usize, depth: usize) -> Result<Item<'a>, String> {
        if depth > MAX_DEPTH {
            return Err("CBOR nested too deep".to_owned());
        }
        let first = take(buf, at, 1)?[0];
        let (major, info) = (first >> 5, first & 0x1f);
        let n: u64 = match info {
            0..=23 => u64::from(info),
            24 => u64::from(take(buf, at, 1)?[0]),
            25 => u64::from(u16::from_be_bytes(take(buf, at, 2)?.try_into().expect("2"))),
            26 => u64::from(u32::from_be_bytes(take(buf, at, 4)?.try_into().expect("4"))),
            27 => u64::from_be_bytes(take(buf, at, 8)?.try_into().expect("8")),
            _ => return Err("indefinite-length or reserved CBOR".to_owned()),
        };
        // Every element is at least one byte: a count past what is left is a lie.
        let left = buf.len() - *at;
        let count = |n: u64| usize::try_from(n).ok().filter(|n| *n <= left).ok_or_else(|| "CBOR cut short".to_owned());
        Ok(match major {
            0 => Item::Int(i128::from(n)),
            1 => Item::Int(-1 - i128::from(n)),
            2 => Item::Bytes(take(buf, at, count(n)?)?),
            3 => Item::Text(std::str::from_utf8(take(buf, at, count(n)?)?).map_err(|_| "CBOR text is not UTF-8".to_owned())?),
            4 => {
                let n = count(n)?;
                let mut items = Vec::with_capacity(n);
                for _ in 0..n {
                    items.push(read(buf, at, depth + 1)?);
                }
                Item::Array(items)
            }
            5 => {
                let n = count(n)?;
                let mut pairs = Vec::with_capacity(n);
                for _ in 0..n {
                    let k = read(buf, at, depth + 1)?;
                    let v = read(buf, at, depth + 1)?;
                    if pairs.iter().any(|(seen, _)| *seen == k) {
                        return Err("a CBOR map names one key twice".to_owned());
                    }
                    pairs.push((k, v));
                }
                Item::Map(pairs)
            }
            6 => return Err("tagged CBOR".to_owned()),
            _ if info < 24 => Item::Simple(info),
            _ => return Err("CBOR floats have no place in an attestation".to_owned()),
        })
    }

    pub(super) fn head(out: &mut Vec<u8>, major: u8, n: u64) {
        let m = major << 5;
        match n {
            0..=23 => out.push(m | n as u8),
            24..=0xff => out.extend_from_slice(&[m | 24, n as u8]),
            0x100..=0xffff => {
                out.push(m | 25);
                out.extend_from_slice(&(n as u16).to_be_bytes());
            }
            0x1_0000..=0xffff_ffff => {
                out.push(m | 26);
                out.extend_from_slice(&(n as u32).to_be_bytes());
            }
            _ => {
                out.push(m | 27);
                out.extend_from_slice(&n.to_be_bytes());
            }
        }
    }
    pub(super) fn int(out: &mut Vec<u8>, v: i64) {
        if v >= 0 {
            head(out, 0, v as u64);
        } else {
            head(out, 1, (-1 - v) as u64);
        }
    }
    pub(super) fn bytes(out: &mut Vec<u8>, b: &[u8]) {
        head(out, 2, b.len() as u64);
        out.extend_from_slice(b);
    }
    pub(super) fn text(out: &mut Vec<u8>, t: &str) {
        head(out, 3, t.len() as u64);
        out.extend_from_slice(t.as_bytes());
    }
    pub(super) fn array_head(out: &mut Vec<u8>, n: u64) {
        head(out, 4, n);
    }
    pub(super) fn map_head(out: &mut Vec<u8>, n: u64) {
        head(out, 5, n);
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn key(seed: u8) -> Vec<u8> {
        p256::ecdsa::SigningKey::from_slice(&[seed; 32])
            .expect("a scalar")
            .verifying_key()
            .to_sec1_point(false)
            .as_bytes()
            .to_vec()
    }

    #[test]
    fn a_point_on_the_curve_is_a_key_and_nothing_else_is() {
        assert!(check_public_key(&key(1)).is_ok());
        let mut off = key(1);
        off[64] ^= 1;
        assert!(check_public_key(&off).is_err(), "off the curve");
        let compressed = p256::ecdsa::SigningKey::from_slice(&[1; 32])
            .expect("a scalar")
            .verifying_key()
            .to_sec1_point(true)
            .as_bytes()
            .to_vec();
        assert!(check_public_key(&compressed).is_err(), "compressed is not the stored form");
        assert!(check_public_key(&[0u8; 65]).is_err());
    }

    #[test]
    fn an_origin_belongs_to_its_relying_party_and_no_other() {
        for (origin, rp, fits) in [
            ("https://gunnar.rs", "gunnar.rs", true),
            ("https://console.gunnar.rs", "gunnar.rs", true),
            ("https://gunnar.rs:8443", "gunnar.rs", true),
            ("https://evilgunnar.rs", "gunnar.rs", false),
            ("http://gunnar.rs", "gunnar.rs", false),
            ("http://localhost:3210", "localhost", true),
            ("https://gunnar.rs/path", "gunnar.rs", false),
            ("gunnar.rs", "gunnar.rs", false),
        ] {
            assert_eq!(origin_fits_rp_id(origin, rp), fits, "{origin} / {rp}");
        }
    }

    #[test]
    fn a_challenge_is_spent_once_and_not_after_it_expires() {
        let c = Challenges::default();
        let (one, expires) = c.issue(1_000).expect("issued");
        assert_eq!(one.len(), CHALLENGE_LEN);
        assert_eq!(expires, 1_000 + CHALLENGE_TTL_MS);
        assert!(c.take(&one, 2_000));
        assert!(!c.take(&one, 2_000), "spent");
        let (two, expires) = c.issue(1_000).expect("issued");
        assert!(!c.take(&two, expires), "expired");
        assert!(!c.take(&[0; 32], 1_000), "never issued");
    }

    #[test]
    fn a_credential_id_round_trips_through_its_key() {
        let raw = b"\x00\xffcredential";
        assert_eq!(from_b64url(&b64url(raw)).as_deref(), Some(&raw[..]));
        assert!(check_credential_id(&[]).is_err());
        assert!(check_credential_id(&[1; 1024]).is_err());
        assert!(check_credential_id(&[1; 1023]).is_ok());
    }

    /// Stinky row 23 (2026-10-10): `#[derive(Debug)]` on the challenges this
    /// process holds and on a parsed `clientDataJSON` — one `{:?}` in a log
    /// line wrote live, answerable challenges into it.
    #[test]
    fn a_debug_print_never_carries_a_live_challenge() {
        let c = Challenges::default();
        let (one, _) = c.issue(1_000).expect("issued");
        let shown = format!("{c:?}");
        for spelled in [format!("{one:?}"), format!("{:?}", &one[..4]), b64url(&one)] {
            assert!(!shown.contains(spelled.trim_end_matches(']')), "a live challenge is in {shown}");
        }
        assert!(shown.contains('1'), "how many are outstanding is still said: {shown}");

        let json = format!(r#"{{"type":"webauthn.get","challenge":"{}","origin":"https://gunnar.rs"}}"#, b64url(&one));
        let data = client_data(json.as_bytes()).expect("parsed");
        let shown = format!("{data:?}");
        for spelled in [format!("{:?}", data.challenge), format!("{:?}", &data.challenge[..4]), b64url(&one)] {
            assert!(!shown.contains(spelled.trim_end_matches(']')), "the challenge is in {shown}");
        }
        assert!(shown.contains("webauthn.get") && shown.contains("https://gunnar.rs"), "the rest is still said: {shown}");
    }

    /// Stinky row 13 (2026-10-10): `rp_id` and `origin` came from the request
    /// alone, so the whole ceremony trusted the relay: a relay could run it for
    /// any relying party it named. A server told its relying parties refuses
    /// any other.
    #[test]
    fn a_server_told_its_relying_parties_refuses_any_other() {
        let parties: Vec<RelyingParty> = vec!["gunnar.rs=https://gunnar.rs,https://app.gunnar.rs".parse().unwrap()];
        assert!(check_relying_party(&parties, "gunnar.rs", "https://gunnar.rs").is_ok());
        assert!(check_relying_party(&parties, "GUNNAR.rs", "https://app.gunnar.rs").is_ok());
        assert!(check_relying_party(&parties, "evil.example", "https://evil.example").is_err(), "another relying party");
        assert!(check_relying_party(&parties, "gunnar.rs", "https://evil.gunnar.rs").is_err(), "an origin not listed, though it fits the rp_id");
        assert!(check_relying_party(&[], "evil.example", "https://evil.example").is_ok(), "none told: the relay's word, as before");
        assert!("gunnar.rs=https://evil.example".parse::<RelyingParty>().is_err());
        assert!("gunnar.rs".parse::<RelyingParty>().is_err());
    }

    fn signer(seed: u8) -> p256::ecdsa::SigningKey {
        p256::ecdsa::SigningKey::from_slice(&[seed; 32]).expect("a scalar")
    }

    fn packed_self(seed: u8, rp_id: &str, id: &[u8], client_data_json: &[u8], sign_with: u8) -> Vec<u8> {
        use p256::ecdsa::signature::Signer as _;
        use sha2::Digest as _;
        let auth_data = attested_auth_data(rp_id, FLAG_UP | FLAG_UV | FLAG_AT, 3, id, &key(seed));
        let mut message = auth_data.clone();
        message.extend_from_slice(&sha2::Sha256::digest(client_data_json));
        let sig: p256::ecdsa::Signature = signer(sign_with).sign(&message);
        let der = sig.to_der();
        attestation_object(
            "packed",
            &[("alg", AttStmtValue::Int(-7)), ("sig", AttStmtValue::Bytes(der.as_bytes()))],
            &auth_data,
        )
    }

    /// Stinky row 13, second half (2026-10-10): a registration took a raw
    /// `public_key` on the relay's word. Now the authenticator's own
    /// attestation object must name the same credential, the same key and the
    /// same relying party, with user presence — and a `packed`
    /// self-attestation's signature must verify with that key.
    #[test]
    fn a_registration_is_bound_to_what_its_authenticator_attested() {
        const CD: &[u8] = br#"{"type":"webauthn.create","challenge":"AAAA","origin":"https://gunnar.rs"}"#;
        let none = attestation_none("gunnar.rs", b"phone", &key(5), 0);
        let ok = verify_attestation(&none, CD, "gunnar.rs", b"phone", &key(5)).expect("a none attestation that agrees");
        assert_eq!((ok.format.as_str(), ok.self_signed, ok.sign_count), ("none", false, 0));

        let refused = |obj: &[u8], rp: &str, id: &[u8], pk: &[u8], what: &str| {
            let r = verify_attestation(obj, CD, rp, id, pk);
            assert!(r.is_err(), "{what}: {r:?}");
        };
        refused(&none, "gunnar.rs", b"phone", &key(6), "another key than the authenticator's");
        refused(&none, "gunnar.rs", b"laptop", &key(5), "another credential than the authenticator's");
        refused(&none, "evil.example", b"phone", &key(5), "made for another relying party");
        refused(&[], "gunnar.rs", b"phone", &key(5), "no attestation at all");
        refused(&none[..none.len() - 3], "gunnar.rs", b"phone", &key(5), "cut short");
        refused(&[0xa0], "gunnar.rs", b"phone", &key(5), "an empty map");
        refused(&[0xbb, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff], "gunnar.rs", b"phone", &key(5), "a map that claims 2^64 entries");
        refused(&[[0x81u8; 64].as_slice(), &[0]].concat(), "gunnar.rs", b"phone", &key(5), "nested past the limit");
        refused(&[none.as_slice(), &[0]].concat(), "gunnar.rs", b"phone", &key(5), "bytes after the object");
        let absent = attestation_object("none", &[], &attested_auth_data("gunnar.rs", FLAG_AT, 0, b"phone", &key(5)));
        refused(&absent, "gunnar.rs", b"phone", &key(5), "no user presence");
        let bare = attestation_object("none", &[], &attested_auth_data("gunnar.rs", FLAG_UP, 0, b"phone", &key(5))[..37]);
        refused(&bare, "gunnar.rs", b"phone", &key(5), "no attested credential data");

        let packed = packed_self(5, "gunnar.rs", b"phone", CD, 5);
        let ok = verify_attestation(&packed, CD, "gunnar.rs", b"phone", &key(5)).expect("a packed self-attestation");
        assert_eq!((ok.format.as_str(), ok.self_signed, ok.sign_count, ok.user_verified), ("packed", true, 3, true));
        refused(&packed_self(5, "gunnar.rs", b"phone", CD, 6), "gunnar.rs", b"phone", &key(5), "signed by another key");
        let other_cd = br#"{"type":"webauthn.create","challenge":"BBBB","origin":"https://gunnar.rs"}"#;
        assert!(
            verify_attestation(&packed_self(5, "gunnar.rs", b"phone", other_cd, 5), CD, "gunnar.rs", b"phone", &key(5)).is_err(),
            "signed over another ceremony's client data"
        );
    }
}