Skip to main content

dpp_crypto/keystore/
migration.rs

1use aes_gcm::{
2    Aes256Gcm, Nonce,
3    aead::{Aead, KeyInit, consts::U12},
4};
5use anyhow::{Context, Result};
6use rand::Rng;
7use zeroize::Zeroize;
8
9use super::crypto::derive_aes_key_argon2;
10use super::store::{KeyRecord, KeyRecordMap, KeyStore};
11
12impl KeyStore {
13    /// Open the key store, run `migrate_if_needed` if the store uses the legacy
14    /// SHA-256 KDF, and — if migration actually ran — re-open the file so
15    /// `self.cipher` reflects the new Argon2id key.
16    ///
17    /// This is the recommended entry point for production code. For stores
18    /// already at V2/V3 (Argon2id) it is identical to a single `open` call.
19    pub fn open_and_migrate(path: impl AsRef<std::path::Path>, passphrase: &str) -> Result<Self> {
20        // The permissive door: this is the one function allowed to open a store
21        // that predates a current security property, because it is the one that
22        // repairs it. `open` refuses them and points here.
23        let store = Self::open_permissively(path.as_ref(), passphrase)?;
24        if store.migrate_if_needed(passphrase)? {
25            // Re-open strictly. The upgraded file must satisfy `open` on its own
26            // terms — if it does not, the migration did not finish the job and
27            // saying so beats handing back a store nobody checked.
28            Self::open(path, passphrase)
29        } else {
30            Ok(store)
31        }
32    }
33
34    /// If this store was opened from a legacy format, re-encrypt all keys
35    /// with the Argon2id-derived key and persist. Call this once after
36    /// opening and verifying the passphrase works (e.g. by loading a key).
37    ///
38    /// Returns `true` if migration ran, `false` if the store was already at
39    /// V2/V3. Use `open_and_migrate` in production to avoid the post-migration
40    /// cipher inconsistency (this object's `self.cipher` is not updated here).
41    pub fn migrate_if_needed(&self, passphrase: &str) -> Result<bool> {
42        // Three things can require an upgrade and they are not independent: a
43        // legacy-KDF store also lacks an integrity tag and record binding. One
44        // pass re-encrypts every record under the current key with its
45        // fingerprint as associated data and rewrites the envelope, which
46        // satisfies all three at once.
47        let Some(reason) = self.upgrade_needed else {
48            return Ok(false);
49        };
50
51        tracing::info!(?reason, "upgrading key store to the current format");
52
53        // Decrypt all records with the old cipher, re-encrypt with the new one.
54        let new_key = derive_aes_key_argon2(passphrase, &self.salt)?;
55        let new_cipher = Aes256Gcm::new(&new_key);
56
57        let mut map = self.records.write().expect("key store write lock");
58        let mut migrated = KeyRecordMap::with_capacity(map.len());
59
60        for (id, record) in map.iter() {
61            // Decrypt with legacy cipher.
62            let nonce = <&Nonce<U12>>::try_from(record.nonce.as_slice()).map_err(|_| {
63                anyhow::anyhow!(
64                    "stored nonce is not 12 bytes ({} bytes) for key {id} — corrupt or legacy record",
65                    record.nonce.len()
66                )
67            })?;
68            // Read with whatever binding the *stored* record has, write with the
69            // current one.
70            let opened = if self.records_bound {
71                self.cipher.decrypt(
72                    nonce,
73                    aes_gcm::aead::Payload {
74                        msg: record.encrypted_signing_key.as_ref(),
75                        aad: record.fingerprint.as_bytes(),
76                    },
77                )
78            } else {
79                self.cipher
80                    .decrypt(nonce, record.encrypted_signing_key.as_ref())
81            };
82            let mut raw = opened.map_err(|_| {
83                anyhow::anyhow!("AES-GCM decrypt failed during migration for key {id}")
84            })?;
85
86            // Re-encrypt with new cipher + fresh nonce.
87            let mut nonce_bytes = [0u8; 12];
88            crate::os_rng().fill_bytes(&mut nonce_bytes);
89            let new_nonce = <&Nonce<U12>>::from(&nonce_bytes);
90            let encrypted = new_cipher
91                .encrypt(
92                    new_nonce,
93                    aes_gcm::aead::Payload {
94                        msg: raw.as_ref(),
95                        aad: record.fingerprint.as_bytes(),
96                    },
97                )
98                .map_err(|_| anyhow::anyhow!("AES-GCM encrypt failed during migration"))?;
99            raw.zeroize();
100
101            migrated.insert(
102                id.clone(),
103                KeyRecord {
104                    encrypted_signing_key: encrypted,
105                    nonce: nonce_bytes.to_vec(),
106                    ..record.clone()
107                },
108            );
109        }
110
111        *map = migrated;
112        self.persist_envelope(&map)
113            .context("Failed to persist migrated key store")?;
114
115        drop(map);
116        *self.needs_migration.write().expect("lock") = false;
117
118        // self.cipher still holds the old key; callers must use open_and_migrate
119        // (which re-opens the file) rather than continuing to use this object.
120        tracing::info!("key store upgraded to the current format");
121        Ok(true)
122    }
123}