dig-keystore 0.1.2

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
---
title: dig-keystore — SPEC
status: design spec
last_updated: 2026-04-21
audience: crate implementers, reviewers (security-critical); consumers (apps/validator, apps/wallet, dig-l1-wallet)
authoritative_sources:
  - docs/resources/03-appendices/10-crate-scope-refined.md
  - apps/validator/SPEC.md §11 "Security"
  - apps/ARCHITECTURE.md §7 "Keychain"
  - apps/STAKING.md §11 "Compromise recovery"
---

# dig-keystore — Specification

Encrypted secret-key storage for DIG binaries. Provides:

- On-disk file format for BLS signing keys (`DIGVK1`) and L1 wallet keys (`DIGLW1`).
- AES-256-GCM + Argon2id encryption with memory-hard KDF parameters.
- `Keystore<K>` generic over key scheme, `SignerHandle<K>` that never exposes raw key bytes.
- A `KeychainBackend` trait with `FileBackend` shipped and `OsKeyringBackend` + hardware backends (`LedgerBackend`, `YubiHsmBackend`) as planned future additions.
- `Zeroizing` memory hygiene on every secret.

The crate is the single audit surface for secret-key handling in the DIG workspace. Every BLS or L1 wallet key passed through DIG code goes through this crate.

## Scope

**In scope.**

- Generic `Keystore<K: KeyScheme>` for typed keys (BLS signing, L1 wallet).
- `SignerHandle<K>` that exposes only `sign(msg)` / `public_key()`; never `into_raw()`.
- File format V1: magic prefix + algorithm marker + Argon2id params + salt + ciphertext + tag.
- Encryption: AES-256-GCM (RFC 5116), 96-bit nonce, 128-bit tag.
- Key derivation: Argon2id (RFC 9106), default params 64 MiB / 3 iterations / 4 lanes, configurable per-file.
- Password strength check (optional) via `zxcvbn`.
- Key generation (BLS via `chia-bls::SecretKey::from_seed`, L1 via HD derivation) driven by `KeyScheme::generate`.
- Export / import via EIP-2335 / Chia `.keychain` compatibility shims (feature-gated).
- `KeychainBackend` trait abstraction so a future Phase-2 `OsKeyringBackend` can be slotted in without changing call sites.

**Out of scope.**

- Key generation entropy source beyond calling `OsRng`. Entropy assumptions documented but not enforced.
- Hardware signer implementations (Ledger / YubiHSM) — shape of the backend trait only; full drivers ship later crates.
- Password UX (prompts, confirmation loops). Binaries own their CLI.
- Network operations. `dig-keystore` never touches I/O beyond the file backend.
- Slashing-protection DB. That is `dig-slashing::SlashingProtection` + file I/O in `apps/validator`.

## Placement in the Stack

```
   apps/validator        apps/wallet (future)        dig-l1-wallet
        │                     │                           │
        └──────────┬──────────┴───────────────┬──────────┘
                   ▼                          ▼
                dig-keystore           ← this crate
                   ├── aes-gcm
                   ├── argon2
                   ├── zeroize
                   ├── chia-bls        (BLS key scheme)
                   └── rand_core       (OsRng)
```

## File Format V1

All numbers big-endian. No compression. File is intended to be small (hundreds of bytes) and human-inspectable.

```
┌─────────────────────────────────────────────────────────────┐
│  6 bytes   MAGIC             "DIGVK1" or "DIGLW1"          │
│  2 bytes   FORMAT_VERSION    0x0001                         │
│  2 bytes   KEY_SCHEME        0x0001=BlsSigning              │
│                              0x0002=L1WalletSecp256k1       │
│                              0x0003=L1WalletBls (DIG L1)    │
│  1 byte    KDF_ID            0x01 = Argon2id                │
│  4 bytes   KDF_MEMORY_KIB    u32 (default 65536 = 64 MiB)   │
│  4 bytes   KDF_ITERATIONS    u32 (default 3)                │
│  1 byte    KDF_LANES         u8 (default 4)                 │
│  1 byte    CIPHER_ID         0x01 = AES-256-GCM             │
│ 16 bytes   SALT              random per file                │
│ 12 bytes   NONCE             random per file                │
│  4 bytes   PAYLOAD_LEN       u32                            │
│  N bytes   CIPHERTEXT        AES-256-GCM(plaintext)         │
│ 16 bytes   TAG               AES-GCM auth tag               │
│  4 bytes   CRC32             over all preceding bytes       │
└─────────────────────────────────────────────────────────────┘
```

