fidorium 0.1.6

A FIDO2/CTAP2 authenticator daemon for Linux backed by TPM 2.0
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
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
# Fidorium Architecture

FIDO2/CTAP2 authenticator daemon for Linux, backed by TPM2 hardware.

---

## Table of Contents

1. [Module Tree]#1-module-tree
2. [TPM2 Key Hierarchy]#2-tpm2-key-hierarchy
3. [CTAPHID State Machine]#3-ctaphid-state-machine
4. [CTAP2 Command Flows]#4-ctap2-command-flows
5. [Credential Storage Layout]#5-credential-storage-layout
6. [User Presence Flow]#6-user-presence-flow
7. [Dependencies]#7-dependencies
8. [Security Considerations]#8-security-considerations
9. [Risks and Open Questions]#9-risks-and-open-questions

---

## 1. Module Tree

```
src/
  main.rs                  -- Entrypoint: tokio runtime, CLI args, daemon lifecycle
  config.rs                -- Configuration (XDG paths, TPM device, pinentry binary)
  diagnostics.rs           -- Startup preflight checks (uhid, TPM device, pinentry binary)
  error.rs                 -- Unified error types (thiserror)

  hid/
    mod.rs                 -- Re-exports
    device.rs              -- UHIDDevice wrapper: create virtual FIDO HID device
    report.rs              -- HID report descriptor constant (usage page 0xF1D0)
    transport.rs           -- Read/write loop: raw 64-byte report I/O on /dev/uhid

  ctaphid/
    mod.rs                 -- Re-exports
    packet.rs              -- Init/Continuation packet parsing and assembly
    channel.rs             -- CID allocation table, channel state, timeouts
    dispatch.rs            -- CTAPHID command router (INIT, CBOR, PING, CANCEL, ERROR, KEEPALIVE)
    types.rs               -- CTAPHID command/error code constants

  ctap2/
    mod.rs                 -- Re-exports, top-level CTAP2 command dispatcher
    make_credential.rs     -- authenticatorMakeCredential (0x01)
    get_assertion.rs       -- authenticatorGetAssertion (0x02)
    get_info.rs            -- authenticatorGetInfo (0x04)
    types.rs               -- CBOR request/response structs (serde + ciborium)
    authenticator_data.rs  -- AuthenticatorData builder (rpIdHash, flags, counter, attCredData)
    attestation.rs         -- Self-attestation (packed format, self-signed)

  tpm/
    mod.rs                 -- Re-exports
    context.rs             -- TpmContext: create/own tss_esapi::Context, Mutex wrapper
    keys.rs                -- Primary key creation, per-credential child key creation, signing
    counter.rs             -- NV counter: define, increment, read
    seal.rs                -- Seal/unseal blobs to TPM key hierarchy (credential store encryption)

  store/
    mod.rs                 -- Re-exports
    credential.rs          -- CredentialRecord: the in-memory type for one credential
    disk.rs                -- Read/write/list/delete credential files on disk
    index.rs               -- In-memory index: rpIdHash -> Vec<CredentialRecord>

  up/
    mod.rs                 -- Re-exports
    pinentry.rs            -- Spawn pinentry, Assuan protocol, confirm/cancel
    prompt.rs              -- Build prompt strings (operation type, RP ID, user name)
```

Total: 24 source files across 6 modules plus root.


## 2. TPM2 Key Hierarchy

### Hierarchy Choice: Owner (Storage)

The Owner hierarchy is correct for user-owned credentials. Endorsement is
manufacturer-locked (unsuitable for creating child keys). Platform is
firmware-only.

### Object Tree

```
Owner Hierarchy (SH)
  |
  +-- Primary Key (ECC P-256, fixedTPM, fixedParent, sensitiveDataOrigin)
  |     Template: deterministic (same template always recreates same primary)
  |     Auth: owner password (empty by default on most Linux TPMs)
  |     NOT persisted to a handle -- recreated on startup from fixed template
  |     Purpose: parent for all credential keys + seal/unseal operations
  |
  +---+-- Child Key: credential_A (ECC P-256, ECDSA-SHA256, sign-only)
  |   |     Created via TPM2_Create under primary
  |   |     Returns: (TPM2B_PRIVATE, TPM2B_PUBLIC) -- the "key blob"
  |   |     Key blob stored on disk inside credential file (encrypted)
  |   |     Loaded into TPM via TPM2_Load when needed for signing
  |   |     Unloaded after use (transient handle)
  |   |
  |   +-- Child Key: credential_B ...
  |   +-- Child Key: credential_C ...
  |
  +-- Seal Object (symmetric, for encrypting credential metadata files)
        Created once, blob stored at ~/.local/share/fidorium/seal_key.blob
        Used to seal/unseal the AES-256-GCM key that encrypts credential files
        Bound to PCR policy (optional, configurable PCR selection)

NV Storage (Owner-authorized):
  +-- NV Index 0x01800100: Global signature counter (NV_Counter type)
        Attributes: AUTHWRITE | AUTHREAD | NT=COUNTER
        Size: 8 bytes (monotonic, TPM2_NV_Increment)
        Single counter for all credentials (not per-credential)
```

