dig-keystore 0.13.0

Encrypted secret-key storage for DIG Network binaries (BLS signing + L1 wallet keys). AES-256-GCM + Argon2id, typed per-scheme magic files, zeroizing memory hygiene.
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
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
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
# dig-keystore — Specification

**Status:** Normative. This document is the authoritative contract for the `dig-keystore`
crate: the on-disk keystore file format (byte level), the public API surface and its
semantics, error behavior, security properties, and conformance requirements. The key
words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY are to be interpreted as described in
RFC 2119.

`dig-keystore` is the encrypted secret-key storage layer for DIG Network binaries. It
provides a typed `Keystore<K: KeyScheme>` over an encrypted blob, an AES-256-GCM +
Argon2id at-rest file format, a pluggable `KeychainBackend` storage abstraction
(`FileBackend`, `MemoryBackend`), a BLS AugScheme signing surface via `SignerHandle<K>`,
and zeroizing memory hygiene on every secret. It is the single audit surface for
secret-key handling in the DIG workspace: every BLS validator key and Chia L1 wallet
master seed handled by DIG code goes through this crate.

---

## 1. Scope

**In scope**

- The v1 keystore file format (`FORMAT_VERSION 0x0001`) — §3.
- Key derivation: Argon2id (RFC 9106) — §4.
- Encryption: AES-256-GCM (RFC 5116 / NIST SP 800-38D) with header-as-AAD binding — §5.
- Key schemes: `BlsSigning` (`DIGVK1`) and `L1WalletBls` (`DIGLW1`), both BLS12-381 via
  `chia-bls` — §6.
- The `Keystore<K>` lifecycle (create / load / unlock / change_password / rotate_kdf /
  delete) — §7.
- The `SignerHandle<K>` signing surface and its secret-containment rules — §8.
- The `Password` type — §9.
- The `KeychainBackend` trait and the shipped `FileBackend` / `MemoryBackend` /
  `OsKeychainBackend` (OS credential store, Windows/macOS only) — §10.
- The error catalog — §11.
- Zeroization and other security properties — §12.
- The `opaque` module — arbitrary-length password-sealed secrets, no `KeyScheme` — §15.
- The `dig-keystore-wasm` WebAssembly binding (npm `@dignetwork/dig-keystore-wasm`) — §16.
- The `hardware` module — binding at-rest key material to the OS hardware trusted component — §17.

**Out of scope**

- HD (hierarchical-deterministic) child-key derivation. The keystore stores and
  round-trips the master seed only; wallet layers (e.g. `dig-l1-wallet`) perform
  `m/12381/8444/...` derivation on the exposed seed.
- Password UX (prompts, confirmation loops). Binaries own their CLI.
- Network I/O. The crate performs no I/O beyond the storage backend.
- Hardware *signers* (a device that performs the signature itself, e.g. Ledger/YubiHSM).
  The `KeychainBackend` trait is designed to admit them, but no such backend ships.
  Hardware *binding of the wrapping key* — TPM / Secure Enclave — is in scope and
  specified in §17.

---

## 2. Definitions

| Term | Meaning |
|---|---|
| **Keystore file** | A single encrypted blob in the v1 format of §3, holding exactly one secret. |
| **Scheme** | A `KeyScheme` implementation defining what the stored secret is and how it signs (§6). |
| **Secret** | The plaintext bytes protected by the file — for both shipped schemes, a 32-byte seed. |
| **Backend** | A `KeychainBackend` implementation: a byte-blob KV store addressed by `BackendKey`. |
| **Unlock** | Decrypting a keystore file with a password, yielding a `SignerHandle`. |

---

## 3. File format v1 (normative, byte level)

Every keystore file MUST have the following layout. All multi-byte integers are
**big-endian**. There is no compression and no padding.

```
Offset  Size  Field            Value / semantics
------  ----  ---------------  --------------------------------------------------
 0       6    MAGIC            b"DIGVK1" (BlsSigning) or b"DIGLW1" (L1WalletBls)
 6       2    FORMAT_VERSION   0x0001 (the only version this spec defines)
 8       2    KEY_SCHEME       0x0001 = BlsSigning
                               0x0002 = (reserved, unimplemented)
                               0x0003 = L1WalletBls
10       1    KDF_ID           0x01 = Argon2id (only assigned value)
11       4    KDF_MEMORY_KIB   u32 Argon2id memory cost in KiB
15       4    KDF_ITERATIONS   u32 Argon2id iteration count
19       1    KDF_LANES        u8  Argon2id parallelism (lanes)
20       1    CIPHER_ID        0x01 = AES-256-GCM (only assigned value)
21      16    SALT             random per file; Argon2id salt
37      12    NONCE            random per file; AES-GCM nonce
49       4    PAYLOAD_LEN      u32 = length of CIPHERTEXT+TAG in bytes
53       N    CIPHERTEXT+TAG   AES-256-GCM(secret) || 16-byte auth tag
53+N     4    CRC32            IEEE CRC-32 over ALL preceding bytes (header + payload)
```

- The fixed header is **53 bytes** (offsets 0–52). The footer is 4 bytes.
- `PAYLOAD_LEN` MUST equal `secret_len + 16` (the AES-GCM tag is a fixed 16 bytes).
  For both shipped schemes (`secret_len = 32`) the payload is 48 bytes and the total
  file size is **105 bytes**.
- The CRC-32 is the IEEE 802.3 polynomial (as computed by `crc32fast::hash`), stored
  big-endian, computed over every byte of the file except the trailing 4.

### 3.1 AAD binding (normative)

The 53 header bytes MUST be supplied as AES-GCM **associated data** (AAD) at encrypt
time, and the exact header bytes read from the file MUST be supplied as AAD at decrypt
time. Consequently any edit to any header field (magic, scheme id, KDF params, salt,
nonce, payload length) invalidates the authentication tag. No separate header MAC
exists or is needed.

### 3.2 Decode procedure (normative order)

A conforming reader MUST process a file in this order and fail with the stated error
(§11) at the first violation:

1. **Length floor.** If the file is shorter than `53 + 16 + 4 = 73` bytes →
   `Truncated`.
2. **CRC-32.** Recompute over `bytes[..len-4]`; compare to the stored footer →
   `CrcMismatch` on disagreement. The CRC is a fast-fail corruption check only; it is
   NOT a security boundary (the AES-GCM tag is).
3. **Magic.** The 6-byte magic MUST be one of the known values (`DIGVK1`, `DIGLW1`) →
   `UnknownMagic` otherwise.
4. **Format version.** MUST be `0x0001``UnsupportedFormat { found }` otherwise.
   (A reader implementing only v1 rejects both older and newer versions; when a future
   version ships, its readers dispatch on this field and continue to accept v1.)
5. **KDF id.** MUST be `0x01``UnsupportedKdf(byte)` otherwise.
6. **Cipher id.** MUST be `0x01``UnsupportedCipher(byte)` otherwise.
7. **Payload bounds.** `53 + PAYLOAD_LEN + 4` MUST NOT exceed the file length →
   `Truncated` otherwise.

Only after these checks MAY the reader run the KDF and attempt decryption. Steps 1–7
run before any cryptography so that garbage input rejects in microseconds instead of
paying the ~0.5 s Argon2id cost.

### 3.3 Scheme/type binding

`Keystore::<K>::load` and `unlock` MUST verify **both** `MAGIC == K::MAGIC` and
`KEY_SCHEME == K::SCHEME_ID`, and fail with `SchemeMismatch` if either disagrees.
Opening a wallet file as a validator key (or vice versa) is a hard error, never a
silent reinterpretation.

### 3.4 Forward compatibility

- New scheme ids, KDF ids, and cipher ids are additive: unassigned values are reserved
  and MUST be rejected by v1 readers with the corresponding `Unsupported*` error.
- `FORMAT_VERSION` is the versioning hinge. A change to the header layout, field
  semantics, or footer REQUIRES a version bump; v1 files remain readable forever by
  later readers.

---

## 4. Key derivation (Argon2id)

The 32-byte AES-256 key MUST be derived as:

```
key = Argon2id(version = 0x13, password, salt = SALT[16], m = KDF_MEMORY_KIB,
               t = KDF_ITERATIONS, p = KDF_LANES, output_len = 32)
```

- Algorithm: **Argon2id**, RFC 9106, algorithm version **0x13**. (Version 0x10 is not
  used.)
- The derivation is deterministic: identical `(password, salt, params)` MUST yield an
  identical key. This is the property that makes `unlock` possible.
- The derived key is held in a `Zeroizing<[u8; 32]>` and wiped on drop.

### 4.1 Parameter validation (normative bounds)

`KdfParams` MUST be validated before every derivation (create and unlock alike). A
conforming implementation rejects, with `InvalidKdfParams`:

| Bound | Rule |
|---|---|
| memory floor | `memory_kib >= 8192` (8 MiB) |
| memory cap | `memory_kib <= 1_048_576` (1 GiB) |
| iterations | `1 <= iterations <= 256` |
| lanes | `1 <= lanes <= 64` |

The caps exist so a hostile header cannot DoS the process with pathological cost
parameters; the floors are cryptographic minima.

### 4.2 Presets

| Preset | memory_kib | iterations | lanes | Use |
|---|---|---|---|---|
| `KdfParams::DEFAULT` (= `Default`) | 65 536 (64 MiB) | 3 | 4 | Recommended default; matches `dig-l1-wallet` (§14) |
| `KdfParams::STRONG` | 262 144 (256 MiB) | 4 | 4 | High-value keys |
| `KdfParams::FAST_TEST` (doc-hidden) | 8 192 (8 MiB) | 1 | 1 | Tests only; MUST NOT be used for real keys |

Parameters are recorded per file in the header, so files created under different
presets coexist and `rotate_kdf` (§7) can migrate between them.

---

## 5. Encryption (AES-256-GCM)

- Cipher: **AES-256-GCM** per RFC 5116 / NIST SP 800-38D, 96-bit (12-byte) nonce,
  128-bit (16-byte) tag. Output layout is `ciphertext || tag` (tag appended).
- The plaintext is the scheme secret (32 bytes for both shipped schemes).
- AAD is the 53-byte header (§3.1).
- **Nonce uniqueness (normative):** every encryption operation — `create`,
  `change_password`, `rotate_kdf` — MUST generate a fresh random salt AND a fresh
  random nonce. A `(key, nonce)` pair is never reused, because the key is re-derived
  from a fresh salt whenever a new nonce is drawn.
- **Failure indistinguishability (normative):** every decryption failure — wrong
  password, tampered ciphertext, tampered header (AAD mismatch), wrong nonce — MUST
  surface as the single error `DecryptFailed`. Implementations MUST NOT distinguish
  the causes at the error level (side-channel hygiene).
- Decrypted plaintext MUST be returned wrapped in `Zeroizing`.

---

## 6. Key schemes

### 6.1 The `KeyScheme` trait (contract)

```rust
pub trait KeyScheme: Send + Sync + 'static {
    type PublicKey: Clone + Debug + Send + Sync;
    type Signature: Clone + Send + Sync;

    const MAGIC: [u8; 6];        // unique per scheme; registered in the format decoder
    const NAME: &'static str;    // human-readable, used in SchemeMismatch errors
    const SCHEME_ID: u16;        // stored in the header
    const SECRET_LEN: usize;     // exact plaintext length

    fn generate<R: RngCore + CryptoRng>(rng: &mut R) -> Zeroizing<Vec<u8>>;
    fn public_key(secret: &[u8]) -> Result<Self::PublicKey>;
    fn sign(secret: &[u8], msg: &[u8]) -> Result<Self::Signature>;
}
```

Implementations MUST obey:

