Encrypted secret-key storage for 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 for BLS12-381 keys / signatures; see CRATES_SUMMARY.md for the full DIG + Chia stack.
- Format version:
0x0001(seedocs/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
- Install
- At-a-glance
- Quick reference
Keystore<K>SignerHandle<K>KeySchemetrait- Shipped schemes
KeychainBackendtrait- Shipped backends
PasswordBackendKeyKdfParams/KdfId/CipherIdKeystoreHeaderKeystoreErrorResult<T>blsre-exportstestingmodule- Feature flags
- File format
- Security properties
- Common call sequences
- Performance
- Testing
opaquemodule- WebAssembly / npm (
dig-keystore-wasm) - License
[dependencies]
dig-keystore = "0.1"Default features include file-backend. Enable testing to get MemoryBackend and TEST_PASSWORD for dependent crates' tests.
| Type | Kind | One-line role |
|---|---|---|
Keystore<K> |
struct | the encrypted-file handle — create, load, unlock, change_password, rotate_kdf, delete |
SignerHandle<K> |
struct | the unlocked handle — sign, public_key — never exposes raw secret |
KeyScheme |
trait | turn secret bytes into pubkey + signature; defines on-disk magic + scheme id |
BlsSigning |
unit struct impl KeyScheme | DIG L2 validator BLS key (magic DIGVK1) |
L1WalletBls |
unit struct impl KeyScheme | Chia L1 wallet master seed (magic DIGLW1) |
KeychainBackend |
trait | abstract byte-blob KV — read / write / delete / list |
FileBackend |
struct impl KeychainBackend | filesystem with atomic tmp-then-rename writes |
MemoryBackend |
struct impl KeychainBackend | in-process HashMap (testing only) |
Password |
struct | Zeroizing<Vec<u8>> wrapper; Debug never leaks |
BackendKey |
struct | opaque string key for a blob inside a backend |
KdfParams |
struct | Argon2id memory / iterations / lanes |
KeystoreHeader |
struct | parsed 53-byte on-disk header |
KdfId, CipherId |
enums | algorithm tags in the header |
KeystoreError |
enum | every failure variant |
Result<T> |
alias | Result<T, KeystoreError> |
bls::* |
module | re-exports of chia_bls::{PublicKey, SecretKey, Signature, sign, verify} |
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>(())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.
Create a new encrypted keystore blob on the backend.
pub fn create(
backend: Arc<dyn KeychainBackend>,
path: BackendKey,
password: Password,
plaintext: Option<Zeroizing<Vec<u8>>>,
kdf_params: KdfParams,
) -> Result<Self>Inputs
| Name | Type | Constraints / semantics |
|---|---|---|
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 is populated as a side-effect.
Errors (all KeystoreError)
| Variant | When |
|---|---|
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)andbackend.write(&path, bytes). - On
FileBackend, creates the root directory (if missing) with mode0700and the file with mode0600on Unix. - Writes
HEADER_SIZE + K::SECRET_LEN + TAG_SIZE + 4bytes (forBlsSigning/L1WalletBls: 105 bytes).
Like create but accepts a caller-supplied RNG for deterministic tests.
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 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.
Parse an existing keystore file. Does not decrypt.
pub fn load(
backend: Arc<dyn KeychainBackend>,
path: BackendKey,
) -> Result<Self>Inputs
| Name | Type | Semantics |
|---|---|---|
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
| Variant | When |
|---|---|
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). |
Decrypt the file with the given password; return a SignerHandle<K>.
pub fn unlock(&self, password: Password) -> Result<SignerHandle<K>>Inputs
| Name | Type | Semantics |
|---|---|---|
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
| Variant | When |
|---|---|
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_keyon success.
Re-encrypt the stored secret under a new password.
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
| Name | Type | Semantics |
|---|---|---|
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
| Variant | When |
|---|---|
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.
Bump the Argon2id parameters in place. Password is unchanged.
pub fn rotate_kdf(
&mut self,
password: Password,
new_params: KdfParams,
) -> Result<()>Also: rotate_kdf_with_rng<R: RngCore + CryptoRng>(...).
Inputs
| Name | Type | Semantics |
|---|---|---|
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.
Remove the encrypted blob from the backend. Consumes self.
pub fn delete(self) -> Result<()>Output: Result<()>.
Errors
| Variant | When |
|---|---|
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.
| Method | Return | Purpose |
|---|---|---|
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.
pub struct SignerHandle<K: KeyScheme> { /* … */ }Unlocked handle. Created only by Keystore::unlock. 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.
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>;| Method | Input | Output | Errors | Panics |
|---|---|---|---|---|
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 |
Clone— clones the underlyingZeroizingbuffer and the pubkey. Both copies wipe independently on drop.Debug— printsSignerHandle { scheme, public, secret: <N bytes zeroized> }(never leaks).Send + Sync— whenK::PublicKey: Send + Sync(true for all BLS schemes).
# 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>(())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>;
}| Item | Requirement |
|---|---|
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 |
- Define a unit struct and implement
KeyScheme. - Allocate a fresh
MAGIC(6 printable ASCII) andSCHEME_ID(next unusedu16). - Register the magic in
format::is_known_magic(inside the crate). - Write tests mirroring
scheme/bls_signing.rs.
DIG L2 validator BLS signing key.
| Constant | Value |
|---|---|
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 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.
Chia L1 wallet master seed.
| Constant | Value |
|---|---|
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.
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 | Input | Output | 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 |
pub struct FileBackend { /* … */ }
impl FileBackend {
pub fn new(root: impl Into<PathBuf>) -> Self;
pub fn root(&self) -> &Path;
}| Property | Value |
|---|---|
| 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.
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.
Binds stored key material to the host's OS hardware trusted component — Windows TPM 2.0, Apple Secure Enclave, or Linux TPM 2.0 — so a sealed blob copied to another machine cannot be opened. It is a tier above the other backends, not a replacement: it decorates one.
No platform provider ships in 0.5.0. This release provides the seam, the envelope and the tier semantics; nothing here binds to a real TPM or Secure Enclave yet. Unless you supply your own
HardwareProvider, every host resolvesSoftware(NotRequested)— honest by construction, but not hardware protection. Providers are tracked as dig_ecosystem #1693.
pub struct HardwareBoundBackend { /* … */ }
impl HardwareBoundBackend {
pub fn new<B: KeychainBackend>(
inner: B,
provider: Option<Arc<dyn HardwareProvider>>,
policy: HardwarePolicy,
) -> Result<Self>;
pub fn with_inner(
inner: Arc<dyn KeychainBackend>,
provider: Option<Arc<dyn HardwareProvider>>,
policy: HardwarePolicy,
) -> Result<Self>;
pub fn tier(&self) -> &ProtectionTier;
pub fn blob_tier(&self, key: &BackendKey) -> Result<ProtectionTier>;
pub fn inner(&self) -> &Arc<dyn KeychainBackend>;
}The AES-256-GCM + Argon2id file envelope stays the floor. Hardware wrapping is an outer
DIGHW1 envelope around an already-sealed blob, so a host without hardware writes exactly
the bytes it always wrote — never a bare secret. The v1 format is untouched, and any blob
that is not a DIGHW1 envelope is read back unchanged, so existing keystores keep opening.
Never assume hardware protection is in effect, and be careful which question you ask — there are two, and they can legitimately disagree:
| Method | Answers |
|---|---|
tier() |
what this host is capable of — the tier every newly written blob gets |
blob_tier(key) |
what protects the key stored at key, read from the stored bytes |
Use blob_tier for anything a user sees. A hardware-capable host can hold a keystore
written before hardware binding existed: tier() reports Hardware, but that blob is
protected by the passphrase alone and does open on another machine. Quoting tier() there
would tell the user they have copy-resistance they do not have. A capable host does not
retroactively protect bytes already at rest — only rewriting the blob binds it.
match backend.blob_tier(&key)? {
ProtectionTier::Hardware(kind) => println!("this key is bound to {kind}"),
// The reason is part of the variant — there is no way to report a degrade
// without saying why. `BlobNotWrapped` is the legacy-keystore case.
ProtectionTier::Software(reason) => println!("software-wrapped: {reason}"),
}blob_tier reports the blob's own sealing class, independent of this host — a blob from
a Mac reads as Hardware(MacSecureEnclave) even on Windows. Whether this host can open it
is a separate question, answered by read. It fails closed on a blob it cannot fully
classify (malformed envelope, or a hardware class this build cannot name) rather than
reporting it as software-protected.
"Is hardware present?" and "could I determine whether hardware is present?" are different
facts, so HardwareProbe is three-valued (Available / Absent / Indeterminate) and the
policy decides what each means:
HardwarePolicy |
Absent |
Indeterminate |
Unusable | No provider |
|---|---|---|---|---|
Required |
error | error | error | error |
Preferred (default) |
degrade | error | degrade | degrade |
Optional |
degrade | degrade | degrade | degrade |
Preferred fails closed on Indeterminate on purpose: treating "I could not tell" as
"there is none" would let a transient probe failure quietly strip hardware protection from a
machine that has it.
A provider is never taken at its word. Before reporting a hardware tier the backend runs a
live wrap/unwrap self-test and requires KeyCustody::NonExportable, so a provider that
probes available but cannot wrap, "wraps" by returning the key verbatim, or holds its key in
process memory is refuted and reported as HardwareUnusable.
This crate forbids unsafe code as a spec-pinned security property (SPEC.md §13.2,
conformance C-15), which CNG and Security Framework FFI cannot satisfy. Platform providers
therefore cannot live here; they are planned for a separate hardware/ workspace member
that will mirror the wasm/ split (dig_ecosystem #1693) and will be injected through the
HardwareProvider trait. Passing provider = None resolves Software(NotRequested):
honest, and explicitly not a claim of hardware protection.
FakeDevice (module hardware::double, feature testing) is a configurable provider
double for dependents' tests. It models cross-machine binding structurally — its device key
never enters an envelope — and can be made to lie in several ways at once, which is what the
self-test above is tested against.
pub struct Password(/* Zeroizing<Vec<u8>> */);Zeroizing wrapper around password bytes. Memory is wiped when Password is dropped.
| Method | Signature | Purpose |
|---|---|---|
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 |
| Source | Behaviour |
|---|---|
&str |
Copy via as_bytes() |
String |
Consume into zeroizing buffer (single allocation) |
&[u8] |
Copy |
Vec<u8> |
Consume into zeroizing buffer |
Clone— duplicates the zeroizing buffer; each copy wipes independently on drop.Debug— printsPassword(<N bytes>), never leaks content.
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".
pub struct KdfParams {
pub id: KdfId,
pub memory_kib: u32,
pub iterations: u32,
pub lanes: u8,
}| Preset | memory_kib |
iterations |
lanes |
Use |
|---|---|---|---|---|
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 |
| Field | Min | Max |
|---|---|---|
memory_kib |
8192 (8 MiB) | 1048576 (1 GiB) |
iterations |
1 | 256 |
lanes |
1 | 64 |
Default impl returns DEFAULT.
#[repr(u8)]
pub enum KdfId {
Argon2id = 0x01,
}Only Argon2id is recognized in v1. Future-reserved values: 0x02 (scrypt), 0x03 (balloon), etc.
#[repr(u8)]
pub enum CipherId {
Aes256Gcm = 0x01,
}Only Aes256Gcm is recognized in v1. Future-reserved values: 0x02 (ChaCha20-Poly1305), etc.
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 and returned indirectly through the file-format module. Also: FORMAT_VERSION_V1: u16 = 0x0001.
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.
| Class | Variants | Action |
|---|---|---|
| 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 |
impl From<std::io::Error> for KeystoreError;Wraps the io::Error in Arc and returns KeystoreError::Backend. Used throughout FileBackend via the ? operator.
pub type Result<T> = std::result::Result<T, KeystoreError>;Every fallible function in the crate returns Result<T>.
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.
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:
[dev-dependencies]
dig-keystore = { version = "0.1", features = ["testing"] }Then in tests:
use dig_keystore::testing::{MemoryBackend, TEST_PASSWORD};| Flag | Default | Effect |
|---|---|---|
file-backend |
on | Ships [FileBackend] |
testing |
off | Ships testing module |
password-strength |
off | Enables [Password::strength] via zxcvbn |
eip2335 |
off | (planned) import/export EIP-2335 v4 JSON |
chia-keychain |
off | (planned) import Chia .keychain files |
┌─────────────────────────────────────────────────────────────┐
│ 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 │
│ N bytes CIPHERTEXT+TAG AES-256-GCM(plaintext) || tag │
│ 4 bytes CRC32 IEEE 802.3 over all preceding │
└─────────────────────────────────────────────────────────────┘
- 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.
| Property | Mechanism |
|---|---|
| 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) |
- Compromised host: a process with ptrace / memory-read access can lift an unlocked
SignerHandle.Zeroizingreduces but does not eliminate this. - Swapped memory: the OS may page a
Zeroizingbuffer before drop. Disable swap or usemlockfor 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
STRONGparams + enforce length at the CLI layer.
# 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>(())# 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>(())# 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>(())# 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>(())# 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>(())All figures on a modern x86_64 laptop (Zen4 / M2-class).
| Operation | Cost |
|---|---|
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 |
cargo test --features testing # 94 tests across 7 binaries
cargo test --release # realistic KDF timings
cargo clippy --features testing --all-targets -- -D warnings # lint-cleanTest coverage:
| Suite | Asserts |
|---|---|
src/**/tests |
Unit tests for each primitive (AES-GCM, Argon2id, Password, file format, schemes, backends, signer, opaque) |
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) |
tests/opaque_vectors.rs |
opaque module KAT, shared byte-for-byte with wasm/tests/opaque_wasm.rs (native↔wasm compat proof) |
Every #[test] carries a /// **Proves:** ... **Why it matters:** ... **Catches:** ... triple-slash doc comment. Run cargo doc --open to browse.
Password-seals secret bytes of any length (including empty) — for callers that don't fit
Keystore<K>'s fixed SECRET_LEN + typed-public-key model, e.g. BIP-39 entropy or any other
opaque application secret. Same on-disk container as every Keystore<K> file (§SPEC.md
§15), distinguished only by magic DIGOP1 / scheme id 0x0004. This is the primitive
dig-keystore-wasm (below) wraps for browser callers.
use dig_keystore::opaque;
use dig_keystore::{KdfParams, Password};
let password = Password::from("correct horse battery staple");
let secret: &[u8] = b"any length of bytes at all, e.g. bip-39 entropy";
let blob = opaque::seal(&password, secret, KdfParams::DEFAULT)?;
assert!(opaque::verify_password(&password, &blob));
let recovered = opaque::open(&password, &blob)?;
assert_eq!(&*recovered, secret);
# Ok::<(), dig_keystore::KeystoreError>(())There is no KeychainBackend involved — seal/open are pure bytes-in/bytes-out; the
caller owns storage (a file, a database row, chrome.storage.local, …) directly.
wasm/ is a sibling crate/package in this repo (a Cargo workspace member alongside the main
dig-keystore package — see the [workspace] table in Cargo.toml) that binds the opaque
module above for browser/Node callers via wasm-bindgen.
It publishes to npm as @dignetwork/dig-keystore-wasm.
It exists as a SEPARATE package, not a feature on dig-keystore itself, so this crate's
unsafe_code = "forbid" lint (a spec-pinned security property, SPEC.md §13.2/§16.1) and
dependency graph stay completely unaffected by wasm support existing.
import init, { seal, open, sealStrong, verifyPassword } from "@dignetwork/dig-keystore-wasm";
await init(); // optional: installs a console panic hook
const blob = seal("correct horse battery staple", new Uint8Array([1, 2, 3, 4]));
// store `blob` (a Uint8Array) yourself — e.g. chrome.storage.local — there is no
// file/keychain backend in the wasm binding
verifyPassword("correct horse battery staple", blob); // true
const secret = open("correct horse battery staple", blob); // Uint8Array([1,2,3,4])
// STRONG preset (256 MiB / 4 iterations / 4 lanes) for a high-value secret — opened
// with the SAME `open` above; the preset is recorded in the blob's own header.
const strongBlob = sealStrong("correct horse battery staple", new Uint8Array([1, 2, 3, 4]));Build locally:
cd wasm
npm run build:bundler # wasm-pack build --target bundler --release -> wasm/pkg/
npm test # wasm-pack test --node (wasm-bindgen-test suite)npm publish status: .github/workflows/publish-npm.yml publishes via npm Trusted
Publishing (OIDC) — no NPM_TOKEN secret involved. npm's trusted-publisher config,
however, can only be attached to a package that already exists on the registry, so the
FIRST publish of a brand-new scoped name 404s even with OIDC wired up correctly (confirmed
via the v0.2.1 publish-npm run) — an org-admin bootstrap (one manual authenticated
npm publish to create the package) is needed before OIDC publishing can take over.
@dignetwork/dig-keystore-wasm is therefore not yet live on the npm registry. Until that
clears, consume it the same way @dignetwork/chia-provider/@dignetwork/chip35-dl-coin-wasm
are consumed as a stopgap: build wasm/pkg locally (above) from a git checkout of this
repo, or vendor that built pkg/ output into the consuming repo as a local/file:
dependency (the dig-chrome-extension's approach, dig_ecosystem #147 Phase B).
See SPEC.md §16 for the full normative binding contract (exported functions, error
behavior, and the native↔wasm byte-compatibility proof).
Licensed under either of:
- Apache License, Version 2.0 (LICENSE-APACHE or http://www.apache.org/licenses/LICENSE-2.0)
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)
at your option.