### Why a Single Global Counter (Not Per-Credential)

TPM2 NV storage is limited (typically ~1500-2000 bytes total on most TPMs,
with a handful of available NV indices). Allocating one NV counter per
credential would exhaust NV space after approximately 10-20 credentials.

**Design**: One global NV counter. Each GetAssertion call:
1. Calls `TPM2_NV_Increment` on the global counter
2. Reads the new counter value via `TPM2_NV_Read`
3. Embeds the value in the authenticator data's 4-byte signCount field

This is monotonically increasing across all credentials, which is what
relying parties need to detect cloning. The spec requires only that the
counter "is incremented for every assertion generated" -- it need not be
per-credential.

### Key Blob vs. Persistent Handle

We use **key blobs on disk** (the TPM2B_PRIVATE + TPM2B_PUBLIC pair returned
by TPM2_Create), not persistent handles. Reasons:

- Persistent handle slots are limited (~7 on many TPMs)
- Key blobs are encrypted by the TPM's storage hierarchy -- they are useless
  without access to the same TPM
- Blobs can be loaded transiently when needed and flushed immediately after
- This allows unlimited credentials

### Primary Key Recreation

The primary key uses a **deterministic template** (fixed parameters, no
random unique field). This means `TPM2_CreatePrimary` with the same template
always produces the same key on the same TPM. We recreate it on every daemon
startup rather than persisting it, saving a persistent handle slot.

Template parameters:
- Algorithm: `TPM2_ALG_ECC`
- Curve: `TPM2_ECC_NIST_P256`
- Scheme: `TPM2_ALG_NULL` (parent does not sign; children do)
- Attributes: `fixedTPM | fixedParent | sensitiveDataOrigin | userWithAuth | restricted | decrypt`
- Auth value: empty (or configurable)
- Unique field: zero-filled (deterministic)


## 3. CTAPHID State Machine

### HID Report Descriptor

64-byte reports, FIDO usage page:

```
0x06, 0xD0, 0xF1,  // Usage Page (FIDO Alliance, 0xF1D0)
0x09, 0x01,         // Usage (CTAP HID)
0xA1, 0x01,         // Collection (Application)
0x09, 0x20,         //   Usage (Data In)
0x15, 0x00,         //   Logical Minimum (0)
0x26, 0xFF, 0x00,   //   Logical Maximum (255)
0x75, 0x08,         //   Report Size (8 bits)
0x95, 0x40,         //   Report Count (64)
0x81, 0x02,         //   Input (Data, Variable, Absolute)
0x09, 0x21,         //   Usage (Data Out)
0x15, 0x00,         //   Logical Minimum (0)
0x26, 0xFF, 0x00,   //   Logical Maximum (255)
0x75, 0x08,         //   Report Size (8 bits)
0x95, 0x40,         //   Report Count (64)
0x91, 0x02,         //   Output (Data, Variable, Absolute)
0xC0,               // End Collection
```

### Packet Format

**Initialization packet** (first packet of a message):
```
Offset  Size  Field
0       4     CID (Channel ID)
4       1     CMD (command byte, bit 7 = 1)
5       2     BCNT (payload length, big-endian)
7       57    DATA (first chunk of payload, zero-padded to 57)
---
Total: 64 bytes
```

**Continuation packet** (subsequent packets):
```
Offset  Size  Field
0       4     CID (same as init packet)
4       1     SEQ (sequence number 0..127, bit 7 = 0)
5       59    DATA (next chunk of payload, zero-padded to 59)
---
Total: 64 bytes
```

### Channel State Machine

```
                      +-----------+
              INIT    |           |   timeout
     +--------------->|   IDLE    |<----------+
     |   (broadcast)  |           |           |
     |                +-----+-----+           |
     |                      |                 |
     |               INIT pkt (CMD)           |
     |                      |                 |
     |                      v                 |
     |                +-----+-----+           |
     |                |           |  CANCEL   |
     |                |  BUSY     +---------->+
     |                | (assembling|          |
     |                |  message)  |          |
     |                +-----+-----+          |
     |                      |                |
     |               all CONT pkts           |
     |               received                |
     |                      |                |
     |                      v                |
     |                +-----+-----+          |
     |                | PROCESSING|  CANCEL  |
     |                | (CTAP2 cmd+--------->+
     |                |  running) |          |
     |                +-----+-----+          |
     |                      |                |
     |               response ready          |
     |                      |                |
     |                      v                |
     |                +-----+-----+          |
     |                | RESPONDING|          |
     |                | (sending  |          |
     +<---------------+  packets) |          |
          done        +-----------+          |
                                             |
          30s inactivity on any state -------+
```

### CID Allocation

- Broadcast CID: `0xFFFFFFFF` -- used only for `CTAPHID_INIT`
- On receiving `CTAPHID_INIT` on broadcast, allocate a random non-zero,
  non-broadcast 4-byte CID