- `public_key` and `sign` are **pure functions** of their inputs — no RNG, no external
  state. Signatures are deterministic given `(secret, msg)`.
- `generate` MUST use the caller-supplied RNG (never a hidden `OsRng`) and MUST return
  exactly `SECRET_LEN` bytes in a `Zeroizing` buffer.
- `public_key` / `sign` MUST return `InvalidPlaintext { expected, got }` (never panic)
  when `secret.len() != SECRET_LEN`.
- `MAGIC` and `SCHEME_ID` MUST be unique across schemes; a new scheme's magic MUST be
  registered in the format decoder's known-magic set.

### 6.2 Shipped schemes

| Scheme | Magic | Scheme id | Secret | PublicKey | Signature |
|---|---|---|---|---|---|
| `BlsSigning` | `DIGVK1` | `0x0001` | 32-byte seed | `chia_bls::PublicKey` (48-byte compressed G1) | `chia_bls::Signature` (96-byte compressed G2) |
| `L1WalletBls` | `DIGLW1` | `0x0003` | 32-byte seed | `chia_bls::PublicKey` | `chia_bls::Signature` |

Scheme id `0x0002` is reserved (a secp256k1 wallet scheme that is not implemented) and
MUST NOT be emitted.

**Secret semantics (normative).** The stored secret is a **seed**, not a curve scalar.
On every use, the BLS secret key is derived as `chia_bls::SecretKey::from_seed(seed)`
(EIP-2333-style master-key derivation as implemented by `chia-bls` 0.36.1; the derivation is byte-identical to the 0.26 line this crate shipped on through v0.9.0, pinned by the `bls_signing_deterministic_pubkey` KAT). Storing the
seed keeps the file interoperable with Chia tooling conventions and lets HD consumers
regenerate the full key tree.

**Signing algorithm (normative).** `sign` delegates to `chia_bls::sign`, which
implements the BLS12-381 **augmented scheme (AUG)** — ciphersuite
`BLS_SIG_BLS12381G2_XMD:SHA-256_SSWU_RO_AUG_` per draft-irtf-cfrg-bls-signature-05: the
signer's public key is prepended to the message before hashing, foreclosing rogue-key
attacks. Signatures produced by this crate verify with `chia_bls::verify(sig, pk, msg)`
and are byte-compatible with Chia's BLS signing (the same primitive used for Chia L1
`AGG_SIG` conditions).

The two schemes are behaviourally identical; they exist as **distinct types** with
distinct magics so that a validator signing key and a wallet master seed can never be
confused at compile time or on disk.

`L1WalletBls` performs **no HD derivation** — `public_key`/`sign` operate on the master
key. Wallet layers obtain the seed via `SignerHandle::expose_secret` (§8) and derive
children themselves.

---

## 7. `Keystore<K>` lifecycle and semantics

`Keystore<K>` holds the backend handle, the `BackendKey`, and the parsed header — never
the plaintext secret. It is `Send + Sync`; the only interior state is a mutex-guarded
cached public key.

```
create(password, secret?) ──► encrypted blob on backend ──► Keystore
load(backend, key)        ──► Keystore  (header validated; NOT decrypted)
unlock(password)          ──► SignerHandle<K>
change_password(old, new)     re-encrypts, same secret, fresh salt+nonce
rotate_kdf(password, params)  re-encrypts, same secret, new KDF cost, fresh salt+nonce
delete(self)              ──► blob removed from backend
```

### 7.1 `create` / `create_with_rng`

- If a blob already exists at the key, `create` MUST fail with `AlreadyExists`  it never silently overwrites an existing key.
- **The refusal MUST be carried by a single `write_new` (normative).** `create`
  MUST NOT probe for existence and then write: the seal between the two calls is
  an Argon2id derivation, and two racers that both observe an absence both write,
  with the loser's blob replacing the winner's. `write_new` makes that state
  unreachable rather than unlikely. Consequently the strength of the guarantee is
  exactly the backend's `write_new_exclusivity`:
  - On an `Atomic` backend (`FileBackend`, `MemoryBackend`) concurrent mints of
    the same key MUST resolve to exactly one winner, every loser receiving
    `AlreadyExists`.
  - On a `BestEffort` backend (`OsKeychainBackend`, whose credential store has no
    create-if-absent primitive) a residual race REMAINS and is not closed by this
    clause. `create` does not refuse to mint there — refusing would make the OS
    credential store unable to hold a keystore at all — so a caller minting
    concurrently against one MUST serialise the mint itself.
- If `plaintext` is supplied, its length MUST equal `K::SECRET_LEN`
  (`InvalidPlaintext` otherwise). If `None`, a fresh secret is generated via
  `K::generate` with `OsRng` (or the supplied RNG in `_with_rng`).
- The public key is derived before writing (validating the secret) and cached.
- Salt and nonce are drawn fresh from the RNG; `payload_len` is finalized **before**
  encryption because the header is AAD.
- `create_with_rng` exists for deterministic test fixtures; production keys MUST use
  a cryptographically secure OS RNG.

### 7.2 `load`

Reads the blob, runs the full decode procedure of §3.2 plus the scheme check of §3.3,
and returns a `Keystore` **without decrypting**. The cached public key starts `None`.

### 7.3 `unlock`

- MUST re-read the blob from the backend on every call (never trusts in-memory state),
  so concurrent `change_password`/`rotate_kdf` by another handle is picked up and any
  external tampering since the last unlock is caught.
- Runs §3.2 + §3.3 checks, derives the key (§4), decrypts (§5), verifies
  `plaintext.len() == K::SECRET_LEN` (`InvalidPlaintext` otherwise — defence in depth),
  derives and caches the public key, and returns a `SignerHandle`.
- Cost is dominated by Argon2id (~0.5 s at default params). Callers that sign
  frequently SHOULD unlock once and share the `SignerHandle` (e.g. in an `Arc`), not
  re-unlock per signature.

### 7.4 `change_password` / `rotate_kdf` (+ `_with_rng` variants)

Both decrypt with the current password, then re-encrypt with a **fresh random salt and
nonce** (so the output ciphertext differs even under an unchanged password), and write
the new file through the backend's atomic write. `change_password` keeps the KDF
params; `rotate_kdf` keeps the password and replaces the params (validated per §4.1).
The secret itself never changes. On success the in-memory header is updated to match
the written file.

### 7.5 Accessors

- `header()` → the parsed `KeystoreHeader` (metadata inspection without a password).
- `path()` → the `BackendKey`.
- `cached_public_key()``Some(pk)` only if this process has created or unlocked the
  keystore; otherwise `None`. Reading the public key from a cold file REQUIRES an
  unlock (the format stores no plaintext public key).
- `Debug` for `Keystore` prints scheme name, path, and KDF params — never key material.

---

## 8. `SignerHandle<K>` — the signing surface

`SignerHandle<K>` owns a `Zeroizing<Vec<u8>>` copy of the decrypted secret plus the
public key derived at unlock time.

| Method | Semantics |
|---|---|
| `public_key() -> &K::PublicKey` | Borrow the cached public key; no crypto cost. |
| `sign(msg: &[u8]) -> K::Signature` | Sign via `K::sign` (BLS AugScheme for shipped schemes). Infallible in practice — the handle's secret length is validated at construction; an internal length error would panic. |
| `try_sign(msg) -> Result<K::Signature>` | Fallible variant surfacing scheme errors instead of panicking. `sign` and `try_sign` MUST produce identical signatures for the same input. |
| `expose_secret() -> &[u8]` | Borrow the raw seed bytes. The **only** secret escape hatch (see below). |

**Secret containment (normative).**

- `SignerHandle` MUST NOT implement `AsRef<[u8]>`, `Deref` to the secret, or any owned
  `into_raw()`-style extractor.
- `expose_secret` exists solely for HD-wallet consumers that need the master seed to
  derive child keys (e.g. `chia_bls::SecretKey::from_seed(handle.expose_secret())` then
  `DerivableKey` derivation). It returns a **borrow** tied to the handle's lifetime —
  the bytes are wiped when the handle drops. Callers MUST NOT copy the bytes into a
  non-zeroizing buffer.
- `Clone` deep-copies the zeroizing buffer; each clone wipes independently on drop and
  produces byte-identical signatures.
- `Debug` MUST redact the secret (it prints `<N bytes zeroized>`); the handle is safe
  to include in `tracing`/log output.
- Drop zeroizes the secret.

---

## 9. `Password`

- Internally `Zeroizing<Vec<u8>>`; the buffer is wiped on drop. `Clone` copies are
  wiped independently.
- Accepts **arbitrary bytes** (not just UTF-8) via `Password::new(impl AsRef<[u8]>)`
  and `From<&str> / From<String> / From<&[u8]> / From<Vec<u8>>`. The owning `From`
  impls (`String`, `Vec<u8>`) move the buffer into the zeroizing wrapper without an
  extra copy. No normalization, trimming, or case-folding is applied — the KDF hashes
  the caller's bytes verbatim.
- Empty passwords are **permitted** by this layer (Argon2id hashes them); rejecting
  them is the calling binary's responsibility.
- `Debug` MUST redact content (prints `Password(<N> bytes)`).
- `strength()` (feature `password-strength` only) returns a zxcvbn score for CLI
  prompts; non-UTF-8 passwords are conservatively scored as the empty string and MUST
  NOT panic. It is a UX aid, not a security guarantee.

---

## 10. Storage backends

### 10.1 `BackendKey`

An opaque `String` newtype addressing one blob within a backend
(`new`, blanket `From<Into<String>>`, `as_str`, `Display`; `Eq + Hash`).

### 10.2 `KeychainBackend` trait (contract)

```rust
pub trait KeychainBackend: Send + Sync + 'static {
    fn read(&self, key: &BackendKey) -> Result<Vec<u8>>;
    fn write(&self, key: &BackendKey, data: &[u8]) -> Result<()>;
    fn delete(&self, key: &BackendKey) -> Result<()>;
    fn list(&self, prefix: &str) -> Result<Vec<BackendKey>>;
    fn exists(&self, key: &BackendKey) -> Result<bool> { /* default via read */ }
    fn write_new(&self, key: &BackendKey, data: &[u8]) -> Result<()>;
    fn write_new_exclusivity(&self) -> Exclusivity { Exclusivity::BestEffort }
}
```

Implementations MUST satisfy:

- **`read` of a missing key** returns `KeystoreError::Backend` wrapping an
  `std::io::Error` of kind `NotFound`. This exact shape is load-bearing: the default
  `exists` and `Keystore::create`'s overwrite guard branch on it. (`Ok(true)` when a
  read succeeds; `Ok(false)` on `NotFound`; any other error propagates.)
- **`exists` is THREE-valued and MUST NOT be collapsed to two (MUST).** `Ok(true)` is
  present, `Ok(false)` is a **confident** absence, and `Err` is **could not determine**.
  An implementation MUST return `Err` when the store could not answer — an unreadable
  parent, a failing mount, an I/O fault — and MUST NOT report such a case as absent.
  A cheaper override inherits this obligation; in particular Rust's `Path::exists()`
  does **not** satisfy it, because it maps every error to `false`.

  The reason is what the answer decides. `Keystore::create` uses `exists` to choose
  whether to **mint**, and `write` replaces, so a spurious `false` does not create a
  harmless duplicate beside the original — it **destroys the original**. Where the blob
  is hardware-wrapped (§17) that destruction is unrecoverable and the resulting error
  cannot name its own cause (§17.5b). Refusing on an unanswerable read is the only
  fail-closed choice.
- **`write` is atomic**: a concurrent reader observes either the old bytes or the new
  bytes in full — never a torn mix. Overwriting an existing key replaces it.
