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.
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) andL1WalletBls(DIGLW1), both BLS12-381 viachia-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
Passwordtype — §9. - The
KeychainBackendtrait and the shippedFileBackend/MemoryBackend— §10. - The error catalog — §11.
- Zeroization and other security properties — §12.
- The
opaquemodule — arbitrary-length password-sealed secrets, noKeyScheme— §15. - The
dig-keystore-wasmWebAssembly binding (npm@dignetwork/dig-keystore-wasm) — §16. - The
hardwaremodule — 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) performm/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
KeychainBackendtrait 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.
| 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. |
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_LENMUST equalsecret_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.
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.
A conforming reader MUST process a file in this order and fail with the stated error (§11) at the first violation:
- Length floor. If the file is shorter than
53 + 16 + 4 = 73bytes →Truncated. - CRC-32. Recompute over
bytes[..len-4]; compare to the stored footer →CrcMismatchon disagreement. The CRC is a fast-fail corruption check only; it is NOT a security boundary (the AES-GCM tag is). - Magic. The 6-byte magic MUST be one of the known values (
DIGVK1,DIGLW1) →UnknownMagicotherwise. - 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.) - KDF id. MUST be
0x01→UnsupportedKdf(byte)otherwise. - Cipher id. MUST be
0x01→UnsupportedCipher(byte)otherwise. - Payload bounds.
53 + PAYLOAD_LEN + 4MUST NOT exceed the file length →Truncatedotherwise.
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.
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.
- 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_VERSIONis 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.
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 makesunlockpossible. - The derived key is held in a
Zeroizing<[u8; 32]>and wiped on drop.
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.
| 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.
- 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.
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_keyandsignare pure functions of their inputs — no RNG, no external state. Signatures are deterministic given(secret, msg).generateMUST use the caller-supplied RNG (never a hiddenOsRng) and MUST return exactlySECRET_LENbytes in aZeroizingbuffer.public_key/signMUST returnInvalidPlaintext { expected, got }(never panic) whensecret.len() != SECRET_LEN.MAGICandSCHEME_IDMUST be unique across schemes; a new scheme's magic MUST be registered in the format decoder's known-magic set.
| 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.26). 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.
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
- If a blob already exists at the key,
createMUST fail withAlreadyExists— it never silently overwrites an existing key. (The existence probe treats a backendNotFoundread error as "absent"; any other backend error propagates.) - If
plaintextis supplied, its length MUST equalK::SECRET_LEN(InvalidPlaintextotherwise). IfNone, a fresh secret is generated viaK::generatewithOsRng(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_lenis finalized before encryption because the header is AAD. create_with_rngexists for deterministic test fixtures; production keys MUST use a cryptographically secure OS RNG.
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.
- MUST re-read the blob from the backend on every call (never trusts in-memory state),
so concurrent
change_password/rotate_kdfby 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(InvalidPlaintextotherwise — defence in depth), derives and caches the public key, and returns aSignerHandle. - 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 anArc), not re-unlock per signature.
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.
header()→ the parsedKeystoreHeader(metadata inspection without a password).path()→ theBackendKey.cached_public_key()→Some(pk)only if this process has created or unlocked the keystore; otherwiseNone. Reading the public key from a cold file REQUIRES an unlock (the format stores no plaintext public key).DebugforKeystoreprints scheme name, path, and KDF params — never key material.
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).
SignerHandleMUST NOT implementAsRef<[u8]>,Derefto the secret, or any ownedinto_raw()-style extractor.expose_secretexists solely for HD-wallet consumers that need the master seed to derive child keys (e.g.chia_bls::SecretKey::from_seed(handle.expose_secret())thenDerivableKeyderivation). 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.Clonedeep-copies the zeroizing buffer; each clone wipes independently on drop and produces byte-identical signatures.DebugMUST redact the secret (it prints<N bytes zeroized>); the handle is safe to include intracing/log output.- Drop zeroizes the secret.
- Internally
Zeroizing<Vec<u8>>; the buffer is wiped on drop.Clonecopies are wiped independently. - Accepts arbitrary bytes (not just UTF-8) via
Password::new(impl AsRef<[u8]>)andFrom<&str> / From<String> / From<&[u8]> / From<Vec<u8>>. The owningFromimpls (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.
DebugMUST redact content (printsPassword(<N> bytes)).strength()(featurepassword-strengthonly) 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.
An opaque String newtype addressing one blob within a backend
(new, blanket From<Into<String>>, as_str, Display; Eq + Hash).
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 */ }
}Implementations MUST satisfy:
readof a missing key returnsKeystoreError::Backendwrapping anstd::io::Errorof kindNotFound. This exact shape is load-bearing: the defaultexistsandKeystore::create's overwrite guard branch on it. (Ok(true)when a read succeeds;Ok(false)onNotFound; any other error propagates.)writeis 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.deleteis idempotent: deleting an absent key succeeds. Implementations SHOULD best-effort overwrite storage before removal.list(prefix)returns keys whose names start withprefix(strict prefix, not substring); order unspecified; empty prefix lists all.
- 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 firstwrite, with mode0700on Unix. - Atomic write procedure (normative): write to a sibling
<key>.dks.tmp.<random16hex>(mode0600on Unix) →fsyncthe file →renameonto the final name → on Unix,fsyncthe 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: no-op if absent; 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: scans the root (empty result if the root does not exist), skipping
non-
.dksand non-UTF-8 names, returning the extension-stripped stems matching the prefix. - Exists: cheap
stat-based override. - 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.
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.
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.
| 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 |
Unix only |
| 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/VirtualLockbuffers, 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::STRONGfor high-value keys. FileBackend::delete's zero-overwrite may not reach physical sectors (SSD FTL, CoW filesystems).Zeroizingis best-effort against compiler optimization and paging.
Root re-exports (crate dig-keystore, importable as dig_keystore):
-
Keystore<K>— §7.SignerHandle<K>— §8.Password— §9. -
KeyScheme,scheme::{BlsSigning, L1WalletBls}— §6. -
KeychainBackend,BackendKey,MemoryBackend;FileBackend(featurefile-backend) — §10. -
KeystoreHeader,KdfParams,KdfId,CipherId,FORMAT_VERSION_V1— §3–4. -
KeystoreError,Result— §11. -
blsmodule — convenience re-exports ofchia_bls::{sign, verify, PublicKey, SecretKey, Signature}so simple consumers need no directchia-blsdependency. -
testingmodule (featuretesting) — re-exportsMemoryBackendand the constantTEST_PASSWORD = "dig-keystore-test-password"for dependent crates' tests. -
opaquemodule —seal,seal_with_rng,open,verify_password,MAGIC,SCHEME_ID— §15.
| Flag | Default | Effect |
|---|---|---|
file-backend |
on | Ships FileBackend. |
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.
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.
| Contract | Must match | Where |
|---|---|---|
| BLS signing algorithm | Chia's BLS12-381 AugScheme (chia-bls 0.26; 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 |
| # | 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-15 | No unsafe code anywhere in the crate |
§13.2 |
| 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 |
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.
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).
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).
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;sealMUST acceptsecretof any length, including empty, and MUST NOT truncate, pad, or otherwise transform it —openMUST recover the exact original bytes.sealuses OS randomness (rand_core::OsRng) for the salt + nonce;seal_with_rngexists for deterministic test fixtures only (production callers MUST useseal).openMUST reject a blob whoseMAGIC/SCHEME_IDare notDIGOP1/0x0004withKeystoreError::SchemeMismatch— including a well-formedDIGVK1/DIGLW1Keystore<K>file. This is the same type-confusion protection §3.3 gives typed schemes.openMUST fail withKeystoreError::DecryptFailedfor a wrong password or any tampering (ciphertext or header/AAD), per the same indistinguishability rule as §5.opaquehas noKeychainBackendconcept — it is pure bytes-in/bytes-out. Callers own storage (a file, a database row,chrome.storage.local, …) themselves.verify_passwordMUST run the full KDF + AEAD verification and return only abool, never exposing the secret on success or failure.
| # | 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).
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).
| 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.
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, callsopaque::seal_with_rngdirectly), andwasm/tests/opaque_wasm.rs(wasm-bindgen-test, callssealWithSeed),
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.
wasm/package.jsonis a private, non-published dev harness (wasm-pack build/testscript wrapper). The PUBLISHED package iswasm/pkg/package.json, generated bywasm-pack build --target bundlerfromwasm/Cargo.toml, then rewritten bywasm/scripts/patch-pkg.mjsto the scoped name@dignetwork/dig-keystore-wasmwithpublishConfig.access = "public"..github/workflows/publish-npm.ymlbuilds + publishes on av*tag push, a published GitHub Release, or manual dispatch, authenticating via npm Trusted Publishing (OIDC) — noNPM_TOKENsecret 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.1publish-npmrun authenticated fine and still got404 Not Found - PUT .../@dignetwork%2fdig-keystore-wasm) — an org-admin bootstrap (one manual authenticatednpm publishto create the package) is needed before OIDC publishing can take over. This does NOT block consumingdig-keystore-wasm: it is buildable and usable as a git/path dependency (wasm-pack buildlocally, or vendoring the builtwasm/pkgoutput into a consuming repo as a local/file:dependency) in the interim, the same stopgap@dignetwork/chia-providerand@dignetwork/chip35-dl-coin-wasmuse. The dig-chrome-extension (dig_ecosystem #147 Phase B) vendors the builtpkg/output this way.
| # | 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.
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.
-
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".
- Host capability — the tier every newly written blob will receive (
-
The wrapping key MUST be non-exportable. A provider MUST NOT report
KeyCustody::NonExportableunless 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.
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:
- With no provider, resolve
Software(NotRequested). - Otherwise probe the host. The probe MUST return one of three answers:
Available(kind),Absent(a confident negative), orIndeterminate(the inspection itself failed — an error, a timeout, or an empty or unintelligible response). A probe MUST NOT reportAbsentwhen it meansIndeterminate. - 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 notNonExportable; 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 yieldsHardwareUnusable, never a hardware tier. - 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.
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_KINDvalues are append-only: an id MUST NOT be renumbered or repurposed, because it is recorded in permanent at-rest data. - An unassigned
HW_KINDMUST 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 andWRAPPED_KEY) MUST be supplied as AES-GCM associated data at encrypt time, and the exact bytes read MUST be replayed at decrypt time. RelabellingHW_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 = 0MUST be rejected: an envelope with no wrapped key asserts that no hardware key protects it, and MUST NOT decode as a hardware envelope.
- 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 requireHW_KINDto 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_tierMUST 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.
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 are planned for a hardware/ workspace member that will mirror the wasm/ split
(§16), tracked as dig_ecosystem #1693.
No provider ships as of 0.5.0. This section specifies the contract a provider MUST
satisfy; it does not describe shipped code. A caller that supplies none resolves
Software(NotRequested): honest, and explicitly not a claim of hardware protection.
| # | 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 |
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).