- Store in `HashMap<u32, ChannelState>`
- Maximum concurrent channels: 8 (reject with `ERR_CHANNEL_BUSY` after)
- Channel timeout: 30 seconds of inactivity -- reclaim the CID

### CTAPHID Command Dispatch

| Command          | Code | Direction     | Description                        |
|------------------|------|---------------|------------------------------------|
| CTAPHID_PING     | 0x01 | Host <-> Auth | Echo: return same data             |
| CTAPHID_MSG      | 0x03 | Host -> Auth  | U2F/CTAP1 (NOT IMPLEMENTED in MVP) |
| CTAPHID_LOCK     | 0x04 | Host -> Auth  | NOT IMPLEMENTED in MVP             |
| CTAPHID_INIT     | 0x06 | Host <-> Auth | Channel allocation                 |
| CTAPHID_WINK     | 0x08 | Host -> Auth  | No-op (respond OK)                 |
| CTAPHID_CBOR     | 0x10 | Host <-> Auth | CTAP2 CBOR command                 |
| CTAPHID_CANCEL   | 0x11 | Host -> Auth  | Cancel pending operation            |
| CTAPHID_KEEPALIVE| 0x3B | Auth -> Host  | Status during UP wait              |
| CTAPHID_ERROR    | 0x3F | Auth -> Host  | Error notification                 |

CTAPHID_INIT response (17 bytes):
```
Offset  Size  Field
0       8     Nonce (echoed from request)
8       4     Allocated CID
12      1     Protocol version (= 2 for CTAP2)
13      1     Device major version
14      1     Device minor version
15      1     Device build version
16      1     Capabilities flags:
                bit 0: WINK
                bit 2: CBOR (= 1)
                bit 3: NMSG (= 1, no CTAP1 MSG support)
```

### Concurrency Model

The daemon runs a single-threaded event loop for HID I/O. CTAP2 command
processing is dispatched to a `tokio::spawn_blocking` task (because TPM
operations are blocking). During processing:

1. The HID read loop continues running
2. KEEPALIVE packets (status = `PROCESSING` or `UPNEEDED`) are sent every
   100ms on the active channel while a command is in progress
3. CANCEL on the active channel sets an `AtomicBool` cancellation flag that
   the command task checks
4. Messages on other channels are queued (up to 1 pending message per channel)


## 4. CTAP2 Command Flows

### authenticatorMakeCredential (0x01)

```
Input CBOR map:
  0x01: clientDataHash    [32 bytes, required]
  0x02: rp                {id: String, name: Option<String>}
  0x03: user              {id: Bytes, name: Option<String>, displayName: Option<String>}
  0x04: pubKeyCredParams  [{alg: i64, type: "public-key"}, ...]
  0x05: excludeList       [Option<Vec<{type, id}>>]
  0x06: extensions        [Option<Map>]
  0x07: options           [Option<{rk: bool, uv: bool}>]

Flow:
  1. PARSE input CBOR into MakeCredentialRequest struct
     - Reject if any required field missing -> CTAP2_ERR_MISSING_PARAMETER

  2. VALIDATE pubKeyCredParams
     - Scan list for alg = -7 (ES256 / ECDSA-SHA256-P256)
     - If ES256 not found -> CTAP2_ERR_UNSUPPORTED_ALGORITHM

  3. CHECK excludeList
     - For each descriptor in excludeList:
       - Look up credential by ID in store
       - If found and rpId matches:
         - Request UP (user must confirm "you already have a credential for X")
         - Return CTAP2_ERR_CREDENTIAL_EXCLUDED

  4. ENFORCE USER PRESENCE *** CRITICAL SECURITY ***
     - Spawn pinentry with prompt:
       "Register new credential for: {rp.name} ({rp.id})"
     - Start sending KEEPALIVE(status=UPNEEDED) packets every 100ms
     - Block until user confirms or:
       - User cancels in pinentry -> CTAP2_ERR_OPERATION_DENIED
       - CTAPHID_CANCEL received -> CTAP2_ERR_KEEPALIVE_CANCEL
       - Timeout (30 seconds) -> CTAP2_ERR_USER_ACTION_TIMEOUT
     - Stop KEEPALIVE packets
     - Set UP=1 flag

  5. CREATE KEY ON TPM
     - TPM2_Create under primary key:
       - Type: ECC NIST P-256, scheme ECDSA-SHA256
       - Attributes: fixedTPM | fixedParent | sensitiveDataOrigin |
                     userWithAuth | sign | noDA
     - Receive: (tpm2b_private, tpm2b_public)
     - Load key: TPM2_Load -> get transient handle
     - Read public key point (x, y) from tpm2b_public
     - Flush transient handle

  6. GENERATE CREDENTIAL ID
     - credential_id = random 32 bytes (from /dev/urandom via rand crate)
     - This is an opaque identifier; the key blob is stored on disk,
       NOT embedded in the credential ID

  7. STORE CREDENTIAL (if rk=true OR always for passkey support)
     - Build CredentialRecord (see Section 5)
     - Encrypt and write to disk
     - Index by rpIdHash for later lookup

  8. BUILD AUTHENTICATOR DATA
     - rpIdHash:    SHA-256(rp.id)                              [32 bytes]
     - flags:       UP=1, AT=1, UV=0, BE=0, BS=0, ED=0         [1 byte = 0x41]
     - signCount:   0 (new credential, counter starts at 0)     [4 bytes, big-endian]
     - aaguid:      fidorium's AAGUID                           [16 bytes]
     - credIdLen:   length of credential_id                     [2 bytes, big-endian]
     - credId:      credential_id                               [32 bytes]
     - credPubKey:  COSE_Key (EC2, P-256, x, y)                [~77 bytes CBOR]

  9. BUILD ATTESTATION OBJECT
     - fmt:     "packed"
     - attStmt: self-attestation:
       - alg: -7 (ES256)
       - sig: ECDSA signature over (authData || clientDataHash)
              signed by the credential key itself (self-attestation)
     - authData: the bytes from step 8

  10. ENCODE response CBOR map:
      0x01: fmt       (String)
      0x02: authData  (Bytes)
      0x03: attStmt   (Map)

  11. RETURN via CTAPHID_CBOR response
      - Status byte: 0x00 (CTAP2_OK) prepended to CBOR
```

