Skip to main content

dig_keystore/custody/
keystore.rs

1//! The typed [`Keystore`] — the crate's primary entry point.
2//!
3//! # Responsibilities
4//!
5//! [`Keystore<K>`] is the orchestration layer that composes [`crate::format`],
6//! [`crate::kdf`], [`crate::cipher`], [`crate::custody::scheme`], and [`crate::backend`]
7//! into a user-friendly API. It is a thin type; the cryptographic weight
8//! lives in the modules it calls:
9//!
10//! ```text
11//!                        Keystore<K>  (this module)
12//!                       /    |    \
13//!               create/     |      \ unlock/
14//!          change_password  |       rotate_kdf
15//!                  ▼        ▼        ▼
16//!     ┌─────────────────────────────────────────┐
17//!     │ kdf::derive_key   (Argon2id)            │  ← 0.5s bottleneck
18//!     │ cipher::encrypt   (AES-256-GCM)         │
19//!     │ cipher::decrypt   (AES-256-GCM)         │
20//!     │ format::encode_file / decode_file       │
21//!     │ KeyScheme::public_key / sign            │
22//!     │ KeychainBackend::read / write / delete  │
23//!     └─────────────────────────────────────────┘
24//! ```
25//!
26//! # Lifecycle
27//!
28//! ```text
29//!   create(password, seed?) ──► encrypted file on backend ──► Keystore
30//!                                                                 │
31//!                                                   load(backend, path) ──► Keystore
32//!                                                                 │
33//!                      unlock(password) ──► SignerHandle<K>       │
34//!                      change_password(old, new)                  │
35//!                      rotate_kdf(password, new_params)           │
36//!                      delete(self) ──► file removed              │
37//! ```
38//!
39//! # Threading / concurrency
40//!
41//! `Keystore<K>` is `Send + Sync`. Internally it holds
42//! `Arc<dyn KeychainBackend>` (shareable across threads) and a
43//! `parking_lot::Mutex<Option<K::PublicKey>>` for the cached public key.
44//! `unlock` re-reads the file on every call, so a concurrent
45//! `change_password` is picked up automatically.
46//!
47//! # Why re-read on unlock
48//!
49//! Every `unlock` reads the full file, checks CRC + magic + scheme, decrypts.
50//! This is ~0.5s (dominated by Argon2id) and incurs a filesystem read, but:
51//!
52//! - Makes concurrent password rotation safe without an explicit lock.
53//! - Catches any external tampering since the last unlock (paranoid but cheap).
54//! - Avoids a subtle invariant: "in-memory header agrees with disk header".
55//!
56//! If a binary unlocks hundreds of times per second (unusual — validator
57//! duty loops unlock once at startup), share the returned
58//! [`SignerHandle`](crate::SignerHandle) via `Arc` instead of re-unlocking.
59
60use std::marker::PhantomData;
61use std::sync::Arc;
62
63use rand_core::{CryptoRng, RngCore};
64use zeroize::Zeroizing;
65
66use crate::backend::{BackendKey, KeychainBackend};
67use crate::cipher;
68use crate::custody::scheme::KeyScheme;
69use crate::custody::signer::SignerHandle;
70use crate::error::{KeystoreError, Result};
71use crate::format::{
72    decode_file, encode_file, CipherId, KdfParams, KeystoreHeader, FORMAT_VERSION_V1,
73};
74use crate::kdf;
75use crate::password::Password;
76
77/// A typed, encrypted keystore.
78///
79/// Holds metadata — the on-disk header — but never the plaintext secret until
80/// [`unlock`](Keystore::unlock) is called. `unlock` returns a
81/// [`SignerHandle<K>`](SignerHandle) that owns a zeroizing copy of the secret.
82///
83/// # Type parameter
84///
85/// `K` is the key scheme (see [`crate::custody::scheme`]): typically [`BlsSigning`](crate::BlsSigning)
86/// for validator keys, [`L1WalletBls`](crate::L1WalletBls) for Chia L1 wallet keys.
87pub struct Keystore<K: KeyScheme> {
88    backend: Arc<dyn KeychainBackend>,
89    path: BackendKey,
90    header: KeystoreHeader,
91    // Optional cached public key — only populated if the keystore has been
92    // unlocked at least once in this process. Allows metadata queries (e.g.,
93    // `dig-validator keys show`) to avoid re-prompting for a password.
94    cached_public: parking_lot::Mutex<Option<K::PublicKey>>,
95    _marker: PhantomData<fn() -> K>,
96}
97
98impl<K: KeyScheme> Keystore<K> {
99    // ---------------------------------------------------------------------
100    // Constructors
101    // ---------------------------------------------------------------------
102
103    /// Create a new keystore on `backend` at `path`.
104    ///
105    /// - If `plaintext` is `Some`, those bytes are used as the secret (length
106    ///   must equal [`K::SECRET_LEN`](KeyScheme::SECRET_LEN)). Callers who
107    ///   already hold a seed (e.g., from a BIP-39 mnemonic) pass it here.
108    /// - If `plaintext` is `None`, a fresh secret is generated via
109    ///   [`K::generate`](KeyScheme::generate) with an OS-seeded RNG.
110    ///
111    /// Fails with [`KeystoreError::AlreadyExists`] if a blob already exists at
112    /// `path` — this refuses to silently overwrite an existing key.
113    ///
114    /// # Exclusivity under contention
115    ///
116    /// The refusal is carried by a single
117    /// [`write_new`](KeychainBackend::write_new), so on a backend reporting
118    /// [`Exclusivity::Atomic`](crate::backend::Exclusivity::Atomic) —
119    /// `FileBackend` and `MemoryBackend` — two concurrent mints of the same
120    /// path **cannot** both establish one: exactly one succeeds and the rest get
121    /// `AlreadyExists`, which a caller can adopt by loading what the winner
122    /// established.
123    ///
124    /// On a backend reporting
125    /// [`BestEffort`](crate::backend::Exclusivity::BestEffort) — today that is
126    /// `OsKeychainBackend`, whose credential store offers no create-if-absent
127    /// primitive — a residual race remains and is **not** closed by this method.
128    /// A caller minting concurrently against an OS credential store should
129    /// serialise the mint itself. See `SPEC.md` §7.1.
130    pub fn create(
131        backend: Arc<dyn KeychainBackend>,
132        path: BackendKey,
133        password: Password,
134        plaintext: Option<Zeroizing<Vec<u8>>>,
135        kdf_params: KdfParams,
136    ) -> Result<Self> {
137        Self::create_with_rng(
138            backend,
139            path,
140            password,
141            plaintext,
142            kdf_params,
143            &mut rand_core::OsRng,
144        )
145    }
146
147    /// Like [`create`](Self::create) but uses a caller-supplied RNG. Primarily
148    /// for deterministic test fixtures; **do not** use a predictable RNG for
149    /// production keys.
150    pub fn create_with_rng<R: RngCore + CryptoRng>(
151        backend: Arc<dyn KeychainBackend>,
152        path: BackendKey,
153        password: Password,
154        plaintext: Option<Zeroizing<Vec<u8>>>,
155        kdf_params: KdfParams,
156        rng: &mut R,
157    ) -> Result<Self> {
158        // Resolve the secret we are encrypting.
159        let secret: Zeroizing<Vec<u8>> = match plaintext {
160            Some(p) => {
161                if p.len() != K::SECRET_LEN {
162                    return Err(KeystoreError::InvalidPlaintext {
163                        expected: K::SECRET_LEN,
164                        got: p.len(),
165                    });
166                }
167                p
168            }
169            None => K::generate(rng),
170        };
171
172        // Confirm the scheme accepts these bytes (derives a valid public key).
173        let public = K::public_key(&secret)?;
174
175        // Random salt + nonce.
176        let mut salt = [0u8; 16];
177        let mut nonce = [0u8; 12];
178        rng.fill_bytes(&mut salt);
179        rng.fill_bytes(&mut nonce);
180
181        // Provisional header so we can use it as AAD.
182        let mut header = KeystoreHeader {
183            magic: K::MAGIC,
184            format_version: FORMAT_VERSION_V1,
185            scheme_id: K::SCHEME_ID,
186            kdf: kdf_params,
187            cipher: CipherId::Aes256Gcm,
188            salt,
189            nonce,
190            payload_len: 0, // filled in after we know ciphertext length
191        };
192        // The payload_len field is part of the AAD — we must finalise it
193        // before computing the tag. We know the plaintext length and the AES-GCM
194        // tag is a fixed 16 bytes, so the payload length is deterministic.
195        header.payload_len = (secret.len() + cipher::TAG_SIZE) as u32;
196
197        let enc_key = kdf::derive_key(password.as_bytes(), &header.salt, &header.kdf)?;
198        let header_bytes = header.encode();
199        let ciphertext_and_tag = cipher::encrypt(&enc_key, &header.nonce, &secret, &header_bytes)?;
200        debug_assert_eq!(
201            ciphertext_and_tag.len() as u32,
202            header.payload_len,
203            "ciphertext length invariant violated"
204        );
205
206        let file_bytes = encode_file(&header, &ciphertext_and_tag);
207        // `write_new`, never `write`: the single call carries the whole
208        // exclusivity guarantee. The check-then-write this replaced —
209        // `exists()` and then a replace-semantics `write` — held an
210        // Argon2id derivation between the observation and the act, so two
211        // racers routinely both observed an absence and both wrote, and the
212        // loser's blob landed on top of the winner's. Measured at 16 winners of
213        // 16 (`tests/mint_exclusivity.rs`).
214        backend.write_new(&path, &file_bytes)?;
215
216        Ok(Self {
217            backend,
218            path,
219            header,
220            cached_public: parking_lot::Mutex::new(Some(public)),
221            _marker: PhantomData,
222        })
223    }
224
225    /// Load an existing keystore. Does NOT decrypt — reads and validates the
226    /// header, verifies CRC32, and returns a handle that `unlock` can use.
227    pub fn load(backend: Arc<dyn KeychainBackend>, path: BackendKey) -> Result<Self> {
228        let bytes = backend.read(&path)?;
229        let (header, _ciphertext_and_tag, _header_bytes) = decode_file(&bytes)?;
230
231        // Check magic matches the requested scheme.
232        if header.magic != K::MAGIC {
233            return Err(KeystoreError::SchemeMismatch {
234                expected: K::SCHEME_ID,
235                expected_name: K::NAME,
236                found: header.scheme_id,
237            });
238        }
239        if header.scheme_id != K::SCHEME_ID {
240            return Err(KeystoreError::SchemeMismatch {
241                expected: K::SCHEME_ID,
242                expected_name: K::NAME,
243                found: header.scheme_id,
244            });
245        }
246
247        Ok(Self {
248            backend,
249            path,
250            header,
251            cached_public: parking_lot::Mutex::new(None),
252            _marker: PhantomData,
253        })
254    }
255
256    // ---------------------------------------------------------------------
257    // Accessors
258    // ---------------------------------------------------------------------
259
260    /// Header metadata (magic, scheme id, KDF params, etc).
261    pub fn header(&self) -> KeystoreHeader {
262        self.header
263    }
264
265    /// Backend key this keystore was loaded from.
266    pub fn path(&self) -> &BackendKey {
267        &self.path
268    }
269
270    /// If the keystore has been unlocked in this process, returns the cached
271    /// public key. Otherwise `None`.
272    pub fn cached_public_key(&self) -> Option<K::PublicKey> {
273        self.cached_public.lock().clone()
274    }
275
276    // ---------------------------------------------------------------------
277    // Core operations
278    // ---------------------------------------------------------------------
279
280    /// Decrypt with `password` and return a [`SignerHandle`] holding the
281    /// zeroizing secret + derived public key.
282    ///
283    /// # Errors
284    ///
285    /// - [`KeystoreError::DecryptFailed`] for a wrong password or a tampered file.
286    /// - [`KeystoreError::CrcMismatch`] / [`KeystoreError::Truncated`] for a corrupt file.
287    /// - [`KeystoreError::InvalidPlaintext`] if the decrypted secret has the wrong length.
288    pub fn unlock(&self, password: Password) -> Result<SignerHandle<K>> {
289        // Re-read the file so concurrent rotations are picked up.
290        let bytes = self.backend.read(&self.path)?;
291        let (header, ciphertext_and_tag, header_bytes) = decode_file(&bytes)?;
292
293        if header.magic != K::MAGIC || header.scheme_id != K::SCHEME_ID {
294            return Err(KeystoreError::SchemeMismatch {
295                expected: K::SCHEME_ID,
296                expected_name: K::NAME,
297                found: header.scheme_id,
298            });
299        }
300
301        let enc_key = kdf::derive_key(password.as_bytes(), &header.salt, &header.kdf)?;
302        let plaintext =
303            cipher::decrypt(&enc_key, &header.nonce, &ciphertext_and_tag, &header_bytes)?;
304
305        if plaintext.len() != K::SECRET_LEN {
306            return Err(KeystoreError::InvalidPlaintext {
307                expected: K::SECRET_LEN,
308                got: plaintext.len(),
309            });
310        }
311
312        let public = K::public_key(&plaintext)?;
313        *self.cached_public.lock() = Some(public.clone());
314        Ok(SignerHandle::from_parts(plaintext, public))
315    }
316
317    /// Re-encrypt the secret under a new password. The secret itself does not
318    /// change; only the encryption key derived from the password. A fresh
319    /// salt + nonce are generated so the output ciphertext differs even with
320    /// the same password.
321    pub fn change_password(&mut self, old: Password, new: Password) -> Result<()> {
322        self.change_password_with_rng(old, new, &mut rand_core::OsRng)
323    }
324
325    /// Like [`change_password`](Self::change_password) but uses a caller-supplied RNG.
326    pub fn change_password_with_rng<R: RngCore + CryptoRng>(
327        &mut self,
328        old: Password,
329        new: Password,
330        rng: &mut R,
331    ) -> Result<()> {
332        // Decrypt with the old password.
333        let bytes = self.backend.read(&self.path)?;
334        let (_header, ciphertext_and_tag, header_bytes) = decode_file(&bytes)?;
335        let old_key = kdf::derive_key(old.as_bytes(), &self.header.salt, &self.header.kdf)?;
336        let plaintext = cipher::decrypt(
337            &old_key,
338            &self.header.nonce,
339            &ciphertext_and_tag,
340            &header_bytes,
341        )?;
342
343        // Re-encrypt with the new password under a fresh salt + nonce.
344        let mut salt = [0u8; 16];
345        let mut nonce = [0u8; 12];
346        rng.fill_bytes(&mut salt);
347        rng.fill_bytes(&mut nonce);
348
349        let mut new_header = self.header;
350        new_header.salt = salt;
351        new_header.nonce = nonce;
352        new_header.payload_len = (plaintext.len() + cipher::TAG_SIZE) as u32;
353
354        let new_key = kdf::derive_key(new.as_bytes(), &salt, &new_header.kdf)?;
355        let new_header_bytes = new_header.encode();
356        let new_ct = cipher::encrypt(&new_key, &nonce, &plaintext, &new_header_bytes)?;
357        let new_file = encode_file(&new_header, &new_ct);
358        self.backend.write(&self.path, &new_file)?;
359
360        self.header = new_header;
361        Ok(())
362    }
363
364    /// Rotate the KDF parameters (e.g., bump to `KdfParams::STRONG`). Uses the
365    /// same password throughout; the on-disk file is re-encrypted under a new
366    /// salt + nonce.
367    pub fn rotate_kdf(&mut self, password: Password, new_params: KdfParams) -> Result<()> {
368        self.rotate_kdf_with_rng(password, new_params, &mut rand_core::OsRng)
369    }
370
371    /// Like [`rotate_kdf`](Self::rotate_kdf) but uses a caller-supplied RNG.
372    pub fn rotate_kdf_with_rng<R: RngCore + CryptoRng>(
373        &mut self,
374        password: Password,
375        new_params: KdfParams,
376        rng: &mut R,
377    ) -> Result<()> {
378        let bytes = self.backend.read(&self.path)?;
379        let (_header, ciphertext_and_tag, header_bytes) = decode_file(&bytes)?;
380        let old_key = kdf::derive_key(password.as_bytes(), &self.header.salt, &self.header.kdf)?;
381        let plaintext = cipher::decrypt(
382            &old_key,
383            &self.header.nonce,
384            &ciphertext_and_tag,
385            &header_bytes,
386        )?;
387
388        let mut salt = [0u8; 16];
389        let mut nonce = [0u8; 12];
390        rng.fill_bytes(&mut salt);
391        rng.fill_bytes(&mut nonce);
392
393        let mut new_header = self.header;
394        new_header.kdf = new_params;
395        new_header.salt = salt;
396        new_header.nonce = nonce;
397        new_header.payload_len = (plaintext.len() + cipher::TAG_SIZE) as u32;
398
399        let new_key = kdf::derive_key(password.as_bytes(), &salt, &new_params)?;
400        let new_header_bytes = new_header.encode();
401        let new_ct = cipher::encrypt(&new_key, &nonce, &plaintext, &new_header_bytes)?;
402        let new_file = encode_file(&new_header, &new_ct);
403        self.backend.write(&self.path, &new_file)?;
404
405        self.header = new_header;
406        Ok(())
407    }
408
409    /// Remove the encrypted blob.
410    pub fn delete(self) -> Result<()> {
411        self.backend.delete(&self.path)
412    }
413}
414
415impl<K: KeyScheme> std::fmt::Debug for Keystore<K> {
416    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
417        f.debug_struct("Keystore")
418            .field("scheme", &K::NAME)
419            .field("path", &self.path)
420            .field("kdf", &self.header.kdf)
421            .finish()
422    }
423}