Plaintext layout (what gets encrypted):

- **BlsSigning.** 32-byte seed. The BLS secret key is derived via `chia_bls::SecretKey::from_seed(seed)` on unlock. This matches Chia's convention of storing the seed, not the curve-scalar, so key shares can be regenerated deterministically.
- **L1WalletSecp256k1.** 32-byte seed + optional 64-byte BIP-39 mnemonic entropy (encoded length-prefixed). HD derivation happens on unlock.
- **L1WalletBls.** 32-byte seed for Chia BLS wallet key (Chia uses BLS for L1 spends).

`MAGIC` signals what the caller should treat the file as. Mixing (using a `DIGVK1` file with `L1WalletScheme`) is a hard error.

`CRC32` is defensive: catches bit-flips before the AES-GCM tag is evaluated, and gives a quick sanity check during recovery flows.

## Public API

### Core types

```rust
/// A key scheme (BLS signing, L1 wallet, etc.). Defines generation + sign.
pub trait KeyScheme: Send + Sync + 'static {
    type PublicKey: Clone + Debug;
    type Signature: Clone;
    type Message<'a>;

    const MAGIC: [u8; 6];
    const SCHEME_ID: u16;

    fn generate<R: RngCore + CryptoRng>(rng: &mut R) -> Plaintext;

    /// Derive PublicKey from the decrypted secret.
    fn public_key(secret: &SecretBytes) -> Self::PublicKey;

    /// Sign a message. Consumes neither the secret nor the message.
    fn sign(secret: &SecretBytes, msg: Self::Message<'_>) -> Self::Signature;
}

/// The three provided schemes.
pub struct BlsSigning;
pub struct L1WalletSecp256k1;
pub struct L1WalletBls;

impl KeyScheme for BlsSigning { /* uses chia-bls */ }
impl KeyScheme for L1WalletBls { /* uses chia-bls for Chia L1 */ }
impl KeyScheme for L1WalletSecp256k1 { /* uses k256 for Ethereum L1 if ever needed */ }
```

```rust
/// An encrypted key on disk. Does not hold any secret material until `unlock`.
pub struct Keystore<K: KeyScheme> {
    backend: Arc<dyn KeychainBackend>,
    path: BackendKey,
    _marker: PhantomData<K>,
}

impl<K: KeyScheme> Keystore<K> {
    /// Create a new keystore. The secret is generated if `plaintext` is None.
    pub fn create(
        backend: Arc<dyn KeychainBackend>,
        path: BackendKey,
        password: Password,
        plaintext: Option<Plaintext>,
        params: KdfParams,
    ) -> Result<Self>;

    /// Load an existing keystore (metadata only; does NOT decrypt).
    pub fn load(backend: Arc<dyn KeychainBackend>, path: BackendKey) -> Result<Self>;

    /// Decrypt + return a SignerHandle. The handle owns a zeroizing copy of the secret.
    pub fn unlock(&self, password: Password) -> Result<SignerHandle<K>>;

    /// Change the encryption password without changing the secret.
    pub fn change_password(&mut self, old: Password, new: Password) -> Result<()>;

    /// Public key is derivable from ciphertext only by unlocking. Convenience:
    /// if callers cache the pubkey separately, they avoid needing the password.
    pub fn cached_public_key(&self) -> Option<K::PublicKey>;

    /// Inspect the header without decrypting (scheme id, KDF params, etc.).
    pub fn header(&self) -> KeystoreHeader;
}
```

### `SignerHandle<K>`

```rust
/// Owns a zeroizing copy of the secret. The secret is wiped on drop.
pub struct SignerHandle<K: KeyScheme> {
    secret: Zeroizing<SecretBytes>,
    public: K::PublicKey,
    _marker: PhantomData<K>,
}

impl<K: KeyScheme> SignerHandle<K> {
    /// Public key derived at unlock time and cached in the handle.
    pub fn public_key(&self) -> &K::PublicKey;

    /// Sign. Cheap; does not re-derive the key.
    pub fn sign(&self, msg: K::Message<'_>) -> K::Signature;
}

// Explicitly NOT:
// impl<K> SignerHandle<K> { pub fn into_raw(self) -> SecretBytes { ... } }
// The secret never leaves the handle except through zeroization.
```

### Password