### authenticatorGetAssertion (0x02)

```
Input CBOR map:
  0x01: rpId              [String, required]
  0x02: clientDataHash    [32 bytes, required]
  0x03: allowList         [Option<Vec<{type, id}>>]
  0x04: extensions        [Option<Map>]
  0x05: options           [Option<{up: bool, uv: bool}>]

Flow:
  1. PARSE input CBOR into GetAssertionRequest struct
     - Reject if any required field missing -> CTAP2_ERR_MISSING_PARAMETER

  2. LOCATE CREDENTIALS
     - Compute rpIdHash = SHA-256(rpId)
     - If allowList is present and non-empty:
       - For each descriptor in allowList:
         - Look up credential by credential_id in store
         - If found and rpIdHash matches, add to candidates
       - If no candidates -> CTAP2_ERR_NO_CREDENTIALS
     - Else (discoverable/resident key flow):
       - Look up all credentials where stored rpIdHash == computed rpIdHash
       - If none -> CTAP2_ERR_NO_CREDENTIALS

  3. SELECT CREDENTIAL
     - If exactly 1 candidate: use it
     - If multiple candidates:
       - For MVP: use the most recently created one
       - (Future: authenticatorGetNextAssertion support)
       - Set numberOfCredentials in response

  4. ENFORCE USER PRESENCE *** CRITICAL SECURITY ***
     - Spawn pinentry with prompt:
       "Sign in to: {rpId}\nCredential: {user.name or user.displayName}"
     - Start sending KEEPALIVE(status=UPNEEDED) packets every 100ms
     - Block until user confirms or:
       - User cancels -> CTAP2_ERR_OPERATION_DENIED
       - CTAPHID_CANCEL -> CTAP2_ERR_KEEPALIVE_CANCEL
       - Timeout (30s) -> CTAP2_ERR_USER_ACTION_TIMEOUT
     - Stop KEEPALIVE
     - Set UP=1 flag

  5. INCREMENT COUNTER (TPM NV)
     - TPM2_NV_Increment on global counter NV index
     - TPM2_NV_Read to get new value
     - Truncate to u32 for authenticator data (lower 4 bytes)

  6. LOAD KEY AND SIGN
     - Load credential's key blob: TPM2_Load(primary, private, public)
     - Build authenticator data:
       - rpIdHash                              [32 bytes]
       - flags: UP=1, UV=0                     [1 byte = 0x01]
       - signCount: counter value              [4 bytes, big-endian]
     - Compute signData = authData || clientDataHash
     - TPM2_Sign(key_handle, SHA-256(signData), scheme=ECDSA-SHA256)
     - Convert TPM signature (r, s) to DER-encoded ECDSA signature
     - Flush transient key handle

  7. ENCODE response CBOR map:
     0x01: credential    {type: "public-key", id: credential_id}
     0x02: authData      (Bytes)
     0x03: signature     (Bytes, DER-encoded)
     0x04: user          {id, name, displayName} (only if resident key)
     0x05: numberOfCredentials (only if > 1 candidate)

  8. RETURN via CTAPHID_CBOR response
     - Status byte: 0x00 (CTAP2_OK) prepended to CBOR
```

### authenticatorGetInfo (0x04)

```
No input parameters.

Static response CBOR map:
  0x01: versions         ["FIDO_2_0"]
  0x02: extensions       []  (none for MVP)
  0x03: aaguid           <16-byte fidorium AAGUID>
  0x04: options          {
                           "rk": true,      // resident key support
                           "up": true,      // user presence enforced
                           "uv": false,     // no user verification (no PIN)
                           "plat": false,   // not a platform authenticator
                         }
  0x05: maxMsgSize       1200
  0x06: pinProtocols     []  (no PIN protocol for MVP)
```