- **`write_new` establishes, never updates.** It MUST store `data` only if nothing is
  stored at `key`, and MUST report a pre-existing record as
  `KeystoreError::AlreadyExists(key)` — distinguishable from an I/O error, so a losing
  racer can **adopt** the record that won rather than only give up. A failed `write_new`
  MUST leave any existing bytes byte-identical. There is deliberately **no default
  implementation**: a default composed of `exists` then `write` would present this
  contract while providing none of it.
- **`write_new_exclusivity` MUST NOT overstate.** `Exclusivity::Atomic` asserts that the
  underlying store creates-if-absent in one indivisible step, so two concurrent
  `write_new` calls cannot both succeed. `Exclusivity::BestEffort` says the backend checks
  then writes. The default is `BestEffort`, so a backend that has not considered the
  question understates rather than overstates.
- **`delete` is idempotent**: deleting an absent key succeeds. Implementations SHOULD
  best-effort overwrite storage before removal.
- **`list(prefix)`** returns keys whose names **start with** `prefix` (strict prefix,
  not substring); order unspecified; empty prefix lists all.

#### 10.2a Coupled records (normative)

Two records are **coupled** when neither is useful without the other: a wrapped blob and
the device key that opens it, a sealed secret and its salt, a payload and its integrity
sidecar.

Written with replace-semantics `write` and no ordering primitive, two concurrent starts
can settle **device key `D_B` beside blob `B_A`** — a key that does not open the blob next
to it. A well-behaved consumer also refuses to re-mint an identity it already has, and
those two individually-correct decisions compose into a state that **can never
self-heal**: the consumer is permanently unable to open its own data, and restarting does
not help.

A consumer storing coupled records **MUST** establish the shared record with `write_new`
and **adopt on `AlreadyExists`**, so exactly one racer creates it and every other seals
under the record that won. The mismatch becomes unreachable rather than unlikely. It
**MUST** first read `write_new_exclusivity`: on a `BestEffort` backend the reasoning does
not hold and the shared record belongs on an `Atomic` one.

Preventing the state matters more than reporting it, because the resulting error
**structurally cannot name its own cause**: once hardware binding is in play a mismatch
surfaces as `HardwareUnwrapFailed`, which §17.5b establishes cannot distinguish a blob
copied to another machine from a device whose key was wiped.

### 10.3 `FileBackend` (feature `file-backend`, on by default)

- Maps `BackendKey``<root>/<key>.dks` (`.dks` = "DIG KeyStore").
- **Lazy root creation:** `FileBackend::new(root)` has no side effects; the root
  directory (and parents) is created on the first `write`, with mode `0700` on Unix.
- **Owner-only enforcement (normative, Unix):** after requesting `0700` on the root
  or `0600` on a blob, the backend MUST re-read the resulting mode and MUST fail with
  `InsecurePermissions` if any group or other bit (`0o077`) remains set. Verifying the
  outcome rather than the `chmod` return value is required because a filesystem
  without POSIX mode support reports a successful `chmod` and changes nothing. The
  check on a blob MUST happen before any ciphertext is written, and a failure MUST
  leave no blob behind. The root check MUST run on **every** `write`, not only on the
  call that creates the root: a root created by an earlier version that requested
  `0700` without verifying it is exactly the one at risk, and it already exists by the
  time any later write reaches it. Where the mode is repairable — on the root or on
  a blob, both of which go through the same request-then-verify step — the backend
  MUST restore the owner-only mode and proceed, reserving `InsecurePermissions` for a
  path whose mode cannot be corrected. Repair is sound only because the mode is
  re-read afterwards; a backend that repairs without verifying has the pre-0.9.0
  defect this clause exists to close.
- **Presence (normative):** `exists` MUST stat the path with a link-preserving stat and
  MUST distinguish `NotFound` (`Ok(false)`) from every other error (`Err`), per §10.2.
  A **dangling symlink** at the key path counts as **present**: something occupies that
  name, and refusing to write over it is the fail-closed reading. `Path::exists()` and
  `Path::try_exists()` both fail this clause — the first maps every error to `false`, the
  second follows the link and reports a dangling one as absent.
- **Exclusive create (normative):** `write_new` MUST open the final path with
  `create_new`, so exclusivity is the OS's and not a check this backend performs, and
  MUST report the resulting `AlreadyExists` as `KeystoreError::AlreadyExists`.
  `write_new_exclusivity` is therefore `Atomic`.

  It deliberately does **not** use tmp + rename: `rename` always replaces, so it cannot
  express "only if absent", and the two guarantees are not simultaneously available
  without a hard link that not every filesystem supports. Exclusivity is the one that
  matters, and the cost is bounded — a crash mid-write leaves a **short file**, which the
  §3.2 decode detects and which is repaired by deleting and retrying, whereas a coupled
  pair that settled mismatched (§10.2a) is neither detectable nor repairable. The blob is
  born owner-only and the mode is verified before any bytes are written, exactly as in
  `write`, and any failure MUST unlink the partial file.
- **Root identity (normative):** the backend MUST inspect the root with a
  link-preserving stat (`lstat`) and MUST fail with `UnsafeRoot` if the configured
  root is a symbolic link, or exists as anything other than a directory. It MUST NOT
  resolve the link and adopt its target. Unlike a permissive mode, this is not
  repaired: a mode is a property of the intended directory that the backend can
  correct and then confirm, whereas a symlink asserts *which* directory the keystore
  is, and adopting it would mean applying the owner-only mode to — and sealing key
  material into — a directory chosen by whoever created the link, since `chmod(2)` and
  `stat(2)` both follow links. A caller that wants the target as its root MUST pass
  the resolved path.
- **Atomic write procedure (normative):** create the sibling
  `<key>.dks.tmp.<random16hex>` with mode `0600` requested in the `open(2)` call
  itself on Unix, so the tmp file never exists under the umask-derived mode → `fsync` the file → `rename`
  onto the final name → on Unix, `fsync` the containing directory. On rename failure
  the tmp file is best-effort unlinked. The tmp-name random suffix is
  non-cryptographic (time × golden-ratio-prime + pid) and exists only to disambiguate
  concurrent writers.
- **Delete (normative):** the presence read MUST use the same link-preserving stat
  as `exists`, and MUST distinguish `NotFound` (an idempotent `Ok(())`) from every
  other error (`Err`). `Path::exists()` fails this clause: `delete` is the
  secure-erase path, so an `Ok(())` derived from an unanswerable stat is a false
  assurance that key material is gone. Otherwise: best-effort single-pass
  zero-overwrite (4 KiB chunks) + `fsync`, then unlink. The zero pass is explicitly
  best-effort — SSD FTLs and CoW filesystems may retain old sectors; operators
  needing stronger guarantees MUST use full-disk encryption.
- **List (normative):** the root presence read MUST use the same link-preserving
  stat. A root that is **confidently absent** (`NotFound`) MUST list as `Ok([])`  a keystore that has not been created yet is legitimately empty — and a root that
  could not be inspected MUST be `Err`, never an empty result: an empty vec is what
  a caller enumerating identities acts on, and "you have no keys" and "I could not
  look" are different statements. Otherwise scans the root, skipping non-`.dks` and
  non-UTF-8 names, returning the extension-stripped stems matching the prefix.
- **Exists:** `lstat`-based override, per the Presence clause above. "Cheap" MUST
  NOT be read as licence for `Path::exists()`; every presence read in this backend
  is three-valued.
- Windows: `std::fs::rename` (`MoveFileExW` + `MOVEFILE_REPLACE_EXISTING`) provides
  old-or-new (never torn) semantics; Unix file permissions do not apply and NTFS ACL
  inheritance governs access. The crate does NOT narrow that inheritance: an explicit
  owner-only DACL requires Win32 FFI, which C-15 (`unsafe_code = "forbid"`) excludes
  from this package. Windows owner-only enforcement is therefore out of scope here and
  belongs in a separate workspace member alongside the hardware providers.

### 10.4 `MemoryBackend` (always available)