```rust
/// A password. Uses Zeroizing<Vec<u8>> internally. Consumes ownership at unlock.
#[derive(Clone)]
pub struct Password(Zeroizing<Vec<u8>>);

impl Password {
    pub fn new(bytes: impl AsRef<[u8]>) -> Self;

    /// Optional strength estimate for CLI helpers. Powered by zxcvbn.
    #[cfg(feature = "password-strength")]
    pub fn strength(&self) -> zxcvbn::Entropy;
}
```

### `KdfParams`

```rust
pub struct KdfParams {
    pub id: KdfId,               // Argon2id in v1
    pub memory_kib: u32,         // default 65536
    pub iterations: u32,         // default 3
    pub lanes: u8,               // default 4
}

pub enum KdfId {
    Argon2id,
}

impl KdfParams {
    pub const DEFAULT: Self = Self {
        id: KdfId::Argon2id,
        memory_kib: 65536,       // 64 MiB
        iterations: 3,
        lanes: 4,
    };

    /// Strong params for high-value keys (e.g., treasury).
    pub const STRONG: Self = Self {
        id: KdfId::Argon2id,
        memory_kib: 262144,      // 256 MiB
        iterations: 4,
        lanes: 4,
    };
}
```

Binaries may tune these per-key. File format records params so rotation is possible.

### `KeychainBackend`

```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>>;
}

pub struct BackendKey(pub String);

pub struct FileBackend {
    root: PathBuf,
}

impl FileBackend {
    pub fn new(root: PathBuf) -> Self;
}

impl KeychainBackend for FileBackend {
    fn read(&self, key: &BackendKey) -> Result<Vec<u8>> { /* open + read + close */ }
    fn write(&self, key: &BackendKey, data: &[u8]) -> Result<()> {
        // Atomic write: write to `<path>.tmp`, fsync, rename
    }
    fn delete(&self, key: &BackendKey) -> Result<()> {
        // Best-effort secure delete: overwrite + remove
    }
    // ...
}
```

**Atomicity.** `FileBackend::write` does a tmp-then-rename. On Windows, the rename is `MoveFileExW` with `MOVEFILE_REPLACE_EXISTING | MOVEFILE_WRITE_THROUGH`. On POSIX, `renameat2` with `RENAME_EXCHANGE` when available (gives crash-safe rollback on the previous encrypted file).

**Secure delete.** `FileBackend::delete` overwrites the file with zeros (one pass; modern SSDs make deeper wipes theatre) before unlink. Documented as best-effort.

### Planned future backends

- **`OsKeyringBackend`** — macOS Keychain / Windows Credential Store / freedesktop Secret Service. Same trait; zero code changes at call sites. Phase 2 target.
- **`LedgerBackend`** — communicates with a connected Ledger Nano. The `sign` operation forwards to the device; no `read`/`write` needed (ciphertext never leaves the device). Implemented as a `KeychainBackend` that returns placeholder metadata and a `SignerHandle<K>` that proxies `sign` over USB HID.
- **`YubiHsmBackend`** — same shape as Ledger.

These are documented in the trait docs but not shipped in v1.

### Errors

```rust
#[derive(thiserror::Error, Debug)]
pub enum KeystoreError {
    #[error("backend I/O error: {0}")]
    Backend(#[source] Arc<anyhow::Error>),

    #[error("unknown magic; not a DIG keystore file (saw {saw:?})")]
    UnknownMagic { saw: [u8; 6] },

    #[error("unsupported format version {found}")]
    UnsupportedFormat { found: u16 },

    #[error("key scheme mismatch: expected {expected:?}, file is {found:?}")]
    SchemeMismatch { expected: u16, found: u16 },

    #[error("CRC32 check failed")]
    CrcMismatch,

    #[error("AES-GCM authentication failed (wrong password or tampered file)")]
    DecryptFailed,

    #[error("invalid KDF params: {0}")]
    InvalidKdfParams(&'static str),

    #[error("unsupported KDF id {0}")]
    UnsupportedKdf(u8),

    #[error("unsupported cipher id {0}")]
    UnsupportedCipher(u8),

    #[error("key path already exists: {0:?}")]
    AlreadyExists(BackendKey),
}
```

## Invariants

