dig-keystore 0.1.2

Encrypted secret-key storage for DIG Network binaries (BLS signing + L1 wallet keys). AES-256-GCM + Argon2id, typed per-scheme magic files, zeroizing memory hygiene.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
//! The typed [`Keystore`] — the crate's primary entry point.
//!
//! # Responsibilities
//!
//! [`Keystore<K>`] is the orchestration layer that composes [`crate::format`],
//! [`crate::kdf`], [`crate::cipher`], [`crate::scheme`], and [`crate::backend`]
//! into a user-friendly API. It is a thin type; the cryptographic weight
//! lives in the modules it calls:
//!
//! ```text
//!                        Keystore<K>  (this module)
//!                       /    |    \
//!               create/     |      \ unlock/
//!          change_password  |       rotate_kdf
//!                  ▼        ▼        ▼
//!     ┌─────────────────────────────────────────┐
//!     │ kdf::derive_key   (Argon2id)            │  ← 0.5s bottleneck
//!     │ cipher::encrypt   (AES-256-GCM)         │
//!     │ cipher::decrypt   (AES-256-GCM)         │
//!     │ format::encode_file / decode_file       │
//!     │ KeyScheme::public_key / sign            │
//!     │ KeychainBackend::read / write / delete  │
//!     └─────────────────────────────────────────┘
//! ```
//!
//! # Lifecycle
//!
//! ```text
//!   create(password, seed?) ──► encrypted file on backend ──► Keystore
//!//!                                                   load(backend, path) ──► Keystore
//!//!                      unlock(password) ──► SignerHandle<K>       │
//!                      change_password(old, new)                  │
//!                      rotate_kdf(password, new_params)           │
//!                      delete(self) ──► file removed              │
//! ```
//!
//! # Threading / concurrency
//!
//! `Keystore<K>` is `Send + Sync`. Internally it holds
//! `Arc<dyn KeychainBackend>` (shareable across threads) and a
//! `parking_lot::Mutex<Option<K::PublicKey>>` for the cached public key.
//! `unlock` re-reads the file on every call, so a concurrent
//! `change_password` is picked up automatically.
//!
//! # Why re-read on unlock
//!
//! Every `unlock` reads the full file, checks CRC + magic + scheme, decrypts.
//! This is ~0.5s (dominated by Argon2id) and incurs a filesystem read, but:
//!
//! - Makes concurrent password rotation safe without an explicit lock.
//! - Catches any external tampering since the last unlock (paranoid but cheap).
//! - Avoids a subtle invariant: "in-memory header agrees with disk header".
//!
//! If a binary unlocks hundreds of times per second (unusual — validator
//! duty loops unlock once at startup), share the returned
//! [`SignerHandle`](crate::SignerHandle) via `Arc` instead of re-unlocking.

use std::marker::PhantomData;
use std::sync::Arc;

use rand_core::{CryptoRng, RngCore};
use zeroize::Zeroizing;

use crate::backend::{BackendKey, KeychainBackend};
use crate::cipher;
use crate::error::{KeystoreError, Result};
use crate::format::{
    decode_file, encode_file, CipherId, KdfParams, KeystoreHeader, FORMAT_VERSION_V1,
};
use crate::kdf;
use crate::password::Password;
use crate::scheme::KeyScheme;
use crate::signer::SignerHandle;

/// A typed, encrypted keystore.
///
/// Holds metadata — the on-disk header — but never the plaintext secret until
/// [`unlock`](Keystore::unlock) is called. `unlock` returns a
/// [`SignerHandle<K>`](SignerHandle) that owns a zeroizing copy of the secret.
///
/// # Type parameter
///
/// `K` is the key scheme (see [`crate::scheme`]): typically [`BlsSigning`](crate::BlsSigning)
/// for validator keys, [`L1WalletBls`](crate::L1WalletBls) for Chia L1 wallet keys.
pub struct Keystore<K: KeyScheme> {
    backend: Arc<dyn KeychainBackend>,
    path: BackendKey,
    header: KeystoreHeader,
    // Optional cached public key — only populated if the keystore has been
    // unlocked at least once in this process. Allows metadata queries (e.g.,
    // `dig-validator keys show`) to avoid re-prompting for a password.
    cached_public: parking_lot::Mutex<Option<K::PublicKey>>,
    _marker: PhantomData<fn() -> K>,
}