A `Mutex<HashMap>`-backed backend compiled unconditionally (since v0.1.2). Legitimate
uses: **scratch backend** for encrypt-to-bytes/decrypt-from-bytes adapters (notably
`dig-l1-wallet`'s encryption helpers, which reuse the full §3 file format in memory),
tests, and doc examples. It MUST NOT be used as durable storage — process exit drops
all state. It satisfies the full §10.2 contract, including the `NotFound` error shape.

`write_new` decides vacancy and inserts inside the map's own lock (the `Entry` API), so
its `write_new_exclusivity` is `Atomic`.

### 10.5 `OsKeychainBackend` (feature `os-keychain`, Windows/macOS only)

`OsKeychainBackend` stores each blob in the host OS credential store — Windows Credential
Manager or macOS Keychain — via the `keyring` crate. It is a **storage location, not an
access-control primitive**.

- The crate's Argon2id + AES-256-GCM sealing (§3–§5) MUST remain the primary access control
  for anything stored here; the OS credential store is defence-in-depth **only**.
- **`write_new` is `BestEffort`, not `Atomic` (normative).** The Windows and macOS
  credential-store APIs expose get and set with no create-if-absent primitive, so the
  vacancy check and the write are separate calls and two concurrent racers can both pass
  the check. This is a limitation of the store rather than a shortcut, and it is reported
  rather than hidden: a consumer relying on `write_new` to make a coupled-record mismatch
  *unreachable* (§10.2a) MUST place the shared record on an `Atomic` backend.
- Callers MUST NOT write an unlock password, passphrase, mnemonic, raw seed, or any other
  plaintext secret to this backend; only blobs this crate has already sealed belong here.
  This is a **caller obligation**. The backend is a byte-blob KV store and does not
  inspect or constrain the payload — it cannot, because `HardwareBoundBackend` (§17.5a)
  legitimately writes unwrapped, non-container bytes through its inner backend on `unbind`
  and on a failed `bind`'s restore.
- **Platform access boundary (normative, differs by platform).** On **macOS** the Keychain
  applies a per-application ACL, but one gated by **user consent**: a different process
  running as the same user triggers an authorization prompt the user may answer "Always
  Allow", and the trusted-application designation rests on a code signature a same-user
  process can generally overwrite. On **Windows**, generic Credential Manager entries are
  protected by DPAPI under the logged-in user's key and are readable by **any process
  running as that user** — a **per-user** boundary, not per-application. Nor is it
  machine-local: entries are written with `CRED_PERSIST_ENTERPRISE`, so on a domain-joined
  host the credential **roams with the user profile**, and the boundary is any process
  running as that user on any machine they roam to. Implementations and consumers MUST NOT
  assume a per-application boundary on Windows, MUST NOT assume the entry stays on the
  machine that wrote it, and MUST NOT treat the macOS ACL as a hard boundary against a
  same-user attacker.
- **Not for a machine/system service.** The OS credential store is released by the **login
  session**. A machine or system service — a node daemon running as SYSTEM or under a
  non-interactive account — has no login session to release it, so such a service MUST NOT
  use this backend; the passphrase-sealed `FileBackend` (§10.3) is the backend for that
  case. This backend targets the user-application case, where secrets live and die with a
  logged-in user.
- **No fallback.** `open` returns `None` when no usable store exists; the crate performs
  **no** fallback. Selecting an alternative backend (§10.3) is entirely the caller's
  responsibility.
- **Excluded targets.** `keyring` compiles only on `target_os = "windows"` /
  `target_os = "macos"`. On Linux and `wasm32` the dependency is never pulled and `open`
  MUST return `None`. Linux's kernel keyutils session keyring is readable by any same-UID
  process and is non-persistent across logout, so it MUST NOT be a custody primary; the
  passphrase-sealed `FileBackend` is the Linux primary.
- **Read is unconditional.** `read` MUST return any previously stored blob
  byte-identically, whatever its prefix (§5.1).
- **Enumeration.** No native enumeration exists; a best-effort reserved index entry powers
  `list` only. It is never authoritative for `read`/`write`/`delete`/`exists`, and is
  written on a private path below the public `write` API — whose reserved-name check
  refuses the index account outright.

---

## 11. Errors

All fallible operations return `Result<T> = Result<T, KeystoreError>`. `KeystoreError`
is `Clone` (the `std::io::Error` is `Arc`-wrapped) so errors can traverse channels.

| Variant | Meaning / when |
|---|---|
| `Backend(Arc<io::Error>)` | Backend I/O failure; preserves the `io::ErrorKind` (see §10.2). |
| `UnknownMagic { saw: [u8; 6] }` | First 6 bytes are not a known magic (§3.2 step 3). |
| `UnsupportedFormat { found: u16 }` | Format version ≠ `0x0001` (step 4). |
| `SchemeMismatch { expected, expected_name, found }` | File's magic/scheme id disagrees with the `K` type parameter (§3.3). |
| `CrcMismatch { stored, computed }` | Footer CRC-32 disagreement (step 2). Corruption/tamper fast-fail, not a security check. |
| `DecryptFailed` | Any AES-GCM authentication failure: wrong password, tampered ciphertext, tampered header/AAD. Deliberately undifferentiated (§5). |
| `InvalidKdfParams(&'static str)` | KDF params outside §4.1 bounds, or the Argon2 backend rejected them. |
| `UnsupportedKdf(u8)` | KDF id ≠ `0x01` (step 5). |
| `UnsupportedCipher(u8)` | Cipher id ≠ `0x01` (step 6). |
| `AlreadyExists(String)` | `create` at an occupied key (§7.1). |
| `InvalidPlaintext { expected, got }` | Secret/plaintext length ≠ `K::SECRET_LEN` (create input, unlock output, or scheme call). |
| `InvalidSeed(String)` | Reserved for schemes with byte-validity constraints; not produced by the shipped BLS schemes. |
| `Truncated { claimed, available }` | File shorter than its own accounting (steps 1 and 7). |

Error `Display` strings MUST NOT contain secret material.

---

## 12. Security properties

| Property | Mechanism | Guarantee level |
|---|---|---|
| Confidentiality at rest | AES-256-GCM under an Argon2id password-derived key | Cryptographic |
| Integrity / tamper evidence | 128-bit AES-GCM tag with the 53-byte header as AAD | Cryptographic |
| Offline brute-force cost | Argon2id ≥ 8 MiB (default 64 MiB / 3 / 4); per-file random 16-byte salt defeats precomputation | Cryptographic (password-strength-dependent) |
| Wrong-password vs tamper indistinguishability | Single `DecryptFailed` for all auth failures | Design invariant |
| Fast-fail on corruption | Outer CRC-32 before any KDF work | Non-security convenience |
| In-memory hygiene | `Zeroizing` on passwords, generated seeds, decrypted plaintexts, derived KDF keys, and the `SignerHandle` secret; wipe on drop | Best effort (see non-guarantees) |
| No secret leakage via `Debug`/logs | Custom redacting `Debug` on `Password` and `SignerHandle`; errors carry no secrets | Design invariant |
| Secret containment | No `AsRef`/`Deref`/`into_raw` on `SignerHandle`; `expose_secret` is the single named, borrow-only escape hatch | Design invariant |
| Crash-safe persistence | tmp + fsync + atomic rename (+ Unix dir fsync) in `FileBackend` | OS-level |
| File-system access control | Unix mode `0700` dir / `0600` files, verified after the request | Unix only; Windows inherits the parent ACL |
| Memory safety | `unsafe_code = "forbid"` crate-wide | Compiler-enforced |

**Non-guarantees (explicit).**

- A process with the same privileges (or root / a debugger / memory-read access) can
  extract an unlocked secret from RAM. No software-only mitigation exists; this crate
  does **not** `mlock`/`VirtualLock` buffers, and the OS may swap them.
- A stolen keystore file plus a weak password is brute-forceable; Argon2id raises the
  cost but cannot rescue a guessable password. Use `KdfParams::STRONG` for high-value
  keys.
- `FileBackend::delete`'s zero-overwrite may not reach physical sectors (SSD FTL, CoW
  filesystems).
- `Zeroizing` is best-effort against compiler optimization and paging.

---

## 13. Public API surface (summary)

Root re-exports (crate `dig-keystore`, importable as `dig_keystore`):

- `Password` — §9.
- `Keystore<K>` (§7), `SignerHandle<K>` (§8), `KeyScheme` and
  `scheme::{BlsSigning, L1WalletBls}` (§6) — all require feature `custody`, §18.
- `KeychainBackend`, `BackendKey`, `Exclusivity`, `MemoryBackend`; `FileBackend` (feature
  `file-backend`); `OsKeychainBackend` (feature `os-keychain`) — §10.
- `KeystoreHeader`, `KdfParams`, `KdfId`, `CipherId`, `FORMAT_VERSION_V1` — §3–4.
- `KeystoreError`, `Result` — §11.
- `bls` module — convenience re-exports of `chia_bls::{sign, verify, PublicKey,
  SecretKey, Signature}` so simple consumers need no direct `chia-bls` dependency.
- `testing` module (feature `testing`) — re-exports `MemoryBackend` and the constant
  `TEST_PASSWORD = "dig-keystore-test-password"` for dependent crates' tests.

- `opaque` module — `seal`, `seal_with_rng`, `open`, `verify_password`, `MAGIC`, `SCHEME_ID`
  — §15.

### 13.1 Feature flags

| Flag | Default | Effect |
|---|---|---|
| `file-backend` | **on** | Ships `FileBackend`. |
| `os-keychain` | off | Ships `OsKeychainBackend` (Windows/macOS only; `open` returns `None` elsewhere). |
| `custody` | off | Ships the user-custody API: `Keystore<K>`, `SignerHandle<K>`, `KeyScheme`, `scheme::{BlsSigning, L1WalletBls}`, and the `scheme` module — §18. |
| `hd-derivation` | off | Ships `SignerHandle::expose_secret` (§8). Implies `custody`. |
| `password-strength` | off | `Password::strength()` via zxcvbn. |
| `testing` | off | `testing` module (`MemoryBackend` re-export + `TEST_PASSWORD`); required by the integration-test suite. |
| `eip2335` | off | **Reserved, no-op** — EIP-2335 import/export is not implemented. |
| `chia-keychain` | off | **Reserved, no-op** — Chia `.keychain` import is not implemented. |

There is no `wasm` feature on THIS package — the WebAssembly binding is a separate sibling
crate/package, `dig-keystore-wasm` (§16), so this crate's own feature set and dependency graph
are completely unaffected by wasm support existing.

### 13.2 Crate lints / MSRV

`unsafe_code = "forbid"`, `missing_docs = "warn"`. MSRV 1.70. License
`Apache-2.0 OR MIT`. This applies to the `dig-keystore` package only; the sibling
`dig-keystore-wasm` package (§16) has its own, looser lint posture because wasm-bindgen's
generated glue code is not `forbid(unsafe_code)`-clean — see §16.1.

---

## 14. Conformance

### 14.1 Cross-repo requirements

| Contract | Must match | Where |
|---|---|---|
| BLS signing algorithm | Chia's BLS12-381 AugScheme (`chia-bls` 0.36.1; `SecretKey::from_seed` + `chia_bls::sign`) — signatures MUST verify with `chia_bls::verify` and interoperate with Chia L1 `AGG_SIG` semantics | §6.2 |
| Default KDF cost | `dig-l1-wallet` uses the same Argon2id 64 MiB / 3 / 4 default, so both crates present a uniform offline-attack cost | §4.2 |
| Keystore byte format | `dig-l1-wallet`'s encrypt/decrypt-bytes helpers wrap `MemoryBackend` and reuse the §3 format verbatim — the bytes they produce are valid keystore files byte-for-byte | §3, §10.4 |
| File format stability | Every v1 file ever written MUST remain readable by all future releases (additive-only evolution; version-dispatched decoding) | §3.4 |

### 14.2 Conformance summary table

| # | Requirement | Spec |
|---|---|---|
| C-1 | File layout exactly per §3: 53-byte BE header, `ciphertext‖tag`, trailing IEEE CRC-32 | §3 |
| C-2 | Header bytes bound as AES-GCM AAD on encrypt and decrypt | §3.1 |
| C-3 | Decode order: length → CRC → magic → version → KDF id → cipher id → payload bounds → crypto | §3.2 |
| C-4 | Magic AND scheme id both checked against `K`; mismatch is a hard error | §3.3 |
| C-5 | Argon2id v0x13, 32-byte output; params validated within §4.1 bounds on every derivation | §4 |
| C-6 | AES-256-GCM, 12-byte nonce, 16-byte appended tag; fresh salt+nonce per encryption | §5 |
| C-7 | All decrypt failures collapse to `DecryptFailed` | §5, §11 |
| C-8 | Secrets are 32-byte seeds; BLS keys via `from_seed`; signing via AugScheme | §6.2 |
| C-9 | `create` never overwrites (`AlreadyExists`); `unlock` re-reads from the backend | §7 |
| C-10 | Password/KDF rotation re-encrypts under fresh salt+nonce, secret unchanged | §7.4 |
| C-11 | `SignerHandle`: no secret extraction except borrow-only `expose_secret`; redacting `Debug`; zeroize on drop | §8 |
| C-12 | `Password`: arbitrary bytes verbatim, zeroizing, redacting `Debug` | §9 |
| C-13 | Backend contract: `NotFound` error shape, atomic write, idempotent delete, strict-prefix list | §10.2 |
| C-14 | `FileBackend`: `<root>/<key>.dks`, lazy 0700 root, 0600 tmp+fsync+rename writes, best-effort zero-wipe delete | §10.3 |
| C-14a | `FileBackend`: owner-only mode is verified after the request; a residual group/other bit fails the write with `InsecurePermissions` before any ciphertext is written (Unix) | §10.3 |
| C-15 | No `unsafe` code anywhere in the crate | §13.2 |
| C-14b | `FileBackend`: a symlinked or non-directory root is refused with `UnsafeRoot` and neither chmodded nor written into; a repairable mode is restored and verified instead of refused | §10.3 |
| C-16 | CI gates: `cargo fmt --check`, `clippy -D warnings`, full test suite under `cargo llvm-cov --all-features --fail-under-lines 80` | repo `.github/workflows/publish.yml` |
| C-16a | CI gates the target-gated code on its own platforms: `clippy -D warnings` and the full test suite on `windows-latest` and `macos-latest`, so `OsKeychainBackend` and any future platform provider are compiled and exercised rather than only declared | repo `.github/workflows/publish.yml` (`platform` job) |

### 14.3 Test evidence

The repository's test suite pins these requirements: header/file round-trip and the
53-byte constant, every decode-order rejection, AAD binding, CRC coverage, KDF
determinism and bounds, wrong-password/tamper behavior (`tests/tamper.rs`,
`tests/wrong_password.rs`), full create→load→unlock→sign round-trips per scheme
(`tests/roundtrip.rs`), deterministic known-answer vectors (`tests/vectors.rs`),
backend contract branches (`tests/keystore_branches.rs`, module tests), and the
no-leak `Debug` impls. A change that alters any behavior in this document MUST come
with a corresponding test change, and format changes MUST keep old fixtures decoding
byte-identically.

---

## 15. `opaque` — arbitrary-length password-sealed secrets

### 15.1 Motivation and scope