## 5. Credential Storage Layout

### Location

```
~/.local/share/fidorium/
  seal_key.blob              -- TPM-sealed AES-256 key material (TPM2B_PRIVATE + TPM2B_PUBLIC)
  counter_initialized        -- Marker file (empty); signals NV counter is defined
  credentials/
    {credential_id_hex}.bin  -- One file per credential, encrypted
```

### Credential File Format

Each `.bin` file is an encrypted blob. The plaintext (before encryption) is
CBOR-encoded with the following schema:

```
CBOR Map {
  "version":        1,                      // u8, schema version
  "credential_id":  Bytes(32),              // the credential ID
  "rp_id":          String,                 // e.g., "github.com"
  "rp_id_hash":     Bytes(32),             // SHA-256(rp_id), precomputed for lookup
  "rp_name":        Option<String>,         // display name, e.g., "GitHub"
  "user_id":        Bytes,                  // opaque user handle from RP
  "user_name":      Option<String>,         // e.g., "alice@example.com"
  "user_display":   Option<String>,         // e.g., "Alice"
  "public_key_x":   Bytes(32),             // EC P-256 public key X coordinate
  "public_key_y":   Bytes(32),             // EC P-256 public key Y coordinate
  "key_private":    Bytes,                 // TPM2B_PRIVATE marshaled
  "key_public":     Bytes,                 // TPM2B_PUBLIC marshaled
  "created_at":     u64,                   // Unix timestamp
  "discoverable":   bool,                  // true if created with rk=true
}
```

### Encryption Scheme

**Method**: AES-256-GCM (via `aes-gcm` crate, software crypto).

**Key derivation**:
1. On first run, create a random 32-byte AES key
2. Seal it to the TPM primary key via `TPM2_Create` with:
   - Type: KEYEDHASH (HMAC key used as opaque sealed data)
   - Attributes: fixedTPM | fixedParent
   - Optional: PCR policy (seal to specific PCR values)
3. Store the sealed blob (TPM2B_PRIVATE + TPM2B_PUBLIC) as `seal_key.blob`
4. On startup, unseal: `TPM2_Load` + `TPM2_Unseal` -> recover 32-byte AES key
5. Hold AES key in memory for the daemon's lifetime

**Per-file encryption**:
- Generate random 12-byte nonce per credential file
- Prepend nonce to ciphertext: `[nonce (12 bytes)][ciphertext][tag (16 bytes)]`
- File = `nonce || AES-256-GCM(key, nonce, plaintext_cbor)`

### Index

On startup, read all `.bin` files in `credentials/`, decrypt, deserialize
the CBOR, and build an in-memory `HashMap<[u8; 32], Vec<CredentialRecord>>`
keyed by `rp_id_hash`. This allows O(1) lookup for both:
- allowList-based lookup (by credential_id via a second `HashMap<[u8; 32], CredentialRecord>`)
- Discoverable credential lookup (by rp_id_hash)


## 6. User Presence Flow

### Pinentry Integration

The `pinentry` crate (v0.5.1) wraps the Assuan protocol. The standard
`pinentry-gtk-2`, `pinentry-qt`, or `pinentry-curses` binary is spawned.

```
Sequence:

  Daemon                          pinentry
    |                                |
    |--- spawn pinentry binary ----->|
    |                                |
    |<-- "OK Pleased to meet you" --|  (Assuan greeting)
    |                                |
    |-- SETTITLE fidorium ---------->|
    |<-- OK -------------------------|
    |                                |
    |-- SETDESC {prompt_text} ------>|
    |<-- OK -------------------------|
    |                                |
    |-- SETPROMPT Confirm ---------->|
    |<-- OK -------------------------|
    |                                |
    |-- CONFIRM -------------------->|
    |                                |  (dialog appears, user sees prompt)
    |         ... waiting ...        |
    |<-- OK -------------------------|  (user clicked OK)
    |   or                           |
    |<-- ERR 83886179 Not confirmed -|  (user clicked Cancel)
    |                                |
    |-- BYE ----------------------->|
    |                                |  (pinentry exits)
```

### Prompt Text

For MakeCredential:
```
Register new passkey

Site: {rp_name} ({rp_id})
Account: {user_display_name or user_name or "(unknown)"}

Press OK to create, or Cancel to deny.
```

For GetAssertion:
```
Sign in with passkey

Site: {rp_id}
Account: {user_display_name or user_name or "(unknown)"}

Press OK to sign in, or Cancel to deny.
```

Account field fallback: `displayName` → `name` → `"(unknown)"`. Many sites
omit `displayName` but provide `name` (typically an email or username).

### Timeout and KEEPALIVE

While waiting for pinentry:

1. A `tokio::spawn` task sends KEEPALIVE packets every 100ms on the
   active CTAPHID channel with status byte:
   - `0x02` = UPNEEDED (user presence needed)