impl<K: KeyScheme> Keystore<K> {
    // ---------------------------------------------------------------------
    // Constructors
    // ---------------------------------------------------------------------

    /// Create a new keystore on `backend` at `path`.
    ///
    /// - If `plaintext` is `Some`, those bytes are used as the secret (length
    ///   must equal [`K::SECRET_LEN`](KeyScheme::SECRET_LEN)). Callers who
    ///   already hold a seed (e.g., from a BIP-39 mnemonic) pass it here.
    /// - If `plaintext` is `None`, a fresh secret is generated via
    ///   [`K::generate`](KeyScheme::generate) with an OS-seeded RNG.
    ///
    /// Fails with [`KeystoreError::AlreadyExists`] if a blob already exists at
    /// `path` — this refuses to silently overwrite an existing key.
    pub fn create(
        backend: Arc<dyn KeychainBackend>,
        path: BackendKey,
        password: Password,
        plaintext: Option<Zeroizing<Vec<u8>>>,
        kdf_params: KdfParams,
    ) -> Result<Self> {
        Self::create_with_rng(
            backend,
            path,
            password,
            plaintext,
            kdf_params,
            &mut rand_core::OsRng,
        )
    }

    /// Like [`create`](Self::create) but uses a caller-supplied RNG. Primarily
    /// for deterministic test fixtures; **do not** use a predictable RNG for
    /// production keys.
    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> {
        if backend.exists(&path)? {
            return Err(KeystoreError::AlreadyExists(path.as_str().to_string()));
        }

        // Resolve the secret we are encrypting.
        let secret: Zeroizing<Vec<u8>> = match plaintext {
            Some(p) => {
                if p.len() != K::SECRET_LEN {
                    return Err(KeystoreError::InvalidPlaintext {
                        expected: K::SECRET_LEN,
                        got: p.len(),
                    });
                }
                p
            }
            None => K::generate(rng),
        };

        // Confirm the scheme accepts these bytes (derives a valid public key).
        let public = K::public_key(&secret)?;

        // Random salt + nonce.
        let mut salt = [0u8; 16];
        let mut nonce = [0u8; 12];
        rng.fill_bytes(&mut salt);
        rng.fill_bytes(&mut nonce);

        // Provisional header so we can use it as AAD.
        let mut header = KeystoreHeader {
            magic: K::MAGIC,
            format_version: FORMAT_VERSION_V1,
            scheme_id: K::SCHEME_ID,
            kdf: kdf_params,
            cipher: CipherId::Aes256Gcm,
            salt,
            nonce,
            payload_len: 0, // filled in after we know ciphertext length
        };
        // The payload_len field is part of the AAD — we must finalise it
        // before computing the tag. We know the plaintext length and the AES-GCM
        // tag is a fixed 16 bytes, so the payload length is deterministic.
        header.payload_len = (secret.len() + cipher::TAG_SIZE) as u32;

        let enc_key = kdf::derive_key(password.as_bytes(), &header.salt, &header.kdf)?;
        let header_bytes = header.encode();
        let ciphertext_and_tag = cipher::encrypt(&enc_key, &header.nonce, &secret, &header_bytes)?;
        debug_assert_eq!(
            ciphertext_and_tag.len() as u32,
            header.payload_len,
            "ciphertext length invariant violated"
        );

        let file_bytes = encode_file(&header, &ciphertext_and_tag);
        backend.write(&path, &file_bytes)?;

        Ok(Self {
            backend,
            path,
            header,
            cached_public: parking_lot::Mutex::new(Some(public)),
            _marker: PhantomData,
        })
    }

