Skip to main content

dig_keystore/
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::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::error::{KeystoreError, Result};
69use crate::format::{
70    decode_file, encode_file, CipherId, KdfParams, KeystoreHeader, FORMAT_VERSION_V1,
71};
72use crate::kdf;
73use crate::password::Password;
74use crate::scheme::KeyScheme;
75use crate::signer::SignerHandle;
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::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    pub fn create(
114        backend: Arc<dyn KeychainBackend>,
115        path: BackendKey,
116        password: Password,
117        plaintext: Option<Zeroizing<Vec<u8>>>,
118        kdf_params: KdfParams,
119    ) -> Result<Self> {
120        Self::create_with_rng(
121            backend,
122            path,
123            password,
124            plaintext,
125            kdf_params,
126            &mut rand_core::OsRng,
127        )
128    }
129
130    /// Like [`create`](Self::create) but uses a caller-supplied RNG. Primarily
131    /// for deterministic test fixtures; **do not** use a predictable RNG for
132    /// production keys.
133    pub fn create_with_rng<R: RngCore + CryptoRng>(
134        backend: Arc<dyn KeychainBackend>,
135        path: BackendKey,
136        password: Password,
137        plaintext: Option<Zeroizing<Vec<u8>>>,
138        kdf_params: KdfParams,
139        rng: &mut R,
140    ) -> Result<Self> {
141        if backend.exists(&path)? {
142            return Err(KeystoreError::AlreadyExists(path.as_str().to_string()));
143        }
144
145        // Resolve the secret we are encrypting.
146        let secret: Zeroizing<Vec<u8>> = match plaintext {
147            Some(p) => {
148                if p.len() != K::SECRET_LEN {
149                    return Err(KeystoreError::InvalidPlaintext {
150                        expected: K::SECRET_LEN,
151                        got: p.len(),
152                    });
153                }
154                p
155            }
156            None => K::generate(rng),
157        };
158
159        // Confirm the scheme accepts these bytes (derives a valid public key).
160        let public = K::public_key(&secret)?;
161
162        // Random salt + nonce.
163        let mut salt = [0u8; 16];
164        let mut nonce = [0u8; 12];
165        rng.fill_bytes(&mut salt);
166        rng.fill_bytes(&mut nonce);
167
168        // Provisional header so we can use it as AAD.
169        let mut header = KeystoreHeader {
170            magic: K::MAGIC,
171            format_version: FORMAT_VERSION_V1,
172            scheme_id: K::SCHEME_ID,
173            kdf: kdf_params,
174            cipher: CipherId::Aes256Gcm,
175            salt,
176            nonce,
177            payload_len: 0, // filled in after we know ciphertext length
178        };
179        // The payload_len field is part of the AAD — we must finalise it
180        // before computing the tag. We know the plaintext length and the AES-GCM
181        // tag is a fixed 16 bytes, so the payload length is deterministic.
182        header.payload_len = (secret.len() + cipher::TAG_SIZE) as u32;
183
184        let enc_key = kdf::derive_key(password.as_bytes(), &header.salt, &header.kdf)?;
185        let header_bytes = header.encode();
186        let ciphertext_and_tag = cipher::encrypt(&enc_key, &header.nonce, &secret, &header_bytes)?;
187        debug_assert_eq!(
188            ciphertext_and_tag.len() as u32,
189            header.payload_len,
190            "ciphertext length invariant violated"
191        );
192
193        let file_bytes = encode_file(&header, &ciphertext_and_tag);
194        backend.write(&path, &file_bytes)?;
195
196        Ok(Self {
197            backend,
198            path,
199            header,
200            cached_public: parking_lot::Mutex::new(Some(public)),
201            _marker: PhantomData,
202        })
203    }
204
205    /// Load an existing keystore. Does NOT decrypt — reads and validates the
206    /// header, verifies CRC32, and returns a handle that `unlock` can use.
207    pub fn load(backend: Arc<dyn KeychainBackend>, path: BackendKey) -> Result<Self> {
208        let bytes = backend.read(&path)?;
209        let (header, _ciphertext_and_tag, _header_bytes) = decode_file(&bytes)?;
210
211        // Check magic matches the requested scheme.
212        if header.magic != K::MAGIC {
213            return Err(KeystoreError::SchemeMismatch {
214                expected: K::SCHEME_ID,
215                expected_name: K::NAME,
216                found: header.scheme_id,
217            });
218        }
219        if header.scheme_id != K::SCHEME_ID {
220            return Err(KeystoreError::SchemeMismatch {
221                expected: K::SCHEME_ID,
222                expected_name: K::NAME,
223                found: header.scheme_id,
224            });
225        }
226
227        Ok(Self {
228            backend,
229            path,
230            header,
231            cached_public: parking_lot::Mutex::new(None),
232            _marker: PhantomData,
233        })
234    }
235
236    // ---------------------------------------------------------------------
237    // Accessors
238    // ---------------------------------------------------------------------
239
240    /// Header metadata (magic, scheme id, KDF params, etc).
241    pub fn header(&self) -> KeystoreHeader {
242        self.header
243    }
244
245    /// Backend key this keystore was loaded from.
246    pub fn path(&self) -> &BackendKey {
247        &self.path
248    }
249
250    /// If the keystore has been unlocked in this process, returns the cached
251    /// public key. Otherwise `None`.
252    pub fn cached_public_key(&self) -> Option<K::PublicKey> {
253        self.cached_public.lock().clone()
254    }
255
256    // ---------------------------------------------------------------------
257    // Core operations
258    // ---------------------------------------------------------------------
259
260    /// Decrypt with `password` and return a [`SignerHandle`] holding the
261    /// zeroizing secret + derived public key.
262    ///
263    /// # Errors
264    ///
265    /// - [`KeystoreError::DecryptFailed`] for a wrong password or a tampered file.
266    /// - [`KeystoreError::CrcMismatch`] / [`KeystoreError::Truncated`] for a corrupt file.
267    /// - [`KeystoreError::InvalidPlaintext`] if the decrypted secret has the wrong length.
268    pub fn unlock(&self, password: Password) -> Result<SignerHandle<K>> {
269        // Re-read the file so concurrent rotations are picked up.
270        let bytes = self.backend.read(&self.path)?;
271        let (header, ciphertext_and_tag, header_bytes) = decode_file(&bytes)?;
272
273        if header.magic != K::MAGIC || header.scheme_id != K::SCHEME_ID {
274            return Err(KeystoreError::SchemeMismatch {
275                expected: K::SCHEME_ID,
276                expected_name: K::NAME,
277                found: header.scheme_id,
278            });
279        }
280
281        let enc_key = kdf::derive_key(password.as_bytes(), &header.salt, &header.kdf)?;
282        let plaintext =
283            cipher::decrypt(&enc_key, &header.nonce, &ciphertext_and_tag, &header_bytes)?;
284
285        if plaintext.len() != K::SECRET_LEN {
286            return Err(KeystoreError::InvalidPlaintext {
287                expected: K::SECRET_LEN,
288                got: plaintext.len(),
289            });
290        }
291
292        let public = K::public_key(&plaintext)?;
293        *self.cached_public.lock() = Some(public.clone());
294        Ok(SignerHandle::from_parts(plaintext, public))
295    }
296
297    /// Re-encrypt the secret under a new password. The secret itself does not
298    /// change; only the encryption key derived from the password. A fresh
299    /// salt + nonce are generated so the output ciphertext differs even with
300    /// the same password.
301    pub fn change_password(&mut self, old: Password, new: Password) -> Result<()> {
302        self.change_password_with_rng(old, new, &mut rand_core::OsRng)
303    }
304
305    /// Like [`change_password`](Self::change_password) but uses a caller-supplied RNG.
306    pub fn change_password_with_rng<R: RngCore + CryptoRng>(
307        &mut self,
308        old: Password,
309        new: Password,
310        rng: &mut R,
311    ) -> Result<()> {
312        // Decrypt with the old password.
313        let bytes = self.backend.read(&self.path)?;
314        let (_header, ciphertext_and_tag, header_bytes) = decode_file(&bytes)?;
315        let old_key = kdf::derive_key(old.as_bytes(), &self.header.salt, &self.header.kdf)?;
316        let plaintext = cipher::decrypt(
317            &old_key,
318            &self.header.nonce,
319            &ciphertext_and_tag,
320            &header_bytes,
321        )?;
322
323        // Re-encrypt with the new password under a fresh salt + nonce.
324        let mut salt = [0u8; 16];
325        let mut nonce = [0u8; 12];
326        rng.fill_bytes(&mut salt);
327        rng.fill_bytes(&mut nonce);
328
329        let mut new_header = self.header;
330        new_header.salt = salt;
331        new_header.nonce = nonce;
332        new_header.payload_len = (plaintext.len() + cipher::TAG_SIZE) as u32;
333
334        let new_key = kdf::derive_key(new.as_bytes(), &salt, &new_header.kdf)?;
335        let new_header_bytes = new_header.encode();
336        let new_ct = cipher::encrypt(&new_key, &nonce, &plaintext, &new_header_bytes)?;
337        let new_file = encode_file(&new_header, &new_ct);
338        self.backend.write(&self.path, &new_file)?;
339
340        self.header = new_header;
341        Ok(())
342    }
343
344    /// Rotate the KDF parameters (e.g., bump to `KdfParams::STRONG`). Uses the
345    /// same password throughout; the on-disk file is re-encrypted under a new
346    /// salt + nonce.
347    pub fn rotate_kdf(&mut self, password: Password, new_params: KdfParams) -> Result<()> {
348        self.rotate_kdf_with_rng(password, new_params, &mut rand_core::OsRng)
349    }
350
351    /// Like [`rotate_kdf`](Self::rotate_kdf) but uses a caller-supplied RNG.
352    pub fn rotate_kdf_with_rng<R: RngCore + CryptoRng>(
353        &mut self,
354        password: Password,
355        new_params: KdfParams,
356        rng: &mut R,
357    ) -> Result<()> {
358        let bytes = self.backend.read(&self.path)?;
359        let (_header, ciphertext_and_tag, header_bytes) = decode_file(&bytes)?;
360        let old_key = kdf::derive_key(password.as_bytes(), &self.header.salt, &self.header.kdf)?;
361        let plaintext = cipher::decrypt(
362            &old_key,
363            &self.header.nonce,
364            &ciphertext_and_tag,
365            &header_bytes,
366        )?;
367
368        let mut salt = [0u8; 16];
369        let mut nonce = [0u8; 12];
370        rng.fill_bytes(&mut salt);
371        rng.fill_bytes(&mut nonce);
372
373        let mut new_header = self.header;
374        new_header.kdf = new_params;
375        new_header.salt = salt;
376        new_header.nonce = nonce;
377        new_header.payload_len = (plaintext.len() + cipher::TAG_SIZE) as u32;
378
379        let new_key = kdf::derive_key(password.as_bytes(), &salt, &new_params)?;
380        let new_header_bytes = new_header.encode();
381        let new_ct = cipher::encrypt(&new_key, &nonce, &plaintext, &new_header_bytes)?;
382        let new_file = encode_file(&new_header, &new_ct);
383        self.backend.write(&self.path, &new_file)?;
384
385        self.header = new_header;
386        Ok(())
387    }
388
389    /// Remove the encrypted blob.
390    pub fn delete(self) -> Result<()> {
391        self.backend.delete(&self.path)
392    }
393}
394
395impl<K: KeyScheme> std::fmt::Debug for Keystore<K> {
396    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
397        f.debug_struct("Keystore")
398            .field("scheme", &K::NAME)
399            .field("path", &self.path)
400            .field("kdf", &self.header.kdf)
401            .finish()
402    }
403}