| ID | Invariant | Enforcer |
|---|---|---|
| KS-001 | `SignerHandle` never exposes raw secret bytes | no `into_raw`, no `AsRef`/`Deref` to secret, `Zeroizing` on drop |
| KS-002 | AES-GCM tag verified before any bytes of plaintext are returned | `aes-gcm::Aead::decrypt` atomic semantics |
| KS-003 | CRC32 checked before AES-GCM attempt; fast-fail on corruption | read path |
| KS-004 | Passwords stored only in `Zeroizing<Vec<u8>>`; never in `String` | `Password` type |
| KS-005 | File writes are atomic (tmp + rename) | `FileBackend::write` |
| KS-006 | `change_password` re-encrypts under a fresh salt + nonce | method impl |
| KS-007 | `Keystore::create` refuses if `backend.read(path)` already succeeds (no accidental overwrite) | explicit check |
| KS-008 | KDF defaults are ≥ 64 MiB memory, ≥ 3 iterations, ≥ 4 lanes | `KdfParams::DEFAULT` constants |
| KS-009 | `KeyScheme::MAGIC` uniquely identifies the scheme on disk | review gate |
| KS-010 | Every panic path in `unlock` is caught and converted to `DecryptFailed` | `catch_unwind` at the boundary |

## Feature Flags

| Flag | Default | Effect |
|---|---|---|
| `file-backend` | on | Ships `FileBackend` |
| `password-strength` | off | Enables `Password::strength` via `zxcvbn` |
| `eip2335` | off | Import/export in Ethereum keystore v4 JSON format (for operators migrating from `ethdo`, `eth2-val-tools`, etc.) |
| `chia-keychain` | off | Import Chia `.keychain` files (seed-based) |
| `testing` | off | Exposes `MemoryBackend` + `TEST_PASSWORD` helpers |

## Dependencies

```toml
[dependencies]
aes-gcm = "0.10"
argon2 = "0.5"
zeroize = { version = "1", features = ["derive"] }
rand_core = "0.6"
rand_chacha = "0.3"             # for deterministic testing
chia-bls = { workspace = true }
crc32fast = "1"
thiserror = "1"
anyhow = "1"

[dependencies.zxcvbn]
version = "3"
optional = true

[dev-dependencies]
proptest = "1"
tempfile = "3"

[features]
default = ["file-backend"]
file-backend = []
password-strength = ["dep:zxcvbn"]
eip2335 = []
chia-keychain = []
testing = []
```

## Consumers

| Consumer | Key schemes used | Notes |
|---|---|---|
| `apps/validator` | `BlsSigning` (DIGVK1) | One keystore file per validator; loaded once at `pre_start`; `SignerHandle` held in `Arc<Validator>` for duty loop |
| `apps/wallet` (future) | `L1WalletBls` (DIGLW1) + possibly `BlsSigning` | Per-account keystore files |
| `dig-l1-wallet` | `L1WalletBls` (DIGLW1) | Currently ships its own ad-hoc key storage; plan is to migrate onto `dig-keystore` for a single audit target |

## Security Properties

| Property | Mechanism |
|---|---|
| Confidentiality | AES-256-GCM over a password-derived key |
| Integrity | AES-GCM auth tag + outer CRC32 |
| Brute-force resistance | Argon2id (memory-hard; GPU/ASIC cost) |
| In-memory hygiene | `Zeroizing` on secret bytes, passwords, and intermediate KDF outputs |
| File-system atomicity | Tmp + rename on write |
| Protection vs swap | Best-effort `mlock` on decrypted buffers (POSIX) / `VirtualLock` on Windows; documented as not guaranteed |

**Non-guarantees (documented explicitly).**

- A compromised process with root or memory-read access can extract the unlocked `SignerHandle` — no software-only mitigation.
- A compromised backup of the keystore file + weak password → brute force possible. Mitigation: `KdfParams::STRONG` for high-value keys.
- The OS may swap `Zeroizing` buffers despite best efforts; use `mlock` / avoid paging file for sensitive workloads.

## Testing Strategy

- **Round-trip tests.** `create → load → unlock → sign` for every `KeyScheme`.
- **Wrong password.** `unlock` fails with `DecryptFailed`; no plaintext leak.
- **Tampered file.** Flip every byte in the ciphertext one-at-a-time; assert `DecryptFailed` (for payload bytes) or `CrcMismatch` (for header bytes).
- **Parameter tampering.** Flip KDF memory / iterations in the header; verify `DecryptFailed` (wrong key derived).
- **Concurrent access.** Two threads unlocking the same file concurrently both succeed; two threads calling `change_password` are serialized by an advisory file lock.
- **Property test.** `proptest` over arbitrary `KdfParams` + passwords verifies encrypt→decrypt round-trip.
- **KATs (known-answer tests).** A fixed-seed RNG + fixed password produces a byte-exact keystore file (`tests/vectors/`). Catches any accidental algorithm change.
- **Fuzzing.** `cargo-fuzz` targets the file parser with arbitrary byte sequences; no panics or segfaults allowed.