2. The pinentry wait has a 30-second timeout. If the user does not
   respond within 30 seconds, the pinentry process is killed and
   CTAP2_ERR_USER_ACTION_TIMEOUT is returned.

3. If CTAPHID_CANCEL is received during the wait, the daemon:
   - Kills the pinentry process
   - Returns CTAP2_ERR_KEEPALIVE_CANCEL

4. The UP enforcement is unconditional. There is no code path that
   skips pinentry. The `options.up` field from the client is checked,
   but per CTAP2 spec, UP is always enforced for MakeCredential and
   for GetAssertion when up is not explicitly false (and even then,
   the authenticator MAY enforce it -- we always do).


## 7. Dependencies

```toml
[dependencies]
# TPM2 interface
tss-esapi = "8.0.0-alpha.1"          # upgraded from 7.6.0; alpha required to drop
                                     # picky-asn1-x509 (future-incompat warning).
                                     # API changes: resource_handles → reserved_handles,
                                     # .value() → .as_bytes() on all buffer types.

# Virtual HID device
uhid-virt = "0.0.8"                  # latest stable: 0.0.8

# CBOR
ciborium = "0.2"                     # latest stable: 0.2.2
serde = { version = "1", features = ["derive"] }  # latest stable: 1.0.228

# Async runtime
tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time", "signal"] }  # latest stable: 1.49.0

# User presence (pinentry/Assuan)
pinentry = "0.5"                     # latest stable: 0.5.1

# Crypto (software, for non-TPM operations)
sha2 = "0.10"                        # latest stable: 0.10.9
rand = "0.8"                         # pinned to 0.8 for RustCrypto ecosystem compat:
                                     # aes-gcm 0.10 depends on rand_core 0.6.x.
                                     # Upgrade all three together once aes-gcm 0.11 stabilises.
aes-gcm = "0.10"                     # latest stable: 0.10.3

# Error handling
thiserror = "2.0"                    # latest stable: 2.0.18
anyhow = "1"                         # latest stable: 1.0.102

# Logging
tracing = "0.1"                      # latest stable: 0.1.44
tracing-subscriber = { version = "0.3", features = ["env-filter"] }  # latest stable: 0.3.22

# CLI
clap = { version = "4", features = ["derive"] }  # latest stable: 4.5.60

# XDG directories
directories = "6"                    # latest stable: 6.0.0

# Single-instance lock
fd-lock = "4"                        # latest stable: 4.0.4
```

### Dependency Rationale

- **tss-esapi 8.0.0-alpha.1**: Upgraded from 7.6.0 to eliminate the
  `picky-asn1-x509` future-incompatibility compiler warning. The 7.6 branch
  pins `picky-asn1-x509 = "0.12.0"` which uses constructs rejected by future
  Rust. The v8 alpha replaces picky entirely with the RustCrypto `x509-cert`
  ecosystem. Required API migration: `resource_handles``reserved_handles`,
  `.value()``.as_bytes()` on buffer types (`Private`, `EccParameter`,
  `MaxNvBuffer`, `SensitiveData`). Requires system `tpm2-tss` libraries.
  On Gentoo: `app-crypt/tpm2-tss`.
- **uhid-virt**: Synchronous API. We wrap it in `spawn_blocking`. No async
  uhid crate is mature enough.
- **ciborium over serde_cbor**: serde_cbor is unmaintained. ciborium is the
  maintained successor with proper CTAP2 CBOR canonical encoding support.
- **sha2 0.10 (not 0.11-rc)**: Stable release (0.10.9). 0.11.0-rc.5 is
  not yet stable. We only need SHA-256 for rpIdHash.
