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}