## File Layout

```
dig-keystore/
├── Cargo.toml
├── README.md
├── docs/
│   └── resources/
│       └── SPEC.md
├── src/
│   ├── lib.rs
│   ├── keystore.rs            ← Keystore<K>, SignerHandle<K>
│   ├── scheme/
│   │   ├── mod.rs
│   │   ├── bls_signing.rs     ← BlsSigning (DIGVK1)
│   │   ├── l1_bls.rs          ← L1WalletBls (DIGLW1)
│   │   └── l1_secp256k1.rs    ← L1WalletSecp256k1 (future)
│   ├── format.rs              ← file-format v1 encode/decode
│   ├── kdf.rs                 ← Argon2id wrapper
│   ├── cipher.rs              ← AES-256-GCM wrapper
│   ├── password.rs            ← Password type, strength
│   ├── backend/
│   │   ├── mod.rs             ← KeychainBackend trait
│   │   ├── file.rs            ← FileBackend
│   │   └── memory.rs          ← MemoryBackend (feature = "testing")
│   ├── eip2335.rs             ← optional import/export (feature = "eip2335")
│   ├── chia_keychain.rs       ← optional (feature = "chia-keychain")
│   └── error.rs
└── tests/
    ├── roundtrip.rs
    ├── tamper.rs
    ├── wrong_password.rs
    ├── vectors/
    │   ├── bls_signing_v1.bin
    │   └── l1_wallet_bls_v1.bin
    └── vectors.rs             ← KAT harness
```

## Risks & Open Questions

1. **Zeroizing in async context.** `SignerHandle` may live across `await` boundaries; `Zeroizing` drops run when the future is dropped. Confirmed correct by the `zeroize` crate but worth an integration test that cancels a spawn mid-sign.
2. **Password prompts.** Binaries drive prompts. This crate does not include a `read_password_from_tty` helper to keep the dependency list clean. Risk: inconsistent prompt behavior across binaries. Mitigation: a documented snippet in the crate README pointing binaries at `rpassword::read_password`.
3. **KDF upgrade path.** V1 = Argon2id. If a future version wants scrypt or a PHC-newer function, the header's `KDF_ID` field accommodates it; older files continue to work.
4. **Hardware signer stability.** The `KeychainBackend` + `SignerHandle` design allows hardware backends, but the exact trait split (can a hardware backend return a `SignerHandle` that isn't really holding a secret?) is mildly awkward. Decision: introduce a `Signer` trait in v1.1 that `SignerHandle` implements and hardware backends return directly. Defer until a real hardware integration is on the roadmap.
5. **Mlock / VirtualLock.** Pinning decrypted keys in memory is best-effort; on many systems it requires privileges. Document this; do not rely on it for security claims.
6. **Deterministic derivation for tests.** Tests need deterministic BLS key derivation. Provide `rand_chacha::ChaCha20Rng::from_seed(...)` + documented test vectors.
7. **Format migration.** When V2 ships, `Keystore::load` must recognize both. Plan: a top-level `Keystore` enum over versions, routed by `FORMAT_VERSION`. Not implemented in v1.
8. **Constant-time comparisons.** AES-GCM tag comparison is in `aes-gcm`; CRC32 check is non-sensitive (user-observable). OK.
9. **Multi-key files.** EIP-2335 supports a single key per file; we follow. Storing multiple keys per file adds indexing complexity for marginal benefit.

## Authoritative Sources

- [`apps/validator/SPEC.md`]../../../dig-network/apps/validator/SPEC.md §11 "Security"
- [`apps/ARCHITECTURE.md`]../../../dig-network/apps/ARCHITECTURE.md §7 "Keychain"
- [`apps/STAKING.md`]../../../dig-network/apps/STAKING.md §11 "Compromise recovery"
- [`docs/resources/03-appendices/10-crate-scope-refined.md`]../../../dig-network/docs/resources/03-appendices/10-crate-scope-refined.md — rationale
- [`chia-bls` crate]https://crates.io/crates/chia-bls — BLS primitives
- [RFC 9106 — Argon2]https://datatracker.ietf.org/doc/html/rfc9106
- [RFC 5116 — AEAD APIs]https://datatracker.ietf.org/doc/html/rfc5116
- [EIP-2335 — BLS12-381 Keystore]https://eips.ethereum.org/EIPS/eip-2335
- [Chia keychain module]https://github.com/Chia-Network/chia-blockchain/blob/main/chia/util/keychain.py