- **rand 0.8** (pinned): `rand 0.10.0` is now stable (released Feb 2026),
  but `aes-gcm 0.10` depends on `rand_core 0.6.x` (rand 0.8's ecosystem).
  Upgrading rand without also upgrading aes-gcm causes duplicate rand_core.
  Upgrade together once `aes-gcm 0.11` stabilises.
- **pinentry 0.5**: Latest stable (0.5.1). Wraps the Assuan protocol.
  Handles spawning the system pinentry binary, sending commands, and
  parsing responses.
- **fd-lock 4**: Provides `RwLock` over an open file using OS advisory locks
  (`flock`/`LockFileEx`). Used for single-instance enforcement via
  `$XDG_RUNTIME_DIR/fidorium.lock`.
- **directories**: XDG base directory support for
  `~/.local/share/fidorium/`.


## 8. Security Considerations

### TPM Context Thread Safety

`tss_esapi::Context` requires `&mut self` for all operations, so it is
not `Send` or `Sync` by default. Our design:

```
struct TpmContext {
    ctx: Mutex<tss_esapi::Context>,
}
```

All TPM operations go through `tokio::task::spawn_blocking` with the mutex:
```rust
let tpm = tpm_ctx.clone();
tokio::task::spawn_blocking(move || {
    let mut ctx = tpm.ctx.lock().unwrap();
    ctx.create(...)?;
    // ...
}).await?
```

This serializes all TPM access, which is correct: the TPM2 resource manager
(`/dev/tpmrm0`) handles multiplexing at the kernel level, but the ESAPI
context itself is not thread-safe.

### Credential ID Format

The credential ID is an opaque random 32-byte value. It does NOT contain:
- Key material
- RP ID
- Any information about the key

This is a deliberate security choice. In tpm-fido, the credential ID
contained the key handle (TPM2B_PRIVATE + TPM2B_PUBLIC concatenated), which
meant the key blob was sent to the relying party and stored in the clear.
Our design stores key blobs locally, encrypted.

The credential ID is used only as a lookup key into the local credential
store. If the store is deleted, credentials are unrecoverable (by design --
the key blobs are encrypted and sealed to this TPM).

### allowList Handling

For GetAssertion:
1. If `allowList` is present: iterate the list, look up each credential_id
   in the local store. Return matches for this rpId.
2. If `allowList` is absent: this is a discoverable credential (passkey)
   flow. Look up all credentials for the given rpIdHash.

An attacker cannot use allowList to probe for credentials on other rpIds
because we verify rpIdHash matches before considering a credential as a
candidate.

### AAGUID

We generate a fixed AAGUID for fidorium. This is a 16-byte identifier
that is the same for all instances of this authenticator software. It is
NOT a secret.

Proposed AAGUID: Generate once using `uuidgen` and hardcode.

```
// Example (replace with actual generated value before release):
const AAGUID: [u8; 16] = [
    0xf1, 0xd0, 0x21, 0x01,  // "fido" + version hint
    0x00, 0x00,               // reserved
    0x40, 0x00,               // UUID version 4 marker
    0x80, 0x00,               // UUID variant marker
    0x00, 0x00, 0x00, 0x00, 0x00, 0x01  // serial
];
```

### User Presence Enforcement (tpm-fido Fix 1)

The critical bug in tpm-fido was that UP could be silently skipped.
Our mitigation is architectural:

- The `user_presence()` function is the ONLY path to set `flags.UP = 1`
- `user_presence()` ALWAYS spawns pinentry and blocks for user input
- There is no boolean flag, no config option, no code path that sets UP
  without going through pinentry
- MakeCredential and GetAssertion both call the same `user_presence()`
  function before any signing occurs
- The signing functions (`tpm::keys::sign`) require a `UserPresenceProof`
  token type that can only be constructed by `user_presence()` -- this
  is a compile-time guarantee via Rust's type system

```rust
/// Proof that user presence was verified. Cannot be constructed
/// outside the `up` module.
pub struct UserPresenceProof {
    _private: (),  // prevents construction outside this module
}

/// The ONLY way to obtain a UserPresenceProof.
pub async fn require_user_presence(
    prompt: &UpPrompt,
    keepalive_tx: &Sender<KeepaliveStatus>,
    cancel: &AtomicBool,
) -> Result<UserPresenceProof, Ctap2Error> {
    // ... pinentry logic, NO bypass possible ...
    Ok(UserPresenceProof { _private: () })
}

/// Signing requires proof of UP.
pub fn sign(
    ctx: &mut tss_esapi::Context,
    key: &LoadedKey,
    data: &[u8],
    _up: &UserPresenceProof,  // must be provided, cannot be faked
) -> Result<Vec<u8>, TpmError> {
    // ...
}
```

### Monotonic Counters (tpm-fido Fix 2)

tpm-fido used a software counter (a file on disk), which could be rolled
back by restoring the file from a backup. Our counter lives in TPM2 NV
storage, which:

- Cannot be decremented (hardware enforced)
- Survives reboots (non-volatile)
- Cannot be rolled back without physical TPM reset

NV counter setup (first run):
```
TPM2_NV_DefineSpace(
    auth_handle: OWNER,
    auth: empty,
    nv_public: {
        nv_index:    0x01800100,
        name_alg:    SHA256,
        attributes:  AUTHWRITE | AUTHREAD | NT_COUNTER,
        auth_policy: empty,
        data_size:   8,
    }
)
```

### Resident Key Support (tpm-fido Fix 3)

tpm-fido did not support resident keys / passkeys. Our credential store
(Section 5) supports discoverable credentials by storing full credential
metadata on disk, indexed by rpIdHash. When a GetAssertion arrives with
an empty allowList, we perform a resident key lookup.


## 9. Risks and Open Questions

### Risks

| Risk | Severity | Status |
|------|----------|--------|
| TPM NV space exhaustion (counter allocation fails on a TPM with full NV) | High | Open — check available NV space on startup |
| /dev/uhid requires root or specific permissions | High | **Mitigated**`dist/99-fidorium.rules` udev rule; startup diagnostic with actionable message |
| tss-esapi API instability (using 8.0.0-alpha.1) | Medium | Accepted — required to eliminate picky-asn1-x509 warning; API is functional |
| pinentry not installed or not in PATH | Medium | **Mitigated** — startup diagnostic checks pinentry binary, prints `emerge app-crypt/pinentry` hint |
| Browser does not detect UHID device | Medium | **Verified** — tested with Firefox end-to-end |
| Large credential store (1000+ passkeys) slows startup | Low | Open — lazy loading in future; unlikely for personal use |
| Concurrent daemon instances corrupt credential store | Medium | **Mitigated**`fd-lock` advisory lock on `$XDG_RUNTIME_DIR/fidorium.lock` |

### Open Questions

1. **PCR binding policy**: Which PCRs to bind the seal key to? PCR7
   (SecureBoot state) is common, but changes on kernel updates. Should
   this be configurable? **Recommendation**: default to no PCR binding,
   with a `--pcr-bind=7` CLI flag for users who want it.

2. **CTAP2.1+ features**: Should we plan for authenticatorGetNextAssertion
   (for multiple credentials per RP)? **Recommendation**: yes, structure
   GetAssertion to return a "pending assertions" list internally, but only
   implement GetNextAssertion in a future phase.

3. **U2F/CTAP1 backward compatibility**: Some sites still send CTAP1
   commands via CTAPHID_MSG. Should we implement a minimal CTAP1 shim?
   **Recommendation**: defer. Return CTAPHID_ERROR with ERR_INVALID_CMD
   for CTAPHID_MSG. Revisit if real-world breakage is observed.

4. **Attestation format**: Self-attestation ("packed" with self-signed)
   is the simplest. Some RPs may want "none" attestation. Should we
   support both? **Recommendation**: default to "packed" self-attestation.
   Add "none" support (trivial: just omit attStmt fields) if requested.

5. **Multiple TPM devices**: Should we support selecting a TPM device
   other than `/dev/tpmrm0`? **Recommendation**: yes, via `--tpm-device`
   CLI flag, defaulting to `/dev/tpmrm0`.

6. **Daemon lifecycle**: systemd user service? Background process?
   **Recommendation**: run in foreground, provide a systemd user service
   unit file. No daemonization logic in the binary itself.

7. **NV index collision**: The hardcoded NV index `0x01800100` could
   conflict with other applications. **Recommendation**: make it
   configurable via `--nv-index`, default to `0x01800100` which is in
   the owner-defined range (`0x01800000-0x01BFFFFF`).

---

## Implementation Phases

### Phase 1: Skeleton + HID (Complexity: Medium) ✓
- [x] Project structure, all module files with stub types
- [x] HID report descriptor + UHID device creation
- [x] CTAPHID packet parser (init/cont) + channel allocator
- [x] CTAPHID_INIT, PING, ERROR handling
- [x] Integration test: send INIT from host, get CID back

### Phase 2: TPM Foundation (Complexity: High) ✓
- [x] TpmContext wrapper with Mutex
- [x] Primary key creation (deterministic template under Owner)
- [x] Child ECC key creation + signing
- [x] NV counter: define, increment, read
- [x] Seal/unseal for credential store key
- [x] Unit tests (store roundtrip, CBOR parsing, authenticator data layout)

### Phase 3: Credential Store (Complexity: Medium) ✓
- [x] CredentialRecord type + CBOR serialization
- [x] AES-256-GCM encryption/decryption with TPM-sealed key
- [x] Disk I/O: write, read, list, delete credential files
- [x] In-memory index (rpIdHash + credential_id lookups)

### Phase 4: CTAP2 Commands (Complexity: High) ✓
- [x] authenticatorGetInfo (static response)
- [x] authenticatorMakeCredential (full flow with UP)
- [x] authenticatorGetAssertion (full flow with counter)
- [x] User presence via pinentry (with timeout, cancel, KEEPALIVE)
- [x] UserPresenceProof type-safety pattern
- [x] CBOR encoding/decoding for all request/response types

### Phase 5: Integration + Hardening (Complexity: Medium) ✓
- [x] End-to-end test with Firefox (passkey registration + authentication)
- [x] Single-instance lock via fd-lock (`$XDG_RUNTIME_DIR/fidorium.lock`)
- [x] CLI (clap): `--tpm-device`, `--nv-index`, `--pinentry`, `--wipe`, `-v`
- [x] Startup diagnostics: uhid writable, TPM readable, pinentry found
- [x] OpenRC init script + conf.d (`dist/fidorium.initd`, `dist/fidorium.confd`)
- [x] systemd user service unit (`dist/fidorium.service`)
- [x] udev rule (`dist/99-fidorium.rules`)
- [x] Gentoo ebuild (`dist/fidorium-0.1.0.ebuild`)
- [x] Prompt fallback: displayName → name → "(unknown)"

### Deferred
- [ ] `--pcr-bind` flag: TPM PCR policy binding for seal key. Requires
      policy session changes in tss-esapi. Not needed for basic operation.
- [ ] `authenticatorGetNextAssertion`: multiple credentials per RP.
- [ ] CTAP1/U2F backward compatibility via CTAPHID_MSG.