    /// Load an existing keystore. Does NOT decrypt — reads and validates the
    /// header, verifies CRC32, and returns a handle that `unlock` can use.
    pub fn load(backend: Arc<dyn KeychainBackend>, path: BackendKey) -> Result<Self> {
        let bytes = backend.read(&path)?;
        let (header, _ciphertext_and_tag, _header_bytes) = decode_file(&bytes)?;

        // Check magic matches the requested scheme.
        if header.magic != K::MAGIC {
            return Err(KeystoreError::SchemeMismatch {
                expected: K::SCHEME_ID,
                expected_name: K::NAME,
                found: header.scheme_id,
            });
        }
        if header.scheme_id != K::SCHEME_ID {
            return Err(KeystoreError::SchemeMismatch {
                expected: K::SCHEME_ID,
                expected_name: K::NAME,
                found: header.scheme_id,
            });
        }

        Ok(Self {
            backend,
            path,
            header,
            cached_public: parking_lot::Mutex::new(None),
            _marker: PhantomData,
        })
    }

    // ---------------------------------------------------------------------
    // Accessors
    // ---------------------------------------------------------------------

    /// Header metadata (magic, scheme id, KDF params, etc).
    pub fn header(&self) -> KeystoreHeader {
        self.header
    }

    /// Backend key this keystore was loaded from.
    pub fn path(&self) -> &BackendKey {
        &self.path
    }

    /// If the keystore has been unlocked in this process, returns the cached
    /// public key. Otherwise `None`.
    pub fn cached_public_key(&self) -> Option<K::PublicKey> {
        self.cached_public.lock().clone()
    }

    // ---------------------------------------------------------------------
    // Core operations
    // ---------------------------------------------------------------------

    /// Decrypt with `password` and return a [`SignerHandle`] holding the
    /// zeroizing secret + derived public key.
    ///
    /// # Errors
    ///
    /// - [`KeystoreError::DecryptFailed`] for a wrong password or a tampered file.
    /// - [`KeystoreError::CrcMismatch`] / [`KeystoreError::Truncated`] for a corrupt file.
    /// - [`KeystoreError::InvalidPlaintext`] if the decrypted secret has the wrong length.
    pub fn unlock(&self, password: Password) -> Result<SignerHandle<K>> {
        // Re-read the file so concurrent rotations are picked up.
        let bytes = self.backend.read(&self.path)?;
        let (header, ciphertext_and_tag, header_bytes) = decode_file(&bytes)?;

        if header.magic != K::MAGIC || header.scheme_id != K::SCHEME_ID {
            return Err(KeystoreError::SchemeMismatch {
                expected: K::SCHEME_ID,
                expected_name: K::NAME,
                found: header.scheme_id,
            });
        }

        let enc_key = kdf::derive_key(password.as_bytes(), &header.salt, &header.kdf)?;
        let plaintext =
            cipher::decrypt(&enc_key, &header.nonce, &ciphertext_and_tag, &header_bytes)?;

        if plaintext.len() != K::SECRET_LEN {
            return Err(KeystoreError::InvalidPlaintext {
                expected: K::SECRET_LEN,
                got: plaintext.len(),
            });
        }

        let public = K::public_key(&plaintext)?;
        *self.cached_public.lock() = Some(public.clone());
        Ok(SignerHandle::from_parts(plaintext, public))
    }

    /// Re-encrypt the secret under a new password. The secret itself does not
    /// change; only the encryption key derived from the password. A fresh
    /// salt + nonce are generated so the output ciphertext differs even with
    /// the same password.
    pub fn change_password(&mut self, old: Password, new: Password) -> Result<()> {
        self.change_password_with_rng(old, new, &mut rand_core::OsRng)
    }

