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