`Keystore<K: KeyScheme>` (§7) requires a fixed `K::SECRET_LEN` and a typed public-key
derivation. That fits validator/wallet seeds (always 32 bytes) but not every secret a DIG
binary or browser client needs to protect at rest — e.g. BIP-39 entropy (16/20/24/28 bytes
depending on word count), or any other opaque application blob with no public-key concept.
`opaque` provides bytes-in/bytes-out password sealing for a secret of **any** length
(including zero), reusing the exact same container format as §3 rather than defining a new
one.

`opaque` is the primitive `dig-keystore-wasm` (§16) wraps for the DIG Chrome extension's
vault (dig_ecosystem #147).

### 15.2 Container identity (normative)

A blob produced by `opaque::seal` / `opaque::seal_with_rng` MUST be a byte-for-byte valid §3
container: the same 53-byte header, the same AES-256-GCM ciphertext+tag payload, the same
trailing CRC-32, the same header-as-AAD binding (§3.1). The ONLY distinguishing fields are:

| Field | Value |
|---|---|
| `MAGIC` | `DIGOP1` |
| `SCHEME_ID` | `0x0004` |

`crate::format::is_known_magic` recognizes `DIGOP1` in addition to `DIGVK1`/`DIGLW1`; this
registration is purely additive and changes nothing about how the two `KeyScheme` magics are
recognized or decoded (§5.1 backwards-compat spirit; §3.4).

### 15.3 API (normative)

```rust
pub const MAGIC: [u8; 6] = *b"DIGOP1";
pub const SCHEME_ID: u16 = 0x0004;

pub fn seal(password: &Password, secret: &[u8], kdf_params: KdfParams) -> Result<Vec<u8>>;
pub fn seal_with_rng<R: RngCore + CryptoRng>(
    password: &Password, secret: &[u8], kdf_params: KdfParams, rng: &mut R,
) -> Result<Vec<u8>>;
pub fn open(password: &Password, blob: &[u8]) -> Result<Zeroizing<Vec<u8>>>;
pub fn verify_password(password: &Password, blob: &[u8]) -> bool;
```

- `seal` MUST accept `secret` of any length, including empty, and MUST NOT truncate, pad, or
  otherwise transform it — `open` MUST recover the exact original bytes.
- `seal` uses OS randomness (`rand_core::OsRng`) for the salt + nonce; `seal_with_rng` exists
  for deterministic test fixtures only (production callers MUST use `seal`).
- `open` MUST reject a blob whose `MAGIC`/`SCHEME_ID` are not `DIGOP1`/`0x0004` with
  `KeystoreError::SchemeMismatch` — including a well-formed `DIGVK1`/`DIGLW1` `Keystore<K>`
  file. This is the same type-confusion protection §3.3 gives typed schemes.
- `open` MUST fail with `KeystoreError::DecryptFailed` for a wrong password or any tampering
  (ciphertext or header/AAD), per the same indistinguishability rule as §5.
- `opaque` has no `KeychainBackend` concept — it is pure bytes-in/bytes-out. Callers own
  storage (a file, a database row, `chrome.storage.local`, …) themselves.
- `verify_password` MUST run the full KDF + AEAD verification and return only a `bool`,
  never exposing the secret on success or failure.

### 15.4 Conformance

| # | Requirement |
|---|---|
| O-1 | `opaque::seal` output is a byte-for-byte valid §3 container with `MAGIC=DIGOP1`, `SCHEME_ID=0x0004` |
| O-2 | `seal``open` recovers the exact original secret bytes for any length, including empty |
| O-3 | `open` rejects a `DIGVK1`/`DIGLW1` blob (or any other magic) with `SchemeMismatch` |
| O-4 | `open` collapses wrong-password and tamper failures to `DecryptFailed` (§5) |
| O-5 | `is_known_magic` recognizes `DIGOP1` additively — `DIGVK1`/`DIGLW1` decoding is unchanged |

Test evidence: `src/opaque.rs` unit tests, `tests/opaque_vectors.rs` (public-API-level KAT).

---

## 16. `dig-keystore-wasm` — WebAssembly binding (npm)

### 16.1 Package layout (normative)

The WebAssembly binding is a SEPARATE crate/package, `dig-keystore-wasm`, living at `wasm/`
in this repository as a Cargo workspace member (root `Cargo.toml` gains `[workspace]
members = ["wasm"] default-members = ["."]` — `default-members` keeps every bare `cargo
<cmd>` invocation, including this repo's own CI and `cargo publish`, scoped to the
`dig-keystore` package exactly as before; the wasm crate requires an explicit `-p
dig-keystore-wasm`).

It is NOT a `wasm` feature on the `dig-keystore` package itself. Reason: this package's
`unsafe_code = "forbid"` (§13.2) is a spec-pinned, tested security property (conformance
C-15, "no unsafe code anywhere in the crate"), and wasm-bindgen's generated glue is not
`forbid`-clean. Keeping the binding in a separate package means `dig-keystore` itself stays
byte-for-byte unaffected — no new dependencies, no relaxed lints, no format change — while
`dig-keystore-wasm` is free to carry the wasm-bindgen toolchain's own constraints.

`dig-keystore-wasm` has `crate-type = ["cdylib", "rlib"]`, is `publish = false` on
crates.io, and publishes to npm as `@dignetwork/dig-keystore-wasm` via `wasm-pack build
--target bundler` (git-dep / local-path consumable regardless of npm publish status — see
§16.4).

### 16.2 Exported surface (normative)

| JS export | Signature | Semantics |
|---|---|---|
| `init()` | `() -> void` | Installs a panic hook (feature `console-panic-hook`, default on) so a Rust panic surfaces a real message in the browser/Node console. Optional; idempotent. |
| `seal(password, secret)` | `(string, Uint8Array) -> Uint8Array`, throws | Direct call to `opaque::seal` with `KdfParams::DEFAULT`. `secret` may be any length, including empty. |
| `open(password, blob)` | `(string, Uint8Array) -> Uint8Array`, throws | Direct call to `opaque::open`. Throws (rejects) with the `KeystoreError` `Display` string on wrong password, tampering, or a non-opaque blob (§15.3). |
| `verifyPassword(password, blob)` | `(string, Uint8Array) -> boolean` | Direct call to `opaque::verify_password`. Never throws. |
| `sealStrong(password, secret)` | `(string, Uint8Array) -> Uint8Array`, throws | Direct call to `opaque::seal` with `KdfParams::STRONG` (256 MiB / 4 iterations / 4 lanes) instead of `DEFAULT` — for a caller's high-value-secret option (dig_ecosystem #147 Phase B: the extension's `ARGON2_STRONG` wallet preset). Opened by the SAME `open` as a `seal`-produced blob; the preset is recorded in the blob's own self-describing header, not tracked by the caller. |
| `sealWithSeed(password, secret, seed)` | `(string, Uint8Array, bigint) -> Uint8Array`, throws | **Test/fixture-only.** Deterministic `ChaCha20Rng::seed_from_u64(seed)` seal at `KdfParams::FAST_TEST`, for cross-target KAT proofs only (§16.3). MUST NOT be used to seal a real secret — the RNG is trivially predictable. |

Every real export (`seal`/`sealStrong`/`open`/`verifyPassword`) is a **direct, non-branching** call into
`dig_keystore::opaque` — no wasm-specific crypto logic exists in `dig-keystore-wasm`. There
is deliberately no `KeychainBackend`/`FileBackend`/`MemoryBackend` binding: the file and
OS-keychain backends have no meaning in a browser, and `seal`/`open` are already
bytes-in/bytes-out, so the JS caller owns storage (e.g. `chrome.storage.local`) directly.

Error values thrown from `seal`/`open` are plain strings built from `KeystoreError::Display`
(§11), which never contains secret material or the password.

### 16.3 Native ↔ wasm byte compatibility (normative)

Because `dig-keystore-wasm`'s exports are direct, non-branching calls into `opaque::seal`/
`opaque::open` — the identical Rust source compiled for `wasm32-unknown-unknown` with no
`cfg(target_arch)` fork — a blob sealed on one target MUST open identically on the other.
This is pinned empirically by a shared deterministic known-answer vector (fixed seed,
password, secret) asserted identical in BOTH:

- `tests/opaque_vectors.rs` (native, calls `opaque::seal_with_rng` directly), and
- `wasm/tests/opaque_wasm.rs` (`wasm-bindgen-test`, calls `sealWithSeed`),