    /// Like [`change_password`](Self::change_password) but uses a caller-supplied RNG.
    pub fn change_password_with_rng<R: RngCore + CryptoRng>(
        &mut self,
        old: Password,
        new: Password,
        rng: &mut R,
    ) -> Result<()> {
        // Decrypt with the old password.
        let bytes = self.backend.read(&self.path)?;
        let (_header, ciphertext_and_tag, header_bytes) = decode_file(&bytes)?;
        let old_key = kdf::derive_key(old.as_bytes(), &self.header.salt, &self.header.kdf)?;
        let plaintext = cipher::decrypt(
            &old_key,
            &self.header.nonce,
            &ciphertext_and_tag,
            &header_bytes,
        )?;

        // Re-encrypt with the new password under a fresh salt + nonce.
        let mut salt = [0u8; 16];
        let mut nonce = [0u8; 12];
        rng.fill_bytes(&mut salt);
        rng.fill_bytes(&mut nonce);

        let mut new_header = self.header;
        new_header.salt = salt;
        new_header.nonce = nonce;
        new_header.payload_len = (plaintext.len() + cipher::TAG_SIZE) as u32;

        let new_key = kdf::derive_key(new.as_bytes(), &salt, &new_header.kdf)?;
        let new_header_bytes = new_header.encode();
        let new_ct = cipher::encrypt(&new_key, &nonce, &plaintext, &new_header_bytes)?;
        let new_file = encode_file(&new_header, &new_ct);
        self.backend.write(&self.path, &new_file)?;

        self.header = new_header;
        Ok(())
    }

    /// Rotate the KDF parameters (e.g., bump to `KdfParams::STRONG`). Uses the
    /// same password throughout; the on-disk file is re-encrypted under a new
    /// salt + nonce.
    pub fn rotate_kdf(&mut self, password: Password, new_params: KdfParams) -> Result<()> {
        self.rotate_kdf_with_rng(password, new_params, &mut rand_core::OsRng)
    }

    /// Like [`rotate_kdf`](Self::rotate_kdf) but uses a caller-supplied RNG.
    pub fn rotate_kdf_with_rng<R: RngCore + CryptoRng>(
        &mut self,
        password: Password,
        new_params: KdfParams,
        rng: &mut R,
    ) -> Result<()> {
        let bytes = self.backend.read(&self.path)?;
        let (_header, ciphertext_and_tag, header_bytes) = decode_file(&bytes)?;
        let old_key = kdf::derive_key(password.as_bytes(), &self.header.salt, &self.header.kdf)?;
        let plaintext = cipher::decrypt(
            &old_key,
            &self.header.nonce,
            &ciphertext_and_tag,
            &header_bytes,
        )?;

        let mut salt = [0u8; 16];
        let mut nonce = [0u8; 12];
        rng.fill_bytes(&mut salt);
        rng.fill_bytes(&mut nonce);

        let mut new_header = self.header;
        new_header.kdf = new_params;
        new_header.salt = salt;
        new_header.nonce = nonce;
        new_header.payload_len = (plaintext.len() + cipher::TAG_SIZE) as u32;

        let new_key = kdf::derive_key(password.as_bytes(), &salt, &new_params)?;
        let new_header_bytes = new_header.encode();
        let new_ct = cipher::encrypt(&new_key, &nonce, &plaintext, &new_header_bytes)?;
        let new_file = encode_file(&new_header, &new_ct);
        self.backend.write(&self.path, &new_file)?;

        self.header = new_header;
        Ok(())
    }

    /// Remove the encrypted blob.
    pub fn delete(self) -> Result<()> {
        self.backend.delete(&self.path)
    }
}

impl<K: KeyScheme> std::fmt::Debug for Keystore<K> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Keystore")
            .field("scheme", &K::NAME)
            .field("path", &self.path)
            .field("kdf", &self.header.kdf)
            .finish()
    }
}