Skip to main content

dpp_crypto/keystore/
mod.rs

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