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}