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, Payload, consts::U12},
10};
11use anyhow::{Context, Result};
12use ed25519_dalek::SigningKey;
13use rand::Rng;
14use sha2::{Digest, Sha256};
15use zeroize::{Zeroize, Zeroizing};
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/// The store format this build writes.
31///
32/// V1 was a bare record map under a SHA-256-derived key. V2 added `kdf`/`salt`
33/// (Argon2id). V3 added the envelope `hmac`. **V4 binds each record's ciphertext
34/// to its own fingerprint** via AES-GCM associated data — see
35/// [`KeyStore::record_aad`].
36///
37/// Absent from a file means pre-V4, which is the only thing the number is used
38/// to decide.
39const STORE_VERSION: u32 = 4;
40
41#[derive(Clone, serde::Serialize, serde::Deserialize)]
42pub(crate) struct KeyRecord {
43    pub(crate) encrypted_signing_key: Vec<u8>,
44    pub(crate) nonce: Vec<u8>,
45    pub(crate) fingerprint: String,
46    pub(crate) verifying_key_hex: String,
47    /// True once the key has been revoked (e.g. on compromise). Revoked keys are
48    /// excluded from the published DID document, so signatures they produced no
49    /// longer verify. Defaults to false (back-compat with pre-revocation stores).
50    #[serde(default)]
51    pub(crate) revoked: bool,
52    /// The signature algorithm this key pair uses. Serialises as its JOSE
53    /// identifier (`"EdDSA"`), so the on-disk shape is unchanged. Defaults for
54    /// back-compat with pre-algorithm-agility stores; an *unrecognised*
55    /// algorithm fails to deserialise rather than loading a key nothing can
56    /// safely use.
57    #[serde(default = "default_algorithm")]
58    pub(crate) algorithm: KeyAlgorithm,
59}
60
61impl KeyRecord {
62    /// Construct a fresh, non-revoked record for a newly generated key pair.
63    pub(crate) fn new(
64        encrypted_signing_key: Vec<u8>,
65        nonce: Vec<u8>,
66        fingerprint: String,
67        verifying_key_hex: String,
68    ) -> Self {
69        Self {
70            encrypted_signing_key,
71            nonce,
72            fingerprint,
73            verifying_key_hex,
74            revoked: false,
75            algorithm: default_algorithm(),
76        }
77    }
78}
79
80pub(crate) fn default_algorithm() -> KeyAlgorithm {
81    KeyAlgorithm::Ed25519
82}
83
84/// A key's public half plus its revocation state, read directly from the
85/// stored record's plaintext `verifying_key_hex`/`revoked` fields — no
86/// private-key decryption involved. For callers (like the `did:web` document
87/// builder in `dpp-vc`) that only ever need the public key, this avoids an
88/// AES-GCM decrypt per key on every call.
89///
90/// `algorithm` travels with the key because a reader cannot otherwise know how
91/// to represent it: the DID-document builder needs it to choose the JWK shape,
92/// and guessing is how a key ends up published under the wrong `kty`.
93#[non_exhaustive]
94pub struct PublicKeyInfo {
95    pub verifying_key_hex: String,
96    pub revoked: bool,
97    pub algorithm: KeyAlgorithm,
98}
99
100impl From<&KeyRecord> for PublicKeyInfo {
101    fn from(record: &KeyRecord) -> Self {
102        Self {
103            verifying_key_hex: record.verifying_key_hex.clone(),
104            revoked: record.revoked,
105            algorithm: record.algorithm,
106        }
107    }
108}
109
110/// On-disk envelope for the key store file.
111///
112/// V2 adds `kdf` and `salt` fields. If `kdf` is missing (V1 format), the
113/// store was encrypted with bare SHA-256 and will be transparently migrated
114/// to Argon2id on next write.
115///
116/// V3 adds `hmac` — an HMAC-SHA256 over the serialised `keys` map, keyed
117/// with a 32-byte integrity key derived separately from the passphrase.
118/// This detects file tampering (swapped keys, modified fingerprints, etc.).
119#[derive(serde::Serialize, serde::Deserialize)]
120struct StoreEnvelope {
121    /// Store format version. Absent means pre-V4 — records are not bound to
122    /// their fingerprints. See [`STORE_VERSION`].
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    version: Option<u32>,
125    /// KDF identifier. `"argon2id"` for V2+, absent for V1 (legacy SHA-256).
126    #[serde(default)]
127    kdf: Option<String>,
128    /// Base64-encoded salt used by Argon2id. Absent for V1.
129    #[serde(default)]
130    salt: Option<String>,
131    /// HMAC-SHA256 over the canonical JSON serialisation of `keys`, keyed
132    /// with a passphrase-derived integrity key. Absent for V1/V2 stores.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    hmac: Option<String>,
135    /// The key records themselves.
136    keys: KeyRecordMap,
137}
138
139/// Why a store cannot be opened by [`KeyStore::open`] without being upgraded.
140///
141/// Each variant is a security property the store predates. They are reported
142/// rather than silently accommodated: every one of them was previously accepted
143/// on open, which meant a store could be *downgraded* into the weaker shape and
144/// opened without complaint.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum UpgradeNeeded {
147    /// Encrypted under the bare SHA-256 KDF — no salt, no iterations.
148    LegacyKdf,
149    /// No envelope HMAC, so the plaintext fields — including `revoked` — are
150    /// unauthenticated and a record swap is undetectable.
151    MissingIntegrityTag,
152    /// Records are not bound to their fingerprints (pre-V4).
153    UnboundRecords,
154}
155
156impl UpgradeNeeded {
157    /// What is wrong, and what it costs.
158    const fn describe(self) -> &'static str {
159        match self {
160            Self::LegacyKdf => "encrypted with the legacy SHA-256 KDF (no salt, no iterations)",
161            Self::MissingIntegrityTag => {
162                "carries no integrity tag, so its revocation flags and key IDs are unauthenticated"
163            }
164            Self::UnboundRecords => {
165                "predates per-record binding, so a record's ciphertext is not tied to its own fingerprint"
166            }
167        }
168    }
169}
170
171/// Thread-safe store that loads, encrypts, and caches Ed25519 signing keys.
172///
173/// Encryption key is derived from a passphrase using Argon2id with a random
174/// 128-bit salt. A separate 32-byte integrity key (derived from the same
175/// passphrase + salt with a different Argon2 context) is used to compute
176/// an HMAC-SHA256 over the serialised key map, protecting against file
177/// tampering. Legacy stores (pre-0.1.0) that used bare SHA-256 are
178/// automatically migrated on first write.
179pub struct KeyStore {
180    pub(crate) path: std::path::PathBuf,
181    pub(crate) cipher: Aes256Gcm,
182    /// 32-byte key used for HMAC-SHA256 file integrity checks.
183    pub(crate) integrity_key: [u8; 32],
184    pub(crate) salt: [u8; ARGON2_SALT_LEN],
185    pub(crate) records: RwLock<KeyRecordMap>,
186    /// True if the store was opened with a legacy SHA-256 derived key and
187    /// needs re-encryption with Argon2id on next write.
188    pub(crate) needs_migration: RwLock<bool>,
189    /// Which security property this store predates, if any. `None` once it is
190    /// at [`STORE_VERSION`] with an Argon2id key and a verified integrity tag.
191    pub(crate) upgrade_needed: Option<UpgradeNeeded>,
192    /// Whether this store's records are bound to their own fingerprints.
193    ///
194    /// Read on every decrypt: a pre-V4 record was encrypted with no associated
195    /// data and will not open if we supply some.
196    pub(crate) records_bound: bool,
197}
198
199impl KeyStore {
200    /// Open a store, refusing one that predates any of the current security
201    /// properties.
202    ///
203    /// # Why this refuses rather than accommodates
204    ///
205    /// Every legacy shape [`UpgradeNeeded`] names was previously accepted here
206    /// silently: a store with `kdf` absent opened under the unsalted SHA-256
207    /// KDF, and a store with no `hmac` opened with **no integrity check at
208    /// all** — which left the plaintext `revoked` flag unauthenticated, and that
209    /// flag is what `dpp-vc`'s `did:web` builder reads to drop a compromised key
210    /// from the published DID document.
211    ///
212    /// Accepting a weaker shape on read means an attacker who can write the file
213    /// can *choose* the weaker shape. The tolerance was for stores written by
214    /// older versions of this crate, and refusing them here does not strand one:
215    /// [`Self::open_and_migrate`] upgrades and opens them. What changes is that
216    /// the weak path now requires a caller who asked for it by name.
217    ///
218    /// # Errors
219    ///
220    /// Names the specific property the store predates, and points at
221    /// `open_and_migrate`.
222    pub fn open(path: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
223        let store = Self::open_permissively(path, passphrase)?;
224        if let Some(reason) = store.upgrade_needed {
225            anyhow::bail!(
226                "key store {}; open it with `KeyStore::open_and_migrate` to upgrade it in place",
227                reason.describe()
228            );
229        }
230        Ok(store)
231    }
232
233    /// Open a store whatever shape it is in, recording what it predates.
234    ///
235    /// `pub(crate)` — [`Self::open`] and [`Self::open_and_migrate`] are the two
236    /// doors, and this is deliberately not a third.
237    pub(crate) fn open_permissively(path: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
238        if path.as_ref().exists() {
239            let bytes = std::fs::read(&path).context("Failed to read key store file")?;
240
241            // Try to deserialize as the V2/V3 envelope first. A legacy V0/V1
242            // store is a raw `{ "key_id": KeyRecord }` map with no envelope
243            // wrapper, so fall back to that shape if the envelope parse fails.
244            let envelope: StoreEnvelope = match serde_json::from_slice(&bytes) {
245                Ok(env) => env,
246                Err(_) => {
247                    let keys: KeyRecordMap = serde_json::from_slice(&bytes)
248                        .context("Failed to deserialise key store")?;
249                    StoreEnvelope {
250                        version: None,
251                        kdf: None,
252                        salt: None,
253                        hmac: None,
254                        keys,
255                    }
256                }
257            };
258            let records_bound = envelope.version.unwrap_or(0) >= STORE_VERSION;
259
260            if envelope.kdf.as_deref() == Some("argon2id") {
261                // V2/V3 format — Argon2id.
262                let salt_b64 = envelope.salt.as_deref().ok_or_else(|| {
263                    anyhow::anyhow!("key store has kdf=argon2id but no salt field")
264                })?;
265                let salt_vec =
266                    base64::Engine::decode(&base64::engine::general_purpose::STANDARD, salt_b64)
267                        .context("invalid base64 salt in key store")?;
268                let salt: [u8; ARGON2_SALT_LEN] = salt_vec.as_slice().try_into().map_err(|_| {
269                    anyhow::anyhow!(
270                        "key store salt has wrong length: expected {ARGON2_SALT_LEN}, got {}",
271                        salt_vec.len()
272                    )
273                })?;
274                let cipher_key = derive_aes_key_argon2(passphrase, &salt)?;
275                let cipher = Aes256Gcm::new(&cipher_key);
276                let integrity_key = derive_integrity_key(passphrase, &salt)?;
277
278                // A present HMAC is always verified, and a failure is fatal
279                // regardless of which door was used: a tampered store is not a
280                // store to be upgraded, it is one to be refused.
281                //
282                // An absent HMAC no longer opens quietly. It is recorded as an
283                // upgrade requirement so `open` refuses it and
284                // `open_and_migrate` repairs it — the plaintext `revoked` flag
285                // is unauthenticated without it, and that flag decides whether a
286                // compromised key stays in the published DID document.
287                let mut upgrade_needed = None;
288                if let Some(ref stored_hmac) = envelope.hmac {
289                    verify_envelope_hmac(
290                        &integrity_key,
291                        "argon2id",
292                        salt_b64,
293                        &envelope.keys,
294                        stored_hmac,
295                    )?;
296                    if !records_bound {
297                        upgrade_needed = Some(UpgradeNeeded::UnboundRecords);
298                    }
299                } else {
300                    upgrade_needed = Some(UpgradeNeeded::MissingIntegrityTag);
301                }
302
303                Ok(Self {
304                    path: path.as_ref().to_owned(),
305                    cipher,
306                    integrity_key,
307                    salt,
308                    records: RwLock::new(envelope.keys),
309                    needs_migration: RwLock::new(false),
310                    upgrade_needed,
311                    records_bound,
312                })
313            } else {
314                // V1 format — legacy SHA-256. Open with legacy KDF, flag for migration.
315                tracing::warn!(
316                    "key store at {:?} uses legacy SHA-256 KDF — will migrate to Argon2id on next write",
317                    path.as_ref()
318                );
319
320                // V1 files might be a raw HashMap (pre-envelope) or an
321                // envelope with kdf=null. Try the envelope's `keys` first;
322                // fall back to treating the whole file as the map.
323                let records = if !envelope.keys.is_empty() {
324                    envelope.keys
325                } else {
326                    // Raw V0/V1 format: file is just `{ "key_id": KeyRecord }`.
327                    serde_json::from_slice(&bytes)
328                        .context("Failed to deserialise legacy key store")?
329                };
330
331                let cipher_key = derive_aes_key_sha256(passphrase);
332                let cipher = Aes256Gcm::new(&cipher_key);
333
334                // Generate a new salt for the eventual migration.
335                let mut salt = [0u8; ARGON2_SALT_LEN];
336                crate::os_rng().fill_bytes(&mut salt);
337
338                // Integrity key will be derived properly after migration.
339                let integrity_key = derive_integrity_key(passphrase, &salt)?;
340
341                Ok(Self {
342                    path: path.as_ref().to_owned(),
343                    cipher,
344                    integrity_key,
345                    salt,
346                    records: RwLock::new(records),
347                    needs_migration: RwLock::new(true),
348                    upgrade_needed: Some(UpgradeNeeded::LegacyKdf),
349                    // A legacy store predates binding by definition; migration
350                    // re-encrypts every record and sets this.
351                    records_bound: false,
352                })
353            }
354        } else {
355            // Brand new store — generate a fresh salt.
356            let mut salt = [0u8; ARGON2_SALT_LEN];
357            crate::os_rng().fill_bytes(&mut salt);
358            let cipher_key = derive_aes_key_argon2(passphrase, &salt)?;
359            let cipher = Aes256Gcm::new(&cipher_key);
360            let integrity_key = derive_integrity_key(passphrase, &salt)?;
361
362            Ok(Self {
363                path: path.as_ref().to_owned(),
364                cipher,
365                integrity_key,
366                salt,
367                records: RwLock::new(HashMap::new()),
368                needs_migration: RwLock::new(false),
369                upgrade_needed: None,
370                // Nothing to migrate: every record this store will ever hold is
371                // written by this build, bound.
372                records_bound: true,
373            })
374        }
375    }
376
377    pub fn generate_key(&self, key_id: &str) -> Result<KeyEntry> {
378        if *self.needs_migration.read().expect("lock") {
379            anyhow::bail!(
380                "key store requires KDF migration before writes are allowed — \
381                 call migrate_if_needed() first"
382            );
383        }
384        let signing_key = SigningKey::generate(&mut crate::os_rng());
385        let verifying_key = signing_key.verifying_key();
386        let fingerprint = hex::encode(Sha256::digest(verifying_key.as_bytes()));
387        let verifying_key_hex = hex::encode(verifying_key.as_bytes());
388
389        let mut nonce_bytes = [0u8; 12];
390        crate::os_rng().fill_bytes(&mut nonce_bytes);
391        let nonce = <&Nonce<U12>>::from(&nonce_bytes);
392
393        let mut raw = signing_key.to_bytes();
394        let encrypted = self
395            .cipher
396            .encrypt(nonce, Self::record_payload(raw.as_ref(), &fingerprint))
397            .map_err(|_| anyhow::anyhow!("AES-GCM encrypt failed"))?;
398        raw.zeroize();
399
400        let record = KeyRecord::new(
401            encrypted,
402            nonce_bytes.to_vec(),
403            fingerprint.clone(),
404            verifying_key_hex,
405        );
406
407        {
408            let mut map = self.records.write().expect("key store write lock poisoned");
409            map.insert(key_id.to_owned(), record);
410            self.persist_envelope(&map)?;
411        }
412
413        Ok(KeyEntry {
414            signing_key,
415            verifying_key,
416            fingerprint,
417            revoked: false,
418            algorithm: default_algorithm(),
419        })
420    }
421
422    pub fn load_key(&self, key_id: &str) -> Result<KeyEntry> {
423        let map = self.records.read().expect("key store read lock poisoned");
424        let record = map
425            .get(key_id)
426            .ok_or_else(|| anyhow::anyhow!("no key found for {key_id}"))?;
427        self.decrypt_record(record)
428    }
429
430    pub fn has_key(&self, key_id: &str) -> bool {
431        let map = self.records.read().expect("key store read lock poisoned");
432        map.contains_key(key_id)
433    }
434
435    /// The public key and revocation state of the current key under `key_id`,
436    /// without decrypting the private key. Returns `None` if no such key exists.
437    ///
438    /// `pub` rather than `pub(crate)` because the `did:web` document builder
439    /// lives in `dpp-vc` and needs exactly this: public key material and
440    /// revocation state, never the private key.
441    pub fn public_key(&self, key_id: &str) -> Option<PublicKeyInfo> {
442        let map = self.records.read().expect("key store read lock poisoned");
443        map.get(key_id).map(PublicKeyInfo::from)
444    }
445
446    /// Public keys of all archived records for `key_id`, in the same ascending
447    /// timestamp order as [`Self::load_archived_keys`], without decrypting any
448    /// private key material.
449    pub fn archived_public_keys(&self, key_id: &str) -> Vec<PublicKeyInfo> {
450        let prefix = format!("{key_id}#archived-");
451        let map = self.records.read().expect("key store read lock poisoned");
452
453        let mut entries: Vec<(&str, &KeyRecord)> = map
454            .iter()
455            .filter(|(k, _)| k.starts_with(&prefix))
456            .map(|(k, v)| (k.as_str(), v))
457            .collect();
458        entries.sort_by_key(|(k, _)| *k);
459
460        entries
461            .into_iter()
462            .map(|(_, record)| PublicKeyInfo::from(record))
463            .collect()
464    }
465
466    /// Return all archived keys for the given identifier in ascending timestamp order.
467    pub fn load_archived_keys(&self, key_id: &str) -> Vec<KeyEntry> {
468        let prefix = format!("{key_id}#archived-");
469        let map = self.records.read().expect("key store read lock poisoned");
470
471        let mut entries: Vec<(&str, &KeyRecord)> = map
472            .iter()
473            .filter(|(k, _)| k.starts_with(&prefix))
474            .map(|(k, v)| (k.as_str(), v))
475            .collect();
476
477        entries.sort_by_key(|(k, _)| *k);
478
479        let mut result = Vec::with_capacity(entries.len());
480        for (key_id, record) in entries {
481            match self.decrypt_record(record) {
482                Ok(entry) => result.push(entry),
483                Err(e) => {
484                    tracing::warn!(archive_key = key_id, error = %e, "failed to decrypt archived key — skipping");
485                }
486            }
487        }
488        result
489    }
490
491    /// The associated data binding a record's ciphertext to its own identity.
492    ///
493    /// The **fingerprint**, not the map key. `archive_key` and `rotate_inner`
494    /// copy a record to a new map key *without re-encrypting it*, so associated
495    /// data derived from the map key would make every archived key undecryptable
496    /// the moment it was archived. The fingerprint is the SHA-256 of the public
497    /// half: unique per key pair, stored in plaintext beside the ciphertext, and
498    /// it travels with the record wherever it is filed.
499    ///
500    /// What this buys: a record's encrypted private key can no longer be moved
501    /// onto a different record's plaintext. Swapping two records' ciphertexts —
502    /// or grafting one onto a `verifying_key_hex` and `revoked` flag from
503    /// another — now fails to decrypt instead of succeeding quietly.
504    fn record_aad(fingerprint: &str) -> &[u8] {
505        fingerprint.as_bytes()
506    }
507
508    /// An AES-GCM payload carrying `msg` bound to `fingerprint`.
509    fn record_payload<'a>(msg: &'a [u8], fingerprint: &'a str) -> Payload<'a, 'a> {
510        Payload {
511            msg,
512            aad: Self::record_aad(fingerprint),
513        }
514    }
515
516    fn decrypt_record(&self, record: &KeyRecord) -> Result<KeyEntry> {
517        let nonce = <&Nonce<U12>>::try_from(record.nonce.as_slice()).map_err(|_| {
518            anyhow::anyhow!(
519                "stored nonce is not 12 bytes ({} bytes) — corrupt or legacy key record",
520                record.nonce.len()
521            )
522        })?;
523        // A pre-V4 record was sealed with no associated data and will not open
524        // if we supply any. `open` refuses such a store outright; this path is
525        // reached only through `open_and_migrate`, which is re-encrypting them.
526        let plaintext = if self.records_bound {
527            self.cipher.decrypt(
528                nonce,
529                Self::record_payload(record.encrypted_signing_key.as_ref(), &record.fingerprint),
530            )
531        } else {
532            self.cipher
533                .decrypt(nonce, record.encrypted_signing_key.as_ref())
534        };
535        let mut raw =
536            Zeroizing::new(plaintext.map_err(|_| anyhow::anyhow!("AES-GCM decrypt failed"))?);
537
538        // `Zeroizing` on both: the intermediate array is a second copy of the
539        // private key, and the early return below used to drop `raw` without
540        // clearing it.
541        let bytes: Zeroizing<[u8; 32]> = Zeroizing::new(
542            raw.as_slice()
543                .try_into()
544                .map_err(|_| anyhow::anyhow!("unexpected key length"))?,
545        );
546        let signing_key = SigningKey::from_bytes(&bytes);
547        let verifying_key = signing_key.verifying_key();
548        raw.zeroize();
549
550        Ok(KeyEntry {
551            fingerprint: record.fingerprint.clone(),
552            signing_key,
553            verifying_key,
554            revoked: record.revoked,
555            algorithm: record.algorithm,
556        })
557    }
558
559    pub(crate) fn persist_envelope(&self, map: &KeyRecordMap) -> Result<()> {
560        let keys_clone: KeyRecordMap = map.clone();
561
562        let salt_b64 =
563            base64::Engine::encode(&base64::engine::general_purpose::STANDARD, self.salt);
564        let hmac_hex =
565            compute_envelope_hmac(&self.integrity_key, "argon2id", &salt_b64, &keys_clone)?;
566
567        let envelope = StoreEnvelope {
568            version: Some(STORE_VERSION),
569            kdf: Some("argon2id".into()),
570            salt: Some(salt_b64),
571            hmac: Some(hmac_hex),
572            keys: keys_clone,
573        };
574        let bytes = serde_json::to_vec(&envelope).context("Failed to serialise key store")?;
575        atomic_write(&self.path, &bytes).context("Failed to write key store file")
576    }
577}
578
579/// Write `bytes` to `path` atomically: write to a sibling temp file, fsync it,
580/// then rename over the target. A crash mid-write therefore leaves the previous
581/// key store intact rather than a half-written, integrity-failing file.
582fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
583    use std::io::Write;
584
585    let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
586    let file_name = path
587        .file_name()
588        .and_then(|s| s.to_str())
589        .unwrap_or("keystore");
590    let tmp_name = format!(".{file_name}.tmp.{}", std::process::id());
591    let tmp = match dir {
592        Some(d) => d.join(tmp_name),
593        None => std::path::PathBuf::from(tmp_name),
594    };
595
596    let write_result = (|| -> Result<()> {
597        let mut f = std::fs::File::create(&tmp).context("create temp key store")?;
598        f.write_all(bytes).context("write temp key store")?;
599        f.sync_all().context("fsync temp key store")?;
600        Ok(())
601    })();
602    if let Err(e) = write_result {
603        let _ = std::fs::remove_file(&tmp);
604        return Err(e);
605    }
606
607    // `std::fs::rename` replaces an existing destination on both Unix and Windows.
608    std::fs::rename(&tmp, path).map_err(|e| {
609        let _ = std::fs::remove_file(&tmp);
610        anyhow::anyhow!("atomically replace key store: {e}")
611    })
612}