Skip to main content

dpp_crypto/keystore/
store.rs

1//! [`KeyStore`] — the encrypted on-disk record map, and its persistence envelope.
2
3use std::collections::HashMap;
4use std::path::Path;
5use std::sync::RwLock;
6
7use aes_gcm::{
8    Aes256Gcm, Nonce,
9    aead::{Aead, KeyInit, consts::U12},
10};
11use anyhow::{Context, Result};
12use ed25519_dalek::SigningKey;
13use rand::Rng;
14use sha2::{Digest, Sha256};
15use zeroize::Zeroize;
16
17use super::crypto::{
18    compute_envelope_hmac, derive_aes_key_argon2, derive_aes_key_sha256, derive_integrity_key,
19    verify_envelope_hmac,
20};
21use super::entry::KeyEntry;
22use crate::jws::algorithm::KeyAlgorithm;
23
24/// Type alias for the key-ID → record map stored in the key store.
25pub(crate) type KeyRecordMap = HashMap<String, KeyRecord>;
26
27/// Salt length for Argon2id key derivation (16 bytes = 128 bits).
28const ARGON2_SALT_LEN: usize = 16;
29
30#[derive(Clone, serde::Serialize, serde::Deserialize)]
31pub(crate) struct KeyRecord {
32    pub(crate) encrypted_signing_key: Vec<u8>,
33    pub(crate) nonce: Vec<u8>,
34    pub(crate) fingerprint: String,
35    pub(crate) verifying_key_hex: String,
36    /// True once the key has been revoked (e.g. on compromise). Revoked keys are
37    /// excluded from the published DID document, so signatures they produced no
38    /// longer verify. Defaults to false (back-compat with pre-revocation stores).
39    #[serde(default)]
40    pub(crate) revoked: bool,
41    /// The signature algorithm this key pair uses. Serialises as its JOSE
42    /// identifier (`"EdDSA"`), so the on-disk shape is unchanged. Defaults for
43    /// back-compat with pre-algorithm-agility stores; an *unrecognised*
44    /// algorithm fails to deserialise rather than loading a key nothing can
45    /// safely use.
46    #[serde(default = "default_algorithm")]
47    pub(crate) algorithm: KeyAlgorithm,
48}
49
50impl KeyRecord {
51    /// Construct a fresh, non-revoked record for a newly generated key pair.
52    pub(crate) fn new(
53        encrypted_signing_key: Vec<u8>,
54        nonce: Vec<u8>,
55        fingerprint: String,
56        verifying_key_hex: String,
57    ) -> Self {
58        Self {
59            encrypted_signing_key,
60            nonce,
61            fingerprint,
62            verifying_key_hex,
63            revoked: false,
64            algorithm: default_algorithm(),
65        }
66    }
67}
68
69pub(crate) fn default_algorithm() -> KeyAlgorithm {
70    KeyAlgorithm::Ed25519
71}
72
73/// A key's public half plus its revocation state, read directly from the
74/// stored record's plaintext `verifying_key_hex`/`revoked` fields — no
75/// private-key decryption involved. For callers (like the `did:web` document
76/// builder in `dpp-vc`) that only ever need the public key, this avoids an
77/// AES-GCM decrypt per key on every call.
78///
79/// `algorithm` travels with the key because a reader cannot otherwise know how
80/// to represent it: the DID-document builder needs it to choose the JWK shape,
81/// and guessing is how a key ends up published under the wrong `kty`.
82#[non_exhaustive]
83pub struct PublicKeyInfo {
84    pub verifying_key_hex: String,
85    pub revoked: bool,
86    pub algorithm: KeyAlgorithm,
87}
88
89impl From<&KeyRecord> for PublicKeyInfo {
90    fn from(record: &KeyRecord) -> Self {
91        Self {
92            verifying_key_hex: record.verifying_key_hex.clone(),
93            revoked: record.revoked,
94            algorithm: record.algorithm,
95        }
96    }
97}
98
99/// On-disk envelope for the key store file.
100///
101/// V2 adds `kdf` and `salt` fields. If `kdf` is missing (V1 format), the
102/// store was encrypted with bare SHA-256 and will be transparently migrated
103/// to Argon2id on next write.
104///
105/// V3 adds `hmac` — an HMAC-SHA256 over the serialised `keys` map, keyed
106/// with a 32-byte integrity key derived separately from the passphrase.
107/// This detects file tampering (swapped keys, modified fingerprints, etc.).
108#[derive(serde::Serialize, serde::Deserialize)]
109struct StoreEnvelope {
110    /// KDF identifier. `"argon2id"` for V2+, absent for V1 (legacy SHA-256).
111    #[serde(default)]
112    kdf: Option<String>,
113    /// Base64-encoded salt used by Argon2id. Absent for V1.
114    #[serde(default)]
115    salt: Option<String>,
116    /// HMAC-SHA256 over the canonical JSON serialisation of `keys`, keyed
117    /// with a passphrase-derived integrity key. Absent for V1/V2 stores
118    /// (will be added on next write).
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    hmac: Option<String>,
121    /// The key records themselves.
122    keys: KeyRecordMap,
123}
124
125/// Thread-safe store that loads, encrypts, and caches Ed25519 signing keys.
126///
127/// Encryption key is derived from a passphrase using Argon2id with a random
128/// 128-bit salt. A separate 32-byte integrity key (derived from the same
129/// passphrase + salt with a different Argon2 context) is used to compute
130/// an HMAC-SHA256 over the serialised key map, protecting against file
131/// tampering. Legacy stores (pre-0.1.0) that used bare SHA-256 are
132/// automatically migrated on first write.
133pub struct KeyStore {
134    pub(crate) path: std::path::PathBuf,
135    pub(crate) cipher: Aes256Gcm,
136    /// 32-byte key used for HMAC-SHA256 file integrity checks.
137    pub(crate) integrity_key: [u8; 32],
138    pub(crate) salt: [u8; ARGON2_SALT_LEN],
139    pub(crate) records: RwLock<KeyRecordMap>,
140    /// True if the store was opened with a legacy SHA-256 derived key and
141    /// needs re-encryption with Argon2id on next write.
142    pub(crate) needs_migration: RwLock<bool>,
143}
144
145impl KeyStore {
146    pub fn open(path: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
147        if path.as_ref().exists() {
148            let bytes = std::fs::read(&path).context("Failed to read key store file")?;
149
150            // Try to deserialize as the V2/V3 envelope first. A legacy V0/V1
151            // store is a raw `{ "key_id": KeyRecord }` map with no envelope
152            // wrapper, so fall back to that shape if the envelope parse fails.
153            let envelope: StoreEnvelope = match serde_json::from_slice(&bytes) {
154                Ok(env) => env,
155                Err(_) => {
156                    let keys: KeyRecordMap = serde_json::from_slice(&bytes)
157                        .context("Failed to deserialise key store")?;
158                    StoreEnvelope {
159                        kdf: None,
160                        salt: None,
161                        hmac: None,
162                        keys,
163                    }
164                }
165            };
166
167            if envelope.kdf.as_deref() == Some("argon2id") {
168                // V2/V3 format — Argon2id.
169                let salt_b64 = envelope.salt.as_deref().ok_or_else(|| {
170                    anyhow::anyhow!("key store has kdf=argon2id but no salt field")
171                })?;
172                let salt_vec =
173                    base64::Engine::decode(&base64::engine::general_purpose::STANDARD, salt_b64)
174                        .context("invalid base64 salt in key store")?;
175                let salt: [u8; ARGON2_SALT_LEN] = salt_vec.as_slice().try_into().map_err(|_| {
176                    anyhow::anyhow!(
177                        "key store salt has wrong length: expected {ARGON2_SALT_LEN}, got {}",
178                        salt_vec.len()
179                    )
180                })?;
181                let cipher_key = derive_aes_key_argon2(passphrase, &salt)?;
182                let cipher = Aes256Gcm::new(&cipher_key);
183                let integrity_key = derive_integrity_key(passphrase, &salt)?;
184
185                // Verify HMAC if present (V3). V2 stores without HMAC are
186                // accepted — the HMAC will be added on next write.
187                if let Some(ref stored_hmac) = envelope.hmac {
188                    verify_envelope_hmac(
189                        &integrity_key,
190                        "argon2id",
191                        salt_b64,
192                        &envelope.keys,
193                        stored_hmac,
194                    )?;
195                } else {
196                    tracing::info!(
197                        "key store has no HMAC — integrity check will be added on next write"
198                    );
199                }
200
201                Ok(Self {
202                    path: path.as_ref().to_owned(),
203                    cipher,
204                    integrity_key,
205                    salt,
206                    records: RwLock::new(envelope.keys),
207                    needs_migration: RwLock::new(false),
208                })
209            } else {
210                // V1 format — legacy SHA-256. Open with legacy KDF, flag for migration.
211                tracing::warn!(
212                    "key store at {:?} uses legacy SHA-256 KDF — will migrate to Argon2id on next write",
213                    path.as_ref()
214                );
215
216                // V1 files might be a raw HashMap (pre-envelope) or an
217                // envelope with kdf=null. Try the envelope's `keys` first;
218                // fall back to treating the whole file as the map.
219                let records = if !envelope.keys.is_empty() {
220                    envelope.keys
221                } else {
222                    // Raw V0/V1 format: file is just `{ "key_id": KeyRecord }`.
223                    serde_json::from_slice(&bytes)
224                        .context("Failed to deserialise legacy key store")?
225                };
226
227                let cipher_key = derive_aes_key_sha256(passphrase);
228                let cipher = Aes256Gcm::new(&cipher_key);
229
230                // Generate a new salt for the eventual migration.
231                let mut salt = [0u8; ARGON2_SALT_LEN];
232                crate::os_rng().fill_bytes(&mut salt);
233
234                // Integrity key will be derived properly after migration.
235                let integrity_key = derive_integrity_key(passphrase, &salt)?;
236
237                Ok(Self {
238                    path: path.as_ref().to_owned(),
239                    cipher,
240                    integrity_key,
241                    salt,
242                    records: RwLock::new(records),
243                    needs_migration: RwLock::new(true),
244                })
245            }
246        } else {
247            // Brand new store — generate a fresh salt.
248            let mut salt = [0u8; ARGON2_SALT_LEN];
249            crate::os_rng().fill_bytes(&mut salt);
250            let cipher_key = derive_aes_key_argon2(passphrase, &salt)?;
251            let cipher = Aes256Gcm::new(&cipher_key);
252            let integrity_key = derive_integrity_key(passphrase, &salt)?;
253
254            Ok(Self {
255                path: path.as_ref().to_owned(),
256                cipher,
257                integrity_key,
258                salt,
259                records: RwLock::new(HashMap::new()),
260                needs_migration: RwLock::new(false),
261            })
262        }
263    }
264
265    pub fn generate_key(&self, key_id: &str) -> Result<KeyEntry> {
266        if *self.needs_migration.read().expect("lock") {
267            anyhow::bail!(
268                "key store requires KDF migration before writes are allowed — \
269                 call migrate_if_needed() first"
270            );
271        }
272        let signing_key = SigningKey::generate(&mut crate::os_rng());
273        let verifying_key = signing_key.verifying_key();
274        let fingerprint = hex::encode(Sha256::digest(verifying_key.as_bytes()));
275        let verifying_key_hex = hex::encode(verifying_key.as_bytes());
276
277        let mut nonce_bytes = [0u8; 12];
278        crate::os_rng().fill_bytes(&mut nonce_bytes);
279        let nonce = <&Nonce<U12>>::from(&nonce_bytes);
280
281        let mut raw = signing_key.to_bytes();
282        let encrypted = self
283            .cipher
284            .encrypt(nonce, raw.as_ref())
285            .map_err(|_| anyhow::anyhow!("AES-GCM encrypt failed"))?;
286        raw.zeroize();
287
288        let record = KeyRecord::new(
289            encrypted,
290            nonce_bytes.to_vec(),
291            fingerprint.clone(),
292            verifying_key_hex,
293        );
294
295        {
296            let mut map = self.records.write().expect("key store write lock poisoned");
297            map.insert(key_id.to_owned(), record);
298            self.persist_envelope(&map)?;
299        }
300
301        Ok(KeyEntry {
302            signing_key,
303            verifying_key,
304            fingerprint,
305            revoked: false,
306            algorithm: default_algorithm(),
307        })
308    }
309
310    pub fn load_key(&self, key_id: &str) -> Result<KeyEntry> {
311        let map = self.records.read().expect("key store read lock poisoned");
312        let record = map
313            .get(key_id)
314            .ok_or_else(|| anyhow::anyhow!("no key found for {key_id}"))?;
315        self.decrypt_record(record)
316    }
317
318    pub fn has_key(&self, key_id: &str) -> bool {
319        let map = self.records.read().expect("key store read lock poisoned");
320        map.contains_key(key_id)
321    }
322
323    /// The public key and revocation state of the current key under `key_id`,
324    /// without decrypting the private key. Returns `None` if no such key exists.
325    ///
326    /// `pub` rather than `pub(crate)` because the `did:web` document builder
327    /// lives in `dpp-vc` and needs exactly this: public key material and
328    /// revocation state, never the private key.
329    pub fn public_key(&self, key_id: &str) -> Option<PublicKeyInfo> {
330        let map = self.records.read().expect("key store read lock poisoned");
331        map.get(key_id).map(PublicKeyInfo::from)
332    }
333
334    /// Public keys of all archived records for `key_id`, in the same ascending
335    /// timestamp order as [`Self::load_archived_keys`], without decrypting any
336    /// private key material.
337    pub fn archived_public_keys(&self, key_id: &str) -> Vec<PublicKeyInfo> {
338        let prefix = format!("{key_id}#archived-");
339        let map = self.records.read().expect("key store read lock poisoned");
340
341        let mut entries: Vec<(&str, &KeyRecord)> = map
342            .iter()
343            .filter(|(k, _)| k.starts_with(&prefix))
344            .map(|(k, v)| (k.as_str(), v))
345            .collect();
346        entries.sort_by_key(|(k, _)| *k);
347
348        entries
349            .into_iter()
350            .map(|(_, record)| PublicKeyInfo::from(record))
351            .collect()
352    }
353
354    /// Return all archived keys for the given identifier in ascending timestamp order.
355    pub fn load_archived_keys(&self, key_id: &str) -> Vec<KeyEntry> {
356        let prefix = format!("{key_id}#archived-");
357        let map = self.records.read().expect("key store read lock poisoned");
358
359        let mut entries: Vec<(&str, &KeyRecord)> = map
360            .iter()
361            .filter(|(k, _)| k.starts_with(&prefix))
362            .map(|(k, v)| (k.as_str(), v))
363            .collect();
364
365        entries.sort_by_key(|(k, _)| *k);
366
367        let mut result = Vec::with_capacity(entries.len());
368        for (key_id, record) in entries {
369            match self.decrypt_record(record) {
370                Ok(entry) => result.push(entry),
371                Err(e) => {
372                    tracing::warn!(archive_key = key_id, error = %e, "failed to decrypt archived key — skipping");
373                }
374            }
375        }
376        result
377    }
378
379    fn decrypt_record(&self, record: &KeyRecord) -> Result<KeyEntry> {
380        let nonce = <&Nonce<U12>>::try_from(record.nonce.as_slice()).map_err(|_| {
381            anyhow::anyhow!(
382                "stored nonce is not 12 bytes ({} bytes) — corrupt or legacy key record",
383                record.nonce.len()
384            )
385        })?;
386        let mut raw = self
387            .cipher
388            .decrypt(nonce, record.encrypted_signing_key.as_ref())
389            .map_err(|_| anyhow::anyhow!("AES-GCM decrypt failed"))?;
390
391        let bytes: [u8; 32] = raw
392            .as_slice()
393            .try_into()
394            .map_err(|_| anyhow::anyhow!("unexpected key length"))?;
395        let signing_key = SigningKey::from_bytes(&bytes);
396        let verifying_key = signing_key.verifying_key();
397        raw.zeroize();
398
399        Ok(KeyEntry {
400            fingerprint: record.fingerprint.clone(),
401            signing_key,
402            verifying_key,
403            revoked: record.revoked,
404            algorithm: record.algorithm,
405        })
406    }
407
408    pub(crate) fn persist_envelope(&self, map: &KeyRecordMap) -> Result<()> {
409        let keys_clone: KeyRecordMap = map.clone();
410
411        let salt_b64 =
412            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, self.salt);
413        let hmac_hex =
414            compute_envelope_hmac(&self.integrity_key, "argon2id", &salt_b64, &keys_clone)?;
415
416        let envelope = StoreEnvelope {
417            kdf: Some("argon2id".into()),
418            salt: Some(salt_b64),
419            hmac: Some(hmac_hex),
420            keys: keys_clone,
421        };
422        let bytes = serde_json::to_vec(&envelope).context("Failed to serialise key store")?;
423        atomic_write(&self.path, &bytes).context("Failed to write key store file")
424    }
425}
426
427/// Write `bytes` to `path` atomically: write to a sibling temp file, fsync it,
428/// then rename over the target. A crash mid-write therefore leaves the previous
429/// key store intact rather than a half-written, integrity-failing file.
430fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
431    use std::io::Write;
432
433    let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
434    let file_name = path
435        .file_name()
436        .and_then(|s| s.to_str())
437        .unwrap_or("keystore");
438    let tmp_name = format!(".{file_name}.tmp.{}", std::process::id());
439    let tmp = match dir {
440        Some(d) => d.join(tmp_name),
441        None => std::path::PathBuf::from(tmp_name),
442    };
443
444    let write_result = (|| -> Result<()> {
445        let mut f = std::fs::File::create(&tmp).context("create temp key store")?;
446        f.write_all(bytes).context("write temp key store")?;
447        f.sync_all().context("fsync temp key store")?;
448        Ok(())
449    })();
450    if let Err(e) = write_result {
451        let _ = std::fs::remove_file(&tmp);
452        return Err(e);
453    }
454
455    // `std::fs::rename` replaces an existing destination on both Unix and Windows.
456    std::fs::rename(&tmp, path).map_err(|e| {
457        let _ = std::fs::remove_file(&tmp);
458        anyhow::anyhow!("atomically replace key store: {e}")
459    })
460}