# dig-keystore
Encrypted secret-key storage for [DIG Network](https://github.com/DIG-Network) binaries. Typed `Keystore<K>`, AES-256-GCM + Argon2id, `Zeroizing` memory hygiene, filesystem-atomic writes. The single audit surface for "where validator and wallet keys live" in the DIG workspace.
Builds on [`chia-bls` 0.26](https://crates.io/crates/chia-bls) for BLS12-381 keys / signatures; see [`CRATES_SUMMARY.md`](../dig-network/CRATES_SUMMARY.md) for the full DIG + Chia stack.
- **Format version:** `0x0001` (see [`docs/resources/SPEC.md`](docs/resources/SPEC.md))
- **Default KDF:** Argon2id, 64 MiB / 3 iterations / 4 lanes
- **Default cipher:** AES-256-GCM (96-bit nonce, 128-bit tag)
- **MSRV:** 1.70
---
## Table of contents
1. [Install](#install)
2. [At-a-glance](#at-a-glance)
3. [Quick reference](#quick-reference)
4. [`Keystore<K>`](#keystorek)
5. [`SignerHandle<K>`](#signerhandlek)
6. [`KeyScheme` trait](#keyscheme-trait)
7. [Shipped schemes](#shipped-schemes)
8. [`KeychainBackend` trait](#keychainbackend-trait)
9. [Shipped backends](#shipped-backends)
10. [`Password`](#password)
11. [`BackendKey`](#backendkey)
12. [`KdfParams`](#kdfparams) / [`KdfId`](#kdfid) / [`CipherId`](#cipherid)
13. [`KeystoreHeader`](#keystoreheader)
14. [`KeystoreError`](#keystoreerror)
15. [`Result<T>`](#resultt)
16. [`bls` re-exports](#bls-re-exports)
17. [`testing` module](#testing-module-feature-testing)
18. [Feature flags](#feature-flags)
19. [File format](#file-format)
20. [Security properties](#security-properties)
21. [Common call sequences](#common-call-sequences)
22. [Performance](#performance)
23. [Testing](#testing)
24. [License](#license)
---
## Install
```toml
[dependencies]
dig-keystore = "0.1"
```
Default features include `file-backend`. Enable `testing` to get `MemoryBackend` and `TEST_PASSWORD` for dependent crates' tests.
---
## At-a-glance
| [`Keystore<K>`](#keystorek) | struct | the encrypted-file handle — `create`, `load`, `unlock`, `change_password`, `rotate_kdf`, `delete` |
| [`SignerHandle<K>`](#signerhandlek) | struct | the unlocked handle — `sign`, `public_key` — never exposes raw secret |
| [`KeyScheme`](#keyscheme-trait) | trait | turn secret bytes into pubkey + signature; defines on-disk magic + scheme id |
| [`BlsSigning`](#blssigning-digvk1) | unit struct impl KeyScheme | DIG L2 validator BLS key (magic `DIGVK1`) |
| [`L1WalletBls`](#l1walletbls-diglw1) | unit struct impl KeyScheme | Chia L1 wallet master seed (magic `DIGLW1`) |
| [`KeychainBackend`](#keychainbackend-trait) | trait | abstract byte-blob KV — `read` / `write` / `delete` / `list` |
| [`FileBackend`](#filebackend) | struct impl KeychainBackend | filesystem with atomic tmp-then-rename writes |
| [`MemoryBackend`](#memorybackend-feature-testing) | struct impl KeychainBackend | in-process `HashMap` (testing only) |
| [`Password`](#password) | struct | `Zeroizing<Vec<u8>>` wrapper; `Debug` never leaks |
| [`BackendKey`](#backendkey) | struct | opaque string key for a blob inside a backend |
| [`KdfParams`](#kdfparams) | struct | Argon2id memory / iterations / lanes |
| [`KeystoreHeader`](#keystoreheader) | struct | parsed 53-byte on-disk header |
| [`KdfId`](#kdfid), [`CipherId`](#cipherid) | enums | algorithm tags in the header |
| [`KeystoreError`](#keystoreerror) | enum | every failure variant |
| [`Result<T>`](#resultt) | alias | `Result<T, KeystoreError>` |
| [`bls::*`](#bls-re-exports) | module | re-exports of `chia_bls::{PublicKey, SecretKey, Signature, sign, verify}` |
---
## Quick reference
```rust,no_run
use std::sync::Arc;
use dig_keystore::{
Keystore, Password, KdfParams,
scheme::BlsSigning,
backend::{FileBackend, BackendKey, KeychainBackend},
};
let backend: Arc<dyn KeychainBackend> = Arc::new(FileBackend::new("/var/dig/keys"));
let key = BackendKey::new("validator");
let password = Password::from("correct horse battery staple");
// Create (generates a fresh 32-byte seed).
let ks = Keystore::<BlsSigning>::create(
backend.clone(),
key.clone(),
password.clone(),
None,
KdfParams::default(),
)?;
// Unlock and sign.
let signer = ks.unlock(password)?;
let signature = signer.sign(b"block-header");
let pubkey = signer.public_key();
// Reload later.
let ks2 = Keystore::<BlsSigning>::load(backend, key)?;
# Ok::<(), dig_keystore::KeystoreError>(())
```
---
## `Keystore<K>`
```rust
pub struct Keystore<K: KeyScheme> { /* … */ }
```
The main type. Parameterised over a [`KeyScheme`] — typically [`BlsSigning`] for validator keys, [`L1WalletBls`] for Chia L1 wallet master seeds. Holds the parsed on-disk header and a handle to its backing store; **never** holds the plaintext secret in memory between `unlock` calls.
### `Keystore::create`
Create a new encrypted keystore blob on the backend.
```rust
pub fn create(
backend: Arc<dyn KeychainBackend>,
path: BackendKey,
password: Password,
plaintext: Option<Zeroizing<Vec<u8>>>,
kdf_params: KdfParams,
) -> Result<Self>
```
**Inputs**
| `backend` | `Arc<dyn KeychainBackend>` | Where the blob lives. Shared — cloning the `Arc` is the normal way to share a backend across threads. |
| `path` | [`BackendKey`] | Blob identifier. Must be filesystem-safe ASCII. |
| `password` | [`Password`] | The unlocking secret. Consumed (so Zeroizing drop fires). Any byte length ≥ 0. |
| `plaintext` | `Option<Zeroizing<Vec<u8>>>` | `Some(bytes)` → encrypt those bytes (must have length `K::SECRET_LEN`). `None` → generate a fresh secret via `K::generate(OsRng)`. |
| `kdf_params` | [`KdfParams`] | Argon2id cost. See [`KdfParams::DEFAULT`] / `STRONG` / `FAST_TEST`. |
**Output:** `Result<Keystore<K>>` — on success, the in-memory handle is populated with the just-written header. [`cached_public_key`](#keystorecached_public_key) is populated as a side-effect.
**Errors** (all `KeystoreError`)
| `AlreadyExists(path)` | A blob already exists at `path`. Refuses to overwrite. |
| `InvalidPlaintext { expected, got }` | Caller supplied `plaintext` of wrong length. |
| `InvalidKdfParams(_)` | `kdf_params` out of bounds (memory < 8 MiB, iterations < 1, lanes < 1, or upper caps exceeded). |
| `InvalidSeed(_)` | The chosen scheme rejected the seed bytes (rare; BLS schemes accept any 32 bytes). |
| `Backend(io)` | Backend `write` failed (disk full, permission denied, etc.). |
**Side effects**
- Calls `backend.exists(&path)` and `backend.write(&path, bytes)`.
- On `FileBackend`, creates the root directory (if missing) with mode `0700` and the file with mode `0600` on Unix.
- Writes `HEADER_SIZE + K::SECRET_LEN + TAG_SIZE + 4` bytes (for `BlsSigning` / `L1WalletBls`: 105 bytes).
---
### `Keystore::create_with_rng`
Like [`create`](#keystorecreate) but accepts a caller-supplied RNG for deterministic tests.
```rust
pub fn create_with_rng<R: RngCore + CryptoRng>(
backend: Arc<dyn KeychainBackend>,
path: BackendKey,
password: Password,
plaintext: Option<Zeroizing<Vec<u8>>>,
kdf_params: KdfParams,
rng: &mut R,
) -> Result<Self>
```
**Use when**: writing property tests or KATs. `rng` is used for both the generated seed (if `plaintext` is `None`) **and** the per-file salt + nonce. Production callers use [`create`](#keystorecreate) which internally picks `OsRng`.
**Security note:** never pass a predictable RNG for production keys — the Argon2id salt and AES-GCM nonce must be fresh random bytes to retain the crate's security properties.
---
### `Keystore::load`
Parse an existing keystore file. Does **not** decrypt.
```rust
pub fn load(
backend: Arc<dyn KeychainBackend>,
path: BackendKey,
) -> Result<Self>
```
**Inputs**
| `backend` | `Arc<dyn KeychainBackend>` | Must contain a blob at `path`. |
| `path` | [`BackendKey`] | Identifier of an existing blob. |
**Output:** `Result<Keystore<K>>` — on success the header is parsed and validated; `cached_public_key` is `None` (no decryption happened, pubkey not known yet).
**Errors**
| `Backend(io)` (kind `NotFound`) | No blob exists at `path`. |
| `Truncated { claimed, available }` | File shorter than `HEADER_SIZE + TAG_SIZE + FOOTER_SIZE`. |
| `CrcMismatch { stored, computed }` | Outer CRC-32 does not match — disk bit-rot or tamper. |
| `UnknownMagic { saw }` | First 6 bytes are not a recognized scheme magic. |
| `UnsupportedFormat { found }` | `FORMAT_VERSION` is not `0x0001`. |
| `UnsupportedKdf(id)` | KDF id byte is not `0x01`. |
| `UnsupportedCipher(id)` | Cipher id byte is not `0x01`. |
| `SchemeMismatch { expected, found, … }` | File was created for a different scheme (e.g., loading `DIGLW1` as `BlsSigning`). |
---
### `Keystore::unlock`
Decrypt the file with the given password; return a `SignerHandle<K>`.
```rust
pub fn unlock(&self, password: Password) -> Result<SignerHandle<K>>
```
**Inputs**
| `password` | [`Password`] | Must exactly match the password used at `create` time. |
**Output:** `Result<SignerHandle<K>>` — on success, the handle owns a `Zeroizing<Vec<u8>>` copy of the decrypted secret and the derived public key. `cached_public_key` on `self` is populated as a side-effect.
**Errors**
| `Backend(io)` | Re-reading the file failed (file was deleted between `load` and `unlock`). |
| `Truncated` / `CrcMismatch` / `UnknownMagic` / etc. | File became corrupted / swapped since `load`. |
| `SchemeMismatch` | File's scheme id differs from `K::SCHEME_ID`. |
| `DecryptFailed` | **Wrong password**, tampered header (AAD check), or tampered ciphertext. All three collapse into this one variant to avoid side-channel distinctions. |
| `InvalidPlaintext { expected, got }` | Decrypted plaintext length ≠ `K::SECRET_LEN` (defence-in-depth; normally unreachable once scheme-id matches). |
**Side effects**
- Re-reads the file from the backend on every call (picks up concurrent `change_password` / `rotate_kdf`).
- Derives an AES-256 key via Argon2id (~0.5 s with default params).
- Updates `self.cached_public_key` on success.
---
### `Keystore::change_password`
Re-encrypt the stored secret under a new password.
```rust
pub fn change_password(
&mut self,
old: Password,
new: Password,
) -> Result<()>
```
Also: `change_password_with_rng<R: RngCore + CryptoRng>(&mut self, old: Password, new: Password, rng: &mut R) -> Result<()>` for deterministic tests.
**Inputs**
| `old` | [`Password`] | Current password. Required to decrypt before re-encryption. |
| `new` | [`Password`] | Replacement password. Any byte length. |
**Output:** `Result<()>`. On success, `self.header.salt` and `self.header.nonce` are updated (fresh random values).
**Errors**
| `DecryptFailed` | Wrong `old` password or corrupt file. File is **not** modified. |
| `Backend(io)` | Underlying read or write failed. |
| `InvalidKdfParams` | (Internal re-encryption rejected the existing params — should not happen for previously-valid files.) |
**Guarantees**
- The underlying secret is preserved bit-exactly (future signatures are identical to past ones).
- A fresh salt and a fresh nonce are generated — no AES-GCM `(key, nonce)` reuse.
- On failure (including wrong `old`) the on-disk file is unchanged and the old password continues to work.
---
### `Keystore::rotate_kdf`
Bump the Argon2id parameters in place. Password is unchanged.
```rust
pub fn rotate_kdf(
&mut self,
password: Password,
new_params: KdfParams,
) -> Result<()>
```
Also: `rotate_kdf_with_rng<R: RngCore + CryptoRng>(...)`.
**Inputs**
| `password` | [`Password`] | Current password (unchanged). |
| `new_params` | [`KdfParams`] | Stronger (or different) Argon2id parameters. |
**Output:** `Result<()>`. On success, `self.header.kdf` reflects `new_params`.
**Errors:** same as `change_password`.
**Use case:** after an OWASP guideline update or when moving a keystore to higher-value service, bump from `DEFAULT` (64 MiB) to `STRONG` (256 MiB) without key rotation.
---
### `Keystore::delete`
Remove the encrypted blob from the backend. Consumes `self`.
```rust
pub fn delete(self) -> Result<()>
```
**Output:** `Result<()>`.
**Errors**
| `Backend(io)` | Backend `delete` raised an I/O error. Note: calling `delete` on an already-missing file is **not** an error (backends are idempotent). |
**Side effects (FileBackend):** best-effort single-pass zero overwrite, then `unlink`.
---
### Accessors
| `header(&self) -> KeystoreHeader` | `KeystoreHeader` (Copy) | The parsed 53-byte on-disk header |
| `path(&self) -> &BackendKey` | borrowed | The backend identifier this keystore reads from |
| `cached_public_key(&self) -> Option<K::PublicKey>` | `Option<...>` | The derived pubkey, if this keystore has been created or unlocked in this process |
None of the accessors touch the backend or do cryptography — they are O(1) reads of in-memory state.
---
## `SignerHandle<K>`
```rust
pub struct SignerHandle<K: KeyScheme> { /* … */ }
```
Unlocked handle. Created **only** by [`Keystore::unlock`](#keystoreunlock). Owns a `Zeroizing<Vec<u8>>` copy of the secret; the secret is wiped when the handle is dropped.
Deliberately narrow API — no `into_raw`, no `AsRef<[u8]>`, no `Deref`. The secret never leaves the handle by design.
### Methods
```rust
pub fn public_key(&self) -> &K::PublicKey;
pub fn sign(&self, msg: &[u8]) -> K::Signature;
pub fn try_sign(&self, msg: &[u8]) -> Result<K::Signature>;
```
| `public_key` | — | `&K::PublicKey` (borrowed) | — | never |
| `sign` | `msg: &[u8]` | `K::Signature` | — | only if `K::sign` returns `Err` (unreachable for shipped schemes — secret length is guaranteed valid) |
| `try_sign` | `msg: &[u8]` | `Result<K::Signature>` | propagates `K::sign` errors | never |
### Traits
- `Clone` — clones the underlying `Zeroizing` buffer and the pubkey. Both copies wipe independently on drop.
- `Debug` — prints `SignerHandle { scheme, public, secret: <N bytes zeroized> }` (never leaks).
- `Send + Sync` — when `K::PublicKey: Send + Sync` (true for all BLS schemes).
### Example
```rust,no_run
# use std::sync::Arc;
# use dig_keystore::{
# Keystore, Password, KdfParams,
# scheme::BlsSigning,
# backend::{BackendKey, KeychainBackend, MemoryBackend},
# };
# let backend: Arc<dyn KeychainBackend> = Arc::new(MemoryBackend::new());
# let ks = Keystore::<BlsSigning>::create(
# backend, BackendKey::new("k"), Password::from("p"), None, KdfParams::FAST_TEST,
# ).unwrap();
let signer = ks.unlock(Password::from("p"))?;
let sig = signer.sign(b"some bytes");
assert!(chia_bls::verify(&sig, signer.public_key(), b"some bytes"));
# Ok::<(), dig_keystore::KeystoreError>(())
```
---
## `KeyScheme` trait
```rust
pub trait KeyScheme: Send + Sync + 'static {
type PublicKey: Clone + Debug + Send + Sync;
type Signature: Clone + Send + Sync;
const MAGIC: [u8; 6];
const NAME: &'static str;
const SCHEME_ID: u16;
const SECRET_LEN: usize;
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>;
}
```
### Contract
| `MAGIC` | 6-byte unique prefix written to the start of every file of this scheme |
| `NAME` | Human-readable name (for `KeystoreError::SchemeMismatch` messages) |
| `SCHEME_ID` | 2-byte unique id written at offset 8 of the header |
| `SECRET_LEN` | Fixed byte length of stored plaintext |
| `generate` | Pure fn of the RNG state; must yield exactly `SECRET_LEN` bytes |
| `public_key` | Pure fn of the secret bytes; must not panic on malformed input (return `Err`) |
| `sign` | Pure fn of secret + message; deterministic |
### Adding a new scheme
1. Define a unit struct and implement `KeyScheme`.
2. Allocate a fresh `MAGIC` (6 printable ASCII) and `SCHEME_ID` (next unused `u16`).
3. Register the magic in `format::is_known_magic` (inside the crate).
4. Write tests mirroring [`scheme/bls_signing.rs`](src/scheme/bls_signing.rs).
---
## Shipped schemes
### `BlsSigning` (DIGVK1)
DIG L2 validator BLS signing key.
| `MAGIC` | `b"DIGVK1"` |
| `NAME` | `"BlsSigning"` |
| `SCHEME_ID` | `0x0001` |
| `SECRET_LEN` | `32` (raw seed bytes) |
| `PublicKey` | `chia_bls::PublicKey` (48-byte compressed G1) |
| `Signature` | `chia_bls::Signature` (96-byte compressed G2) |
The stored 32 bytes are a **seed**. On unlock, [`chia_bls::SecretKey::from_seed`](https://docs.rs/chia-bls) derives the actual secret key via EIP-2333. `chia_bls::sign` uses the augmented scheme (AUG), matching Chia's on-chain `AGG_SIG_ME` semantics.
### `L1WalletBls` (DIGLW1)
Chia L1 wallet master seed.
| `MAGIC` | `b"DIGLW1"` |
| `NAME` | `"L1WalletBls"` |
| `SCHEME_ID` | `0x0003` |
| `SECRET_LEN` | `32` |
| `PublicKey` | `chia_bls::PublicKey` |
| `Signature` | `chia_bls::Signature` |
The stored 32 bytes are the master seed. HD derivation (`m/12381/8444/2/{index}`) happens in the consuming wallet layer; the keystore only round-trips the master seed.
---
## `KeychainBackend` trait
```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 impl
}
```
### Method contract
| `read` | `key: &BackendKey` | `Result<Vec<u8>>` | Returns `Backend(NotFound)` if blob absent |
| `write` | `key: &BackendKey, data: &[u8]` | `Result<()>` | **Must be atomic** — reader sees either old or new blob, never a torn mix |
| `delete` | `key: &BackendKey` | `Result<()>` | **Idempotent** — removing an absent key returns `Ok(())` |
| `list` | `prefix: &str` | `Result<Vec<BackendKey>>` | Returns keys whose inner string starts with `prefix`; order unspecified |
| `exists` | `key: &BackendKey` | `Result<bool>` | Default impl = `read` + inspect `NotFound` — override for cheaper checks |
---
## Shipped backends
### `FileBackend`
```rust
pub struct FileBackend { /* … */ }
impl FileBackend {
pub fn new(root: impl Into<PathBuf>) -> Self;
pub fn root(&self) -> &Path;
}
```
| Storage | `<root>/<key>.dks` per `BackendKey` |
| Atomicity | tmp file + `fsync` + `rename` (POSIX rename is atomic; Windows uses `MoveFileExW`) |
| Permissions (Unix) | root dir `0700`, files `0600` |
| Root creation | Lazy — on first `write` |
| Secure delete | Best-effort single-pass zero overwrite before `unlink` |
Construction never touches the disk; side effects begin on the first `write` call.
### `MemoryBackend` (feature `testing`)
```rust
pub struct MemoryBackend { /* … */ }
impl MemoryBackend { pub fn new() -> Self; }
```
In-process `parking_lot::Mutex<HashMap<BackendKey, Vec<u8>>>`. `KeychainBackend` impl behaves identically to `FileBackend` but is instantaneous and filesystem-free.
**Use in tests only.** Not compiled without the `testing` feature.
---
## `Password`
```rust
pub struct Password(/* Zeroizing<Vec<u8>> */);
```
Zeroizing wrapper around password bytes. Memory is wiped when `Password` is dropped.
### Methods
| `Password::new` | `fn new(bytes: impl AsRef<[u8]>) -> Self` | Construct from any byte source (copies into zeroizing buffer) |
| `as_bytes` | `fn as_bytes(&self) -> &[u8]` | Borrow raw bytes |
| `len` | `fn len(&self) -> usize` | Byte length |
| `is_empty` | `fn is_empty(&self) -> bool` | `len() == 0` |
| `strength` (feature `password-strength`) | `fn strength(&self) -> zxcvbn::Entropy` | `zxcvbn` estimate; empty on non-UTF-8 |
### `From` impls
| `&str` | Copy via `as_bytes()` |
| `String` | Consume into zeroizing buffer (single allocation) |
| `&[u8]` | Copy |
| `Vec<u8>` | Consume into zeroizing buffer |
### Traits
- `Clone` — duplicates the zeroizing buffer; each copy wipes independently on drop.
- `Debug` — prints `Password(<N bytes>)`, **never** leaks content.
---
## `BackendKey`
```rust
pub struct BackendKey(pub String);
impl BackendKey {
pub fn new(name: impl Into<String>) -> Self;
pub fn as_str(&self) -> &str;
}
impl Display for BackendKey { … }
impl<T: Into<String>> From<T> for BackendKey { … }
```
Opaque string key for a blob inside a backend. The inner `String` is public so `serde`-style code can round-trip without adapters. Keep values filesystem-safe ASCII (no slashes, dots, control chars). Typical: `"validator"`, `"wallet_main"`, `"backup_2026"`.
---
## `KdfParams`
```rust
pub struct KdfParams {
pub id: KdfId,
pub memory_kib: u32,
pub iterations: u32,
pub lanes: u8,
}
```
### Presets
| `KdfParams::DEFAULT` | `65536` (64 MiB) | `3` | `4` | production default; matches `dig-l1-wallet` |
| `KdfParams::STRONG` | `262144` (256 MiB) | `4` | `4` | high-value keys |
| `KdfParams::FAST_TEST` | `8192` (8 MiB) | `1` | `1` | **tests only** |
### Validation bounds (enforced in `derive_key`)
| `memory_kib` | 8192 (8 MiB) | 1048576 (1 GiB) |
| `iterations` | 1 | 256 |
| `lanes` | 1 | 64 |
`Default` impl returns `DEFAULT`.
## `KdfId`
```rust
#[repr(u8)]
pub enum KdfId {
Argon2id = 0x01,
}
```
Only `Argon2id` is recognized in v1. Future-reserved values: `0x02` (scrypt), `0x03` (balloon), etc.
## `CipherId`
```rust
#[repr(u8)]
pub enum CipherId {
Aes256Gcm = 0x01,
}
```
Only `Aes256Gcm` is recognized in v1. Future-reserved values: `0x02` (ChaCha20-Poly1305), etc.
---
## `KeystoreHeader`
```rust
pub struct KeystoreHeader {
pub magic: [u8; 6],
pub format_version: u16,
pub scheme_id: u16,
pub kdf: KdfParams,
pub cipher: CipherId,
pub salt: [u8; 16],
pub nonce: [u8; 12],
pub payload_len: u32,
}
```
Parsed representation of the 53-byte on-disk header. All fields are public for inspection; the header is bound into the AES-GCM AAD, so editing it in memory and expecting a resulting ciphertext to still decrypt is impossible.
Exposed via [`Keystore::header`](#accessors) and returned indirectly through the file-format module. Also: `FORMAT_VERSION_V1: u16 = 0x0001`.
---
## `KeystoreError`
```rust
pub enum KeystoreError {
Backend(Arc<std::io::Error>),
UnknownMagic { saw: [u8; 6] },
UnsupportedFormat { found: u16 },
SchemeMismatch { expected: u16, expected_name: &'static str, found: u16 },
CrcMismatch { stored: u32, computed: u32 },
DecryptFailed,
InvalidKdfParams(&'static str),
UnsupportedKdf(u8),
UnsupportedCipher(u8),
AlreadyExists(String),
InvalidPlaintext { expected: usize, got: usize },
InvalidSeed(String),
Truncated { claimed: usize, available: usize },
}
```
`#[derive(Error, Debug, Clone)]` — `Clone` because async channels and `watch::Sender` broadcasts need it. `Display` comes from `thiserror`.
### Classification
| **User error** | `DecryptFailed`, `SchemeMismatch`, `AlreadyExists` | Reshow prompt / instruct user |
| **Corruption / tampering** | `CrcMismatch`, `UnknownMagic`, `Truncated`, `InvalidPlaintext`, `InvalidSeed` | Loud failure; possible attack; alert operator |
| **Configuration** | `InvalidKdfParams`, `UnsupportedKdf`, `UnsupportedCipher`, `UnsupportedFormat` | Operator fixes config or upgrades library |
| **Infrastructure** | `Backend(io)` | Retry / alert operator / inspect I/O error |
### `From<std::io::Error>`
```rust
impl From<std::io::Error> for KeystoreError;
```
Wraps the `io::Error` in `Arc` and returns `KeystoreError::Backend`. Used throughout `FileBackend` via the `?` operator.
---
## `Result<T>`
```rust
pub type Result<T> = std::result::Result<T, KeystoreError>;
```
Every fallible function in the crate returns `Result<T>`.
---
## `bls` re-exports
```rust
pub mod bls {
pub use chia_bls::{sign, verify, PublicKey, SecretKey, Signature};
}
```
Convenience re-exports so consumers that only need keystore + basic BLS verification don't need to add a direct `chia-bls` dependency. For advanced BLS work (aggregation, `AugSchemeMPL` wrappers, BLS12-381 curve operations) depend on `chia-bls` directly.
---
## `testing` module (feature `testing`)
```rust
pub mod testing {
pub use crate::backend::MemoryBackend;
pub const TEST_PASSWORD: &str = "dig-keystore-test-password";
}
```
Helpers for dependent crates. Add to their `dev-dependencies`:
```toml
[dev-dependencies]
dig-keystore = { version = "0.1", features = ["testing"] }
```
Then in tests:
```rust,ignore
use dig_keystore::testing::{MemoryBackend, TEST_PASSWORD};
```
---
## Feature flags
| `file-backend` | **on** | Ships [`FileBackend`] |
| `testing` | off | Ships [`testing`](#testing-module-feature-testing) module |
| `password-strength` | off | Enables [`Password::strength`] via `zxcvbn` |
| `eip2335` | off | *(planned)* import/export [EIP-2335](https://eips.ethereum.org/EIPS/eip-2335) v4 JSON |
| `chia-keychain` | off | *(planned)* import Chia `.keychain` files |
---
## File format
```
┌─────────────────────────────────────────────────────────────┐
│ 6 bytes MAGIC "DIGVK1" or "DIGLW1" │
│ 2 bytes FORMAT_VERSION 0x0001 │
│ 2 bytes KEY_SCHEME 0x0001=BlsSigning │
│ 0x0003=L1WalletBls │
│ 1 byte KDF_ID 0x01 = Argon2id │
│ 4 bytes KDF_MEMORY_KIB u32 big-endian │
│ 4 bytes KDF_ITERATIONS u32 big-endian │
│ 1 byte KDF_LANES u8 │
│ 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 big-endian │
└─────────────────────────────────────────────────────────────┘
```
- Header (first 53 bytes) is fed to AES-GCM as AAD; any edit invalidates the tag.
- CRC-32 is a fast-fail before Argon2id — not a security check.
- Full 32-byte-secret file = **105 bytes**.
Specification: [`docs/resources/SPEC.md`](docs/resources/SPEC.md).
---
## Security properties
| Confidentiality | AES-256-GCM under a password-derived key |
| Integrity | AES-GCM 128-bit auth tag (AAD-bound header) + outer CRC-32 |
| Brute-force resistance | Argon2id memory-hard KDF (RFC 9106); 64 MiB minimum |
| Memory hygiene | `Zeroizing` on passwords, seeds, AES keys |
| On-disk atomicity | Tmp file + `fsync` + atomic rename |
| Type safety | Magic + scheme-id forbid cross-scheme usage (compile-time + runtime) |
### Not guaranteed
- **Compromised host:** a process with ptrace / memory-read access can lift an unlocked `SignerHandle`. `Zeroizing` reduces but does not eliminate this.
- **Swapped memory:** the OS may page a `Zeroizing` buffer before drop. Disable swap or use `mlock` for high-value keys.
- **Secure-delete on SSDs:** single-pass overwrite does not guarantee sector erasure on modern flash. Use full-disk encryption.
- **Weak passwords:** no KDF hardness compensates for a 6-character password. Use `STRONG` params + enforce length at the CLI layer.
---
## Common call sequences
### New validator
```rust,no_run
# use std::sync::Arc;
# use dig_keystore::*;
# use dig_keystore::{scheme::BlsSigning, backend::*};
# let backend: Arc<dyn KeychainBackend> = Arc::new(FileBackend::new("/var/dig/keys"));
# let prompt_for_password = || Password::from("pw");
let ks = Keystore::<BlsSigning>::create(
backend,
BackendKey::new("validator"),
prompt_for_password(),
None,
KdfParams::default(),
)?;
println!("pubkey = {:?}", ks.cached_public_key());
# Ok::<(), KeystoreError>(())
```
### Daily unlock at validator startup
```rust,no_run
# use std::sync::Arc;
# use dig_keystore::*;
# use dig_keystore::{scheme::BlsSigning, backend::*};
# let backend: Arc<dyn KeychainBackend> = Arc::new(FileBackend::new("/var/dig/keys"));
# let prompt_for_password = || Password::from("pw");
let ks = Keystore::<BlsSigning>::load(backend, BackendKey::new("validator"))?;
let signer = Arc::new(ks.unlock(prompt_for_password())?);
// Stash `signer` in the validator's Node struct; share across duty tasks via Arc.
# Ok::<(), KeystoreError>(())
```
### Password rotation
```rust,no_run
# use std::sync::Arc;
# use dig_keystore::*;
# use dig_keystore::{scheme::BlsSigning, backend::*};
# let backend: Arc<dyn KeychainBackend> = Arc::new(FileBackend::new("/var/dig/keys"));
let mut ks = Keystore::<BlsSigning>::load(backend, BackendKey::new("validator"))?;
ks.change_password(Password::from("old"), Password::from("new"))?;
# Ok::<(), KeystoreError>(())
```
### Upgrade KDF hardness
```rust,no_run
# use std::sync::Arc;
# use dig_keystore::*;
# use dig_keystore::{scheme::BlsSigning, backend::*};
# let backend: Arc<dyn KeychainBackend> = Arc::new(FileBackend::new("/var/dig/keys"));
let mut ks = Keystore::<BlsSigning>::load(backend, BackendKey::new("validator"))?;
ks.rotate_kdf(Password::from("pw"), KdfParams::STRONG)?;
# Ok::<(), KeystoreError>(())
```
### Wallet restore from mnemonic seed
```rust,no_run
# use std::sync::Arc;
# use dig_keystore::*;
# use dig_keystore::{scheme::L1WalletBls, backend::*};
# use zeroize::Zeroizing;
# let backend: Arc<dyn KeychainBackend> = Arc::new(FileBackend::new("/var/dig/keys"));
let seed: Zeroizing<Vec<u8>> = Zeroizing::new(vec![/* 32-byte seed derived from BIP-39 */; 32]);
let ks = Keystore::<L1WalletBls>::create(
backend,
BackendKey::new("l1_wallet"),
Password::from("user-password"),
Some(seed),
KdfParams::default(),
)?;
# Ok::<(), KeystoreError>(())
```
---
## Performance
All figures on a modern x86_64 laptop (Zen4 / M2-class).
| `Keystore::create` (default params) | ~500 ms dominated by Argon2id |
| `Keystore::load` | < 1 ms (header parse + CRC) |
| `Keystore::unlock` (default params) | ~500 ms dominated by Argon2id |
| `Keystore::unlock` (`FAST_TEST` params) | ~10 ms |
| `SignerHandle::sign` (BLS G2) | ~0.5 ms per call |
| `SignerHandle::public_key` | O(1) — pubkey cached at unlock |
| `Keystore::change_password` | ~1 s (two Argon2id runs) |
| `FileBackend::write` | fsync-bound; typically < 5 ms on SSD |
| File size (32-byte secret) | 105 bytes |
---
## Testing
```bash
cargo test --features testing # 72 tests across 6 binaries
cargo test --release # realistic KDF timings
cargo clippy --features testing --all-targets -- -D warnings # lint-clean
```
Test coverage:
| `src/**/tests` | Unit tests for each primitive (AES-GCM, Argon2id, `Password`, file format, schemes, backends, signer) |
| `tests/roundtrip.rs` | End-to-end create / load / unlock / sign / verify; password + KDF rotation; type-confusion rejection |
| `tests/wrong_password.rs` | Wrong / empty / unicode / 4 KiB passwords; failed `change_password` leaves file intact |
| `tests/tamper.rs` | Every byte of a valid file flipped once — load or unlock must fail; truncation + garbage rejection |
| `tests/vectors.rs` | Known-answer tests pinning BLS derivation + file layout (105-byte size, magic bytes) |
Every `#[test]` carries a `/// **Proves:** ... **Why it matters:** ... **Catches:** ...` triple-slash doc comment. Run `cargo doc --open` to browse.
---
## License
Licensed under either of:
- Apache License, Version 2.0 ([LICENSE-APACHE](LICENSE-APACHE) or <http://www.apache.org/licenses/LICENSE-2.0>)
- MIT license ([LICENSE-MIT](LICENSE-MIT) or <http://opensource.org/licenses/MIT>)
at your option.
# dig-keystore