using the same concrete RNG (`rand_chacha::ChaCha20Rng`, NOT `rand::StdRng` — a different
algorithm that would silently break the vector despite an identical numeric seed). Both
suites additionally decode the other's fixture: the native suite opens the wasm-shaped hex
constant and vice versa. This is the property Phase B (dig_ecosystem #147 — migrating the
extension's vault) depends on to prove old blobs stay readable across a native/wasm boundary.

### 16.4 Publishing (normative)

- `wasm/package.json` is a private, non-published dev harness (`wasm-pack build`/`test`
  script wrapper). The PUBLISHED package is `wasm/pkg/package.json`, generated by `wasm-pack
  build --target bundler` from `wasm/Cargo.toml`, then rewritten by
  `wasm/scripts/patch-pkg.mjs` to the scoped name `@dignetwork/dig-keystore-wasm` with
  `publishConfig.access = "public"`.
- `.github/workflows/publish-npm.yml` builds + publishes on a `v*` tag push, a published
  GitHub Release, or manual dispatch, authenticating via npm Trusted Publishing (OIDC) — no
  `NPM_TOKEN` secret is used.
- **Known gap (dig_ecosystem #70-adjacent):** npm's trusted-publisher config can only be
  attached to a package that already exists on the registry, so the FIRST publish of this
  brand-new scoped name 404s even with OIDC correctly wired (confirmed: the `v0.2.1`
  `publish-npm` run authenticated fine and still got `404 Not Found - PUT
  .../@dignetwork%2fdig-keystore-wasm`) — an org-admin bootstrap (one manual authenticated
  `npm publish` to create the package) is needed before OIDC publishing can take over. This
  does NOT block consuming `dig-keystore-wasm`: it is buildable and usable as a git/path
  dependency (`wasm-pack build` locally, or vendoring the built `wasm/pkg` output into a
  consuming repo as a local/`file:` dependency) in the interim, the same stopgap
  `@dignetwork/chia-provider` and `@dignetwork/chip35-dl-coin-wasm` use. The dig-chrome-extension
  (dig_ecosystem #147 Phase B) vendors the built `pkg/` output this way.

### 16.5 Conformance

| # | Requirement |
|---|---|
| W-1 | `dig-keystore-wasm` builds cleanly for `wasm32-unknown-unknown` (`cargo clippy -p dig-keystore-wasm --target wasm32-unknown-unknown -- -D warnings`) |
| W-2 | `dig-keystore`'s own build/lints/format/dependency graph are unaffected by `dig-keystore-wasm` existing (§13.1, §13.2) |
| W-3 | `seal`/`sealStrong`/`open`/`verifyPassword` are direct calls into `opaque::*` — no divergent wasm-only crypto path |
| W-4 | The native↔wasm KAT vector (§16.3) matches byte-for-byte in both `tests/opaque_vectors.rs` and `wasm/tests/opaque_wasm.rs` |
| W-5 | `sealWithSeed` is documented test/fixture-only and MUST NOT be reachable from a production seal path |
| W-6 | `sealStrong`-produced blobs round-trip through the same `open` as `seal`-produced blobs (`wasm/tests/opaque_wasm.rs::seal_strong_roundtrip`) |

Test evidence: `wasm/tests/opaque_wasm.rs` (`wasm-bindgen-test`, run via `wasm-pack test
--node`), cross-checked against `tests/opaque_vectors.rs`.


---

## 17. Hardware binding (`hardware`)

The `hardware` module binds a keystore's wrapping key to the host's OS hardware trusted
component, so that a sealed blob copied to another machine cannot be opened. It is a
**tier above** the §3 file format, never a replacement for it.

### 17.1 Invariants (normative)

- **The §3 passphrase envelope is the FLOOR.** Hardware wrapping MUST be applied to an
  already-sealed §3 blob. An implementation MUST NOT store a secret protected by hardware
  alone. On a host with no usable hardware, the stored bytes MUST be the §3 blob
  **verbatim** — never a bare secret, and never a re-encoding.
- **The §3 format is unchanged.** No field is added, removed, renumbered, or repurposed.
  Hardware wrapping is an outer envelope (§17.3) around the §3 bytes.
- **Readers MUST accept every prior shape.** A stored blob that does not carry the §17.3
  envelope magic MUST be returned to the caller untouched. This rule is stated over the
  whole class of non-envelope prefixes — including inner magics a build does not
  recognise — so a future inner format remains readable.
- **The reported tier MUST be truthful, and it MUST be reported PER BLOB.** Two distinct
  questions exist and an implementation MUST expose both, separately:
  - **Host capability** — the tier every *newly written* blob will receive (`tier()`).
  - **Stored key material** — what protects the blob at a given key (`blob_tier(key)`),
    determined **from the stored bytes**, not from host capability.

  These answers MUST be allowed to disagree, because on a hardware-capable host a keystore
  written before hardware binding existed is still protected by the passphrase envelope
  alone — and *does* open on another machine. **A caller that tells a user a specific key
  is hardware-protected MUST use the per-blob answer.** Quoting host capability would
  claim copy-resistance the key does not have, which could lead a user to guard the file
  less carefully or choose a weaker passphrase. A capable host MUST NOT be treated as
  retroactively protecting bytes already at rest; only rewriting the blob binds it.

  An implementation MUST NOT report a hardware tier it has not verified. A software tier
  MUST carry a reason distinguishing, at minimum, "no hardware present", "could not
  determine whether hardware is present", and "this blob is not wrapped".
- **The wrapping key MUST be non-exportable.** A provider MUST NOT report
  `KeyCustody::NonExportable` unless the key was created non-exportable in the hardware
  component and the platform refuses an export attempt. A provider whose key exists in
  process memory MUST NOT be treated as a hardware tier.

### 17.2 Tier resolution (normative order)

Resolution happens **once**, when the backend is constructed, so the tier is a settled fact
rather than a failure surfacing mid-`unlock`. A conforming implementation MUST:

1. With no provider, resolve `Software(NotRequested)`.
2. Otherwise probe the host. The probe MUST return one of three answers: `Available(kind)`,
   `Absent` (a *confident* negative), or `Indeterminate` (the inspection itself failed — an
   error, a timeout, or an empty or unintelligible response). A probe MUST NOT report
   `Absent` when it means `Indeterminate`.
3. On `Available(kind)`, **verify the claim by use** before reporting a hardware tier. A
   probe is a claim, not a proof. Verification MUST reject: a provider whose declared kind
   disagrees with the probed kind; a provider whose custody is not `NonExportable`; a wrap
   that fails, returns nothing, or returns the content key verbatim; and an unwrap that
   fails or does not reproduce the wrapped key. Any rejection yields `HardwareUnusable`,
   never a hardware tier.
4. Apply the policy to any negative outcome:

| Policy | `Absent` | `Indeterminate` | `HardwareUnusable` | No provider |
|---|---|---|---|---|
| `Required` | error | error | error | error |
| `Preferred` (default) | degrade | **error (fail closed)** | degrade | degrade |
| `Optional` | degrade | degrade | degrade | degrade |

`Preferred` MUST fail closed on `Indeterminate`: silently downgrading "could not determine"
into "there is none" would strip hardware protection from a machine that has it on nothing
more than a transient probe failure, and the resulting software blob would then be openable
anywhere.

### 17.3 Envelope format v1 (normative, byte level)

All multi-byte integers are big-endian. There is no compression and no padding.

```
Offset      Size  Field          Value / semantics
------      ----  -------------  ------------------------------------------------
 0           6    MAGIC          b"DIGHW1"
 6           2    ENV_VERSION    0x0001 (the only version this spec defines)
 8           1    HW_KIND        0x01 = Windows TPM 2.0 (CNG Platform Crypto Provider)
                                 0x02 = Apple Secure Enclave
                                 0x03 = Linux TPM 2.0
 9           1    CIPHER_ID      0x01 = AES-256-GCM (only assigned value)
10          12    NONCE          random per write; AES-GCM nonce
22           2    WRAPPED_LEN    u16 = W, length of WRAPPED_KEY; MUST be non-zero
24           4    PAYLOAD_LEN    u32 = P, length of PAYLOAD
28           W    WRAPPED_KEY    the 32-byte content key, encrypted to the hardware key
28+W         P    PAYLOAD        AES-256-GCM(the §3 blob) || 16-byte tag
28+W+P       4    CRC32          IEEE CRC-32 over ALL preceding bytes
```

- The fixed header is **28 bytes**. `HW_KIND` values are **append-only**: an id MUST NOT be
  renumbered or repurposed, because it is recorded in permanent at-rest data.
- An unassigned `HW_KIND` MUST NOT be silently defaulted to a known kind. It is a
  forward-compatibility case — a blob sealed by hardware this build cannot name — and is
  reported as unopenable here, not as corruption.
- **AAD binding.** Bytes `0..28+W` (the fixed header *and* `WRAPPED_KEY`) MUST be supplied
  as AES-GCM associated data at encrypt time, and the exact bytes read MUST be replayed at
  decrypt time. Relabelling `HW_KIND`, editing a length, or substituting another machine's
  wrapped key therefore invalidates the tag. No separate header MAC exists or is needed.
- **CRC-32** is a fast-fail corruption check only; the AES-GCM tag is the security boundary.
- `WRAPPED_LEN = 0` MUST be rejected: an envelope with no wrapped key asserts that no
  hardware key protects it, and MUST NOT decode as a hardware envelope.

### 17.4 Read and write behaviour (normative)

- **Write, hardware tier.** Generate a fresh random 32-byte content key and nonce per write,
  wrap the content key with the hardware key, and encode §17.3 around the §3 blob.
- **Write, software tier.** Store the §3 blob unchanged.
- **Read, no envelope prefix.** Return the bytes untouched (§17.1).
- **Read, envelope present, hardware tier.** Decode structurally (length floor, CRC, magic,
  version, cipher id, non-zero `WRAPPED_LEN`, declared-length agreement) before any hardware
  round-trip, then require `HW_KIND` to match this host's component, then unwrap and decrypt.
- **Read, envelope present, software tier.** MUST fail with a distinct error, and MUST NOT
  return the envelope bytes: a blob copied to a machine without the sealing hardware must
  fail loudly rather than be mistaken for a corrupt keystore.
- **Failure to unwrap is the guarantee, not a malfunction.** A blob sealed by a *different
  device* of the same class MUST fail to unwrap; a blob sealed by a *different class* of
  component MUST be refused distinctly, since that is a migration case rather than a
  copied-blob case.
- **Three failure classes MUST stay distinct**, because they are different user stories:
  a **hardware refusal** (the blob came from another machine — move the key), a
  **structurally malformed envelope** (the bytes are broken — restore a backup), and an
  **unrecognised hardware class** (a newer writer sealed it — upgrade this build). An
  implementation MUST NOT report a malformed blob or an unnameable class as a hardware
  refusal, or the refusal loses its meaning as the cross-machine guarantee.
- **`blob_tier` MUST fail closed on a blob it cannot fully classify.** A structurally
  invalid envelope, or one recording an unrecognised hardware class, MUST be an error —
  never reported as software-protected. Reporting "software" for a blob that *is* wrapped
  would be a lie in the reassuring direction; reporting "hardware" for a malformed blob
  would be a lie in the dangerous one.

### 17.5 Provider placement

Platform bindings belong outside this package. `unsafe_code = "forbid"` is a spec-pinned
property of this crate (§13.2, C-15), and CNG / Security Framework FFI cannot satisfy it, so
providers MUST be implemented outside it and injected through the `HardwareProvider` trait.
They live in the `dig-keystore-hardware` workspace member under `hardware/`, mirroring the
`wasm/` split (§16).

Provider availability, per platform:

| Platform | Binding | Status |
|---|---|---|
| Windows | TPM 2.0 via the CNG **Microsoft Platform Crypto Provider** | implemented |
| macOS, `aarch64` | Secure Enclave (`kSecAttrTokenIDSecureEnclave`), P-256 + ECIES | implemented |
| macOS, `x86_64` || not implemented; T2 presence is not determinable |
| Linux | TPM 2.0 primary key over the kernel resource manager (`/dev/tpmrm0`) | implemented |

**Probe classification (normative, every provider).** A provider MUST sort each way it can
fail into exactly one of two answers, by this rule:

> the host was inspected and offers no trusted component **this process can use**> `Absent`; the inspection itself could not complete or could not be believed →
> `Indeterminate`.

Both halves are load-bearing. Reporting an inspected non-usability as `Indeterminate` makes an
ordinary host fail closed under any policy stricter than `Optional` and unable to open its own
keystore — the refusal occurs when the backend is CONSTRUCTED, so it gates loading an existing
keystore and not merely minting one — when the correct outcome is a degrade to a fully sealed
software blob. Reporting an inability to inspect as `Absent` is the confident lie about the
machine that the clause below forbids.

**A refusal carrying a RECOGNISED response code is an ANSWER, not a silence (normative).** Where the
platform reports a code the implementation **recognises**, it MUST classify by that code rather
than by the bare fact that a call failed. The qualifier is load-bearing: read without it, this
clause licenses treating *any* response code as a confident answer, which is the silent-downgrade
direction §17.5d forbids — an unrecognised code means the platform said something this build
cannot interpret, which is an uncertainty and not an absence. On Linux the wrapping key is created against the owner hierarchy
with an empty password authorisation, so a hierarchy carrying an authValue
(`tpm2_changeauth -c owner`, enterprise imaging, a Windows install that took ownership), a
disabled hierarchy, or dictionary-attack lockout all answer with a non-zero response code;
each is `Absent`. A transient code — the TPM not yet ready, exhausted object or session memory
— and any unrecognised code remain `Indeterminate`, so the set treated as a confident absence
MUST be enumerated by name and MUST NOT be a range. A format-one response code carries the
offending handle, session or parameter index in bits 8 to 11, and an implementation MUST strip
those before comparing, or the classification will match in a test and never on a device.

**Custody MUST be recorded from a refusal, never from a request (normative).** A provider
claims `NonExportable` only after the platform has refused a real export attempt:
`SecKeyCopyExternalRepresentation` of the private key on macOS; on Linux **both**
`TPM2_ReadPublic` reporting `fixedTPM | fixedParent` in the device's own description of the
key **and** `TPM2_Duplicate` being refused. A refused TPM command is still a well-delivered
response carrying a non-zero `responseCode`, so the refusal MUST be established by parsing
that response — a check of the transport alone reads every refusal as a success, in the
dangerous direction.

**Bounded, named limitation.** The wrap/unwrap path of the macOS and Linux providers has been
proven by CI compilation and by their tests through the `HardwareProvider` seam, **not** on
real silicon: no Mac and no TPM-bearing Linux host was available. The outstanding evidence for
each is a single `cargo run --example tpm-report` on such a host reporting
`probe = Available(...)`, `custody = NonExportable` recorded only after a refused export, and
`rung = hardware-bound`. Until that is produced, a host that reaches rung 1 on either platform
is exercising a path no test has observed; a host that does not is exercising the degrade path,
which is covered.

A caller that supplies no provider resolves `Software(NotRequested)`: honest, and explicitly
not a claim of hardware protection.

**A platform with no binding MUST NOT be reported as `NoHardwarePresent`.** That reason is a
*confident claim about the machine*, and a build carrying no binding for a platform has
inspected no machine on it. Such a platform contributes no candidate and reports
`PlatformUnsupported`, which names the build rather than the hardware. Reporting an
unimplemented platform as an absence is the same defect class as a two-valued existence probe
(§10.2): an unknown asserted as a confident negative.

#### 17.5d The candidate ladder (normative)

A caller MAY offer several candidate providers in preference order. Walking that list MUST:

1. Select the **first** candidate that resolves to a hardware tier under §17.2, and **MUST NOT
   probe any candidate after it**. Probing a trusted component has a side effect — it can
   create a persisted wrapping key — so continuing past a settled answer provisions hardware
   the host has already declined to use.
2. Judge each candidate permissively, so that one candidate's negative outcome yields a
   *reason* rather than aborting the walk. A caller policy stricter than `Optional` MUST NOT
   be applied per candidate: doing so lets an early `Indeterminate` error out while a working
   later candidate sits unexamined, turning a host that genuinely has hardware into one that
   will not open at all.
3. Apply the caller policy **once**, per §17.2's table, to the single reason the walk settles
   on.
4. Settle that reason by this precedence, which is a fail-closed ordering rather than a
   preference: **`ProbeIndeterminate` outranks everything**, then `HardwareUnusable`, then
   `NoHardwarePresent`, and an empty candidate list is `NotRequested`. `NoHardwarePresent` is
   reportable only when **every** candidate agreed on it. A reason this ordering does not
   recognise MUST rank above `NoHardwarePresent`, so an unfamiliar outcome is never summarised
   into a confident negative.
5. Carry the settled reason into the constructed backend. Reducing it to `NotRequested`
   because no provider was ultimately selected reports "nobody asked" on a host that asked,
   looked, and found none.
6. Apply the **caller's** policy to any later re-resolution of the SELECTED provider. Where an
   implementation re-probes and re-runs the self-test so that one authority owns every hardware
   claim (§17.2), that second resolution MUST NOT be given a weaker policy than the caller's.
   It is an independent opportunity to fail — the self-test is a live operation against a
   component that may have become contended since the walk — so a weaker policy makes it fail
   **open**: `Required` would return success over a refuted self-test, and `Preferred` would
   downgrade an uninspectable component into an absence. Applying the policy at both
   resolutions is idempotent, because the walk has already refused every non-hardware settle
   under a strict policy.

The distinction between steps 2 and 6 is deliberate and is not a contradiction. Step 2 governs
the per-candidate SCAN, where strictness would abandon a working candidate for an earlier
one's failure. Step 6 governs the resolution of the candidate that WON, where permissiveness
discards the caller's requirement. The permissive judgement is confined to choosing; the
caller's policy governs every claim that reaches the caller.

**Why a provider's probe classification is a custody-severity decision, in BOTH directions.**
Step 4's precedence is invisible from inside a provider, and it makes each of the two possible
misclassifications severe in an opposite way. Over-classifying as `ProbeIndeterminate` is a
**keystore-load outage**: the reason dominates every other, `Preferred` refuses, and the backend
cannot be constructed at all — so the user cannot open an EXISTING keystore, not merely mint a
new one. Over-classifying as `NoHardwarePresent` is a **silent downgrade**: a host with working
hardware settles at the software tier and reports a confident absence, which is the one claim
nothing downstream re-checks.

Neither direction is the safe default, so the rule between them is not a preference and MUST NOT
be resolved by leaning either way: a **recognised** platform answer is an answer and classifies
confidently; anything else — unrecognised, transient, or a failure with no platform code at all
— fails closed as `ProbeIndeterminate`. This is C-46, and the set a provider treats as a
confident answer is therefore enumerated by name (§17.5), never widened to avoid the outage.

#### 17.5e Non-exportability is asserted, not declared (normative)

A provider MUST NOT report `NonExportable` custody unless the wrapping key was created
non-exportable **and** the platform has actually refused an export attempt. The refusal MUST
be attempted against the platform — a provider that assumes it makes precisely the claim
nothing else in the system re-checks, since §17.2's self-test verifies the wrap round-trip and
takes custody at its word.

An export that **succeeds** is not a warning: the provider MUST then report `ProcessMemory`
custody, which §17.2 rejects as a hardware tier.

#### 17.5f Why `PlatformUnsupported` degrades where `ProbeIndeterminate` errors

Both reasons describe something the implementation does not know, yet `Preferred` opens on the
first and fails closed on the second. This is deliberate.

`ProbeIndeterminate` is a **transient** property of one moment on one machine: the same host may
be inspectable a second later, so degrading on it silently strips hardware protection from a
machine that has it, and the resulting software blob then opens anywhere. `PlatformUnsupported`
is a **deterministic** property of the build and platform. It cannot flicker, so it cannot
transiently remove protection a host previously had — the host never had it in this build. A
`Preferred` caller on such a platform has exactly one honest outcome available, and refusing to
open would deny service permanently rather than fail closed against a real risk.

`Required` MUST still refuse `PlatformUnsupported`, so a caller that genuinely needs hardware is
never told a platform without a binding is good enough.

### 17.5a Binding is REVERSIBLE (normative)

Hardware binding makes the trusted component a **second required factor**. A TPM is cleared
by a firmware update, a mainboard swap or a BIOS reset; a Secure Enclave is lost with the
device. After any of those the correct passphrase is no longer sufficient and the sealed
blob is permanently unopenable — that is what non-exportable custody means, and it MUST NOT
be reachable without a way back that the user can take in advance.

An implementation MUST therefore provide both migrations, and they MUST hold these
properties:

- **`unbind(key)` — down to the portable form.** Rewrites the stored blob as the §3
  passphrase envelope, which a host with no hardware opens. It discloses no secret: those
  are the same bytes the software tier always writes; only cross-machine binding is given
  up. It MUST be possible while the hardware still answers, and there is NO recovery after
  the hardware is gone.
- **`bind(key)` — up to hardware.** Migrates a blob written before hardware binding existed.
  Binding an already-wrapped blob MUST be a no-op: nesting a second envelope yields a blob
  whose unwrap produces another envelope, which nothing opens.
- **Never write before the plaintext is in hand (MUST).** A failed `unbind` MUST leave the
  stored bytes byte-identical, so hardware that returns still finds the blob it sealed.
- **Prove a bind from STORAGE before reporting success (MUST).** `bind` overwrites the only
  copy with bytes only this hardware can open. The implementation MUST re-read the stored
  blob and reopen it through the hardware to the original bytes, and MUST restore the
  previous bytes if it cannot. A success returned over a seal that does not reopen has
  destroyed the key material while reporting that it protected it.
- **Prove an unbind from STORAGE before reporting success (MUST).** A store that accepts a
  write and keeps the old bytes (a full disk, a read-only mount) MUST be reported as
  `HardwareStillBound`, distinctly from any other write error. This is the one message here
  that provokes a destructive follow-on action: a user unbinds in order to retire the
  trusted component, and "unbound" over a still-bound blob is what makes that retirement
  lose the key.

### 17.5b What happens when the hardware goes away (normative)

Three situations end with the same symptom — a blob that no longer opens — and they have opposite
consequences:

| Situation | Consequence |
|---|---|
| **The blob is copied to another machine** (disk moved, image cloned, backup exfiltrated) while the sealing hardware still exists | **Not a defect. This is the property being bought.** Recoverable by returning to the original machine, which still holds the wrapping key. |
| **The TPM is reset** (firmware update, BIOS/UEFI reset, mainboard swap, `Clear-Tpm`) | **Destruction.** The wrapping key is gone, so the blob is permanently unopenable by anyone including its owner. For a wallet seed this is FUNDS LOSS. |
| **The machine is replaced or the device is lost** (hardware failure, theft, decommission) | **Destruction**, identically: the wrapping key never left the old device. |

#### The error does NOT identify which one happened (normative)

An implementation **MUST NOT** infer the situation from the error, and a user-facing surface **MUST
NOT** describe an unwrap failure as recoverable.

This is a structural limit, not an implementation gap. The envelope (§17.3) records a hardware
**class** and nothing else — there is no device identity anywhere in the format — so a blob sealed
by device A and presented to device B is byte-indistinguishable from that same blob presented to
device A *after its key was destroyed*. Both produce `HardwareUnwrapFailed`, and the crate's own
tests pin both halves of that collision:
`a_blob_sealed_by_one_device_cannot_be_opened_by_another` (foreign device) and
`unbind_leaves_the_blob_intact_when_the_hardware_can_no_longer_open_it` (cleared device).

What the three errors DO mean, and what each leaves undetermined:

| Error | Means | Leaves undetermined |
|---|---|---|
| `NotHardwareBound` | This host has no provider at all, so a wrapped blob cannot even be attempted. The ordinary shape of an exfiltrated blob opened on an attacker's machine. | Whether the sealing device still exists. |
| `HardwareKindMismatch` | The blob was sealed by a different hardware **class** than this build binds to (a Secure-Enclave blob on a TPM host). | Which device of that class, and whether it survives. |
| `HardwareUnwrapFailed` | A provider of the right class ran and refused. | **Whether this is a foreign device (recoverable) or the original device with its key destroyed (permanent).** |

Therefore an implementation **MUST**:

- **Report the failure without a recovery promise.** State that this host cannot open the blob and
  that it may be openable on the machine that sealed it *if that machine's trusted component is
  intact* — never that it is recoverable, because the same error is returned when it is not.
- **Treat permanence as the planning assumption.** The distinction is only resolvable out of band, by
  the operator knowing whether the sealing machine still exists and was never cleared. No amount of
  inspection of the blob answers it.
- **Never present a reassuring message on `HardwareUnwrapFailed`.** That is precisely the error the
  irreversible cases produce.

#### There is no recovery after the hardware is gone (normative)

No passphrase, no support path, and no action by this crate recovers a wrapped blob whose wrapping
key no longer exists. The only escape hatch is taken **in advance**, while the hardware still
answers: either [`unbind`](#175a-binding-is-reversible-normative), which returns the stored blob to
the portable §3 passphrase form, or an off-device backup of the underlying secret made before
binding.

- **A consumer that binds a RECOVERY SEED MUST obtain that backup first (MUST).** Binding an identity
  key that can be re-issued is a hardening win. Binding the sole copy of a seed that controls funds
  converts a disk-theft defence into a hardware-failure funds-loss path, because TPMs are cleared and
  mainboards are replaced on otherwise healthy machines. This crate cannot detect which kind of
  secret it was handed, so the obligation sits with the caller and is stated here so no caller has to
  infer it.
- **A surface reporting protection MUST read `blob_tier`, not the host tier.** A capable host does not
  retroactively protect bytes already at rest (§17.1), so the host tier answers a different question
  and using it overstates the protection of an older blob.


### 17.5c What MUST NOT be sealed under the account secret (normative)

Hardware binding is the right answer for the **seed** and the **machine key**. It is the
wrong answer for a **second authentication factor**, and the difference is a matter of
custody rather than strength.

A second factor's whole value is that compromising the first does not yield it. Sealing a
TOTP secret — or any second-factor material — under the **account password** puts both
factors behind **one secret**: an attacker who has the password has both, and the factor
becomes cosmetic while still being reported as a factor. Wrapping that envelope in
hardware raises the bar for an offline attacker without changing this at all, because the
password still opens it on the machine where it is used.

Therefore:

- A second-factor secret **MUST NOT** be sealed under the account password, in this crate
  or any other, whether or not the resulting blob is hardware-bound.
- It belongs under an unlock secret **independent of the account password** — a
  **sealed** container whose key the account password does not yield. Where a future
  design places that container in this crate, the independent unlock secret **MUST** be
  stated normatively at that point; silence is not an acceptable answer, because the
  resulting blob is indistinguishable from a correctly-separated one.
- **Different custody does NOT mean unsealed (MUST).** The OS-native credential store is a
  permitted *location* for such a container and **MUST NOT** be used as a substitute for
  sealing it. §10.5 governs and is not relaxed here: that store is a **storage location,
  not an access-control primitive**, a caller **MUST NOT** write any plaintext secret to
  it, and on Windows an entry is readable by **any process running as that user** and
  roams with the profile under `CRED_PERSIST_ENTERPRISE`.

  Read as permission to store the factor *unsealed*, this clause would be **strictly worse
  than the defect it prevents**: a secret readable today only by someone holding the
  account password becomes readable by any same-user process, with no password at all, on
  every machine the profile roams to. Separation of custody is the requirement; it is
  satisfied by an independent unlock secret, and never by relocation alone.
- The instinct this rule exists to interrupt is the natural reading of "bind at-rest
  secrets to hardware" as "seal everything with dig-keystore". For this one class of
  secret, stronger custody of the **same** secret is a regression, not a hardening win.

This crate cannot enforce the rule — `opaque` seals arbitrary bytes and has no way to
know what it was handed (the same limitation §17.5b records for recovery seeds). The
obligation therefore sits with the caller, and is stated here so no caller has to infer
it.

### 17.6 Conformance additions

| # | Requirement | Spec |
|---|---|---|
| C-17 | Hardware wrapping is applied over a sealed §3 blob; the software tier stores those bytes verbatim | §17.1 |
| C-18 | A blob without the `DIGHW1` prefix — any other prefix, known or not — is returned untouched | §17.1 |
| C-19 | The reported tier is verified by a live wrap/unwrap self-test and a non-exportable custody check, never by the probe alone | §17.2 |
| C-20 | `Absent` and `Indeterminate` are distinct probe answers; `Preferred` fails closed on `Indeterminate` | §17.2 |
| C-21 | Envelope layout exactly per §17.3, header and wrapped key bound as AAD, `WRAPPED_LEN = 0` rejected | §17.3 |
| C-22 | A blob sealed by another device or another hardware class does not open; an envelope on a host with no hardware tier errors rather than returning bytes | §17.4 |
| C-23 | The per-blob tier is determined from the stored bytes and may disagree with host capability; an unwrapped blob on a capable host reports software with reason "blob not wrapped" | §17.1 |
| C-24 | A hardware refusal, a malformed envelope, and an unrecognised hardware class are reported as three distinct errors; `blob_tier` fails closed on the latter two | §17.4 |
| C-25 | `unbind` returns a bound blob to a form a host with no hardware opens; a failed `unbind` leaves the stored bytes byte-identical | §17.5a |
| C-26 | `bind` migrates an unwrapped blob up, is a no-op on an already-wrapped one, and restores the previous bytes when the new seal cannot be reopened from storage | §17.5a |
| C-27 | An `unbind` the store did not take is reported as `HardwareStillBound`, never as success | §17.5a |
| C-34 | The three refusal errors are produced by the paths §17.5b describes: no provider on the host yields `NotHardwareBound`, a different hardware class yields `HardwareKindMismatch`, and a right-class provider that refuses yields `HardwareUnwrapFailed` | §17.5b |
| C-35 | A foreign device and a cleared device both yield `HardwareUnwrapFailed`, so the error alone does not distinguish a recoverable copy from a permanent loss | §17.5b |
| C-29 | `OsKeychainBackend::open` returns `None` on Linux/wasm and the crate performs no fallback | §10.5 |
| C-36 | **Caller obligation** (this crate cannot enforce it — `opaque` seals arbitrary bytes and cannot know what it was handed). A second-factor secret is not sealed under the account password, and is separated by an **independent unlock secret** rather than by relocation to an unsealed store | §17.5c |
| C-37 | `exists` returns `Err`, never `Ok(false)`, when the store could not determine presence — so an unanswerable read never authorises a mint over a replacing `write` | §10.2 |
| C-38 | `write_new` establishes only into a vacant key, reports a collision as the adoptable `AlreadyExists`, and leaves existing bytes byte-identical | §10.2 |
| C-39 | `write_new_exclusivity` reports `Atomic` only where the store creates-if-absent indivisibly; the default is `BestEffort` so silence understates | §10.2, §10.2a |
| C-40 | A platform with no binding contributes no candidate and reports `PlatformUnsupported`, never `NoHardwarePresent` | §17.5 |
| C-41 | The ladder selects the first proven candidate and probes none after it; the caller policy is applied once to a settled reason whose precedence ranks `ProbeIndeterminate` above every confident reason; that reason reaches the constructed backend rather than collapsing to `NotRequested` | §17.5d |
| C-42 | A provider reports `NonExportable` only after the platform has refused a real export attempt; an export that succeeds demotes custody to `ProcessMemory` | §17.5e |
| C-43 | The caller's policy governs EVERY resolution of the selected provider, including a re-resolution performed by the constructed backend; a permissive policy is confined to the per-candidate scan | §17.5d |
| C-44 | `Required` refuses `PlatformUnsupported`; `Preferred` opens on it, because it is deterministic for a build and platform and cannot transiently strip protection | §17.5f |
| C-45 | A recovered content key is held in memory that is wiped on drop, over the full allocated capacity, on every exit path including a wrong-length refusal | §12 |
| C-46 | Every provider classifies an inspected non-usability as `Absent` and an inability to inspect as `Indeterminate`; a wrong-length recovery is a refusal rather than a content key | §17.5 |

Test evidence: `src/hardware/tests.rs` (tier resolution, fail-closed policy, cross-device
binding, envelope codec) and `tests/hardware_v1_compat.rs` (committed golden v1 blobs
decrypted in every tier).

C-43 is evidenced by `hardware/src/tests.rs`'s four-test policy set — two asserting refusal and
two asserting binding, because a refusal-only set is equally satisfied by an implementation that
refuses honest hardware. C-45 is a memory-hygiene property with no direct test: it is enforced by
construction (`Zeroizing` from the point of allocation) rather than asserted, since observing
freed heap from a test is not reliably possible.

C-46 is evidenced in the `hardware/` member by `platform/linux/tests.rs` — which drives all
three classification arms with real directories and a real device file, never a stand-in that
answers TPM commands — and by `platform/content_key.rs`, which pins the length bound from both
sides, since a truncating implementation passes an under-length test and a padding one passes
an over-length test.

C-40..C-42 are evidenced in the `hardware/` member: `src/ladder/tests.rs` (precedence,
fall-through, first-proven selection), `src/tests.rs` (the settled reason surviving the
composition, the floor, cross-device refusal) and `src/platform.rs` (unsupported-platform
reporting) — all of which run on any host, with no trusted component.

`hardware/tests/windows_tpm.rs` carries the assertions that require real silicon. They are
binding only when `DIG_KEYSTORE_REQUIRE_TPM=1`, which makes an absent TPM a failure rather
than a skip; unset, the same tests assert the complementary unbound-host properties and report
which properties were **not** exercised. **A run without that variable is not evidence for
C-42**, and MUST NOT be read as one.

---

## 18. Custody feature tier

### 18.1 What the tier is (normative)

The crate is divided into two tiers.

The **core** tier seals arbitrary bytes under a password and stores them. It has no
notion of *whose* key it is: `opaque` (§15), `Password` (§9), the backends (§10), the
format/KDF/cipher types (§3–4), and hardware binding (§17). It is always compiled.

The **custody** tier models a *user's* identity key: the typed container, the schemes
that give it meaning, and the signer you get by unlocking one. It is compiled ONLY when
the `custody` feature is enabled, which it is NOT by default.

| Feature | Default | Gates |
|---|---|---|
| `custody` | off | `Keystore<K>` (§7), `SignerHandle<K>` (§8), `KeyScheme` and `scheme::{BlsSigning, L1WalletBls}` (§6), and the `scheme` module. Sources live under `src/custody/`; the whole module tree is `#[cfg]`-ed out. |
| `hd-derivation` | off | `SignerHandle::expose_secret` (§8) — and nothing else. Implies `custody`, because the method is on a type that does not exist without it. |

Normative requirements:

- With `custody` off, NONE of the symbols in the table above SHALL be nameable through
  the crate root. Naming one is a compile error (`E0432`, unresolved import).
- With `custody` on and `hd-derivation` off, `SignerHandle::expose_secret` SHALL NOT
  exist. Calling it is a compile error (`E0599`, no such method). Every other
  `SignerHandle` operation — `sign`, `public_key` — remains available.
- Neither feature SHALL change the bytes of any stored blob. The §3 typed-keystore
  layout, the §15 `opaque` layout, and the §17.3 hardware envelope are identical in
  every feature configuration; the features gate an API surface, never a format.
- `opaque` SHALL remain ungated. A build with no custody surface at all MUST still
  decode every blob any prior version wrote (§5.1), including recognising the `DIGVK1`
  and `DIGLW1` magics well enough to reject them as not-`DIGOP1`.

`hd-derivation` is split from `custody` because the overwhelming majority of custody
consumers want to *sign*, not to extract. Only hierarchical-deterministic wallets and
key-derivation libraries need the seed itself, so extraction is a second, separately
named opt-in rather than a capability that arrives with the container.

### 18.2 Why the tier exists

The DIG node engine is identity-agnostic: it seals machine keys and never holds a
user's identity key (dig_ecosystem #908). Splitting the crate along that line lets the
engine depend on the sealing primitives while leaving the user-custody API out of its
build entirely — the boundary is expressed in the dependency graph, where it can be
checked, rather than only in prose.

### 18.3 Limitation — a fail-closed default, NOT an invariant (normative)

**Cargo unifies features across the resolved dependency graph.** If ANY crate anywhere
in a consumer's closure enables `custody`, the custody surface is compiled into that
consumer's build of `dig-keystore` and becomes nameable in the consumer's own code. This
happens with no error, no warning, and no change to the consumer's own manifest.

Therefore this specification claims exactly this and no more:

- Off-by-default means a consumer that does not ask for custody, and whose dependencies
  do not ask for it either, does not get it. That is a **fail-closed default**.
- It is **NOT** a guarantee that a given binary cannot link the custody API. Nothing in
  this crate can provide that guarantee, because the decision is made by feature
  resolution in the consumer's graph, outside this crate.

A consumer that needs the stronger property MUST enforce it on its own side — by
asserting over its resolved graph (for example, failing its build if `cargo tree
-e features` shows `dig-keystore/custody` enabled). For the DIG node that enforcement is
tracked as dig_ecosystem #2177 and is out of scope for this crate.

### 18.4 Conformance additions

| # | Requirement | Spec |
|---|---|---|
| C-30 | With `custody` off, every symbol the feature gates fails to resolve through the crate root (`E0432`), and the core surface still builds | §18.1 |
| C-31 | With `custody` on and `hd-derivation` off, `expose_secret` does not exist (`E0599`) while `sign` and `public_key` do | §18.1 |
| C-32 | `opaque` is ungated: the frozen `DIGOP1` golden vectors decode in a build with no custody surface | §18.1, §5.1 |
| C-33 | The gate claim is proven per-symbol under each feature configuration, each negative case paired with a positive control | §18.1 |

Test evidence: `scripts/surface-check.sh` (C-30, C-31, C-33 — compiles a throwaway
path-dependency probe crate once per symbol per configuration and asserts the exact
rustc error code; run by the `test` CI job) and the `Golden opaque vectors (core-only
build)` CI step running `tests/opaque_vectors.rs` under
`--no-default-features --features file-backend,testing` (C-32).