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