1use 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
23pub(crate) type KeyRecordMap = HashMap<String, KeyRecord>;
25
26const 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 #[serde(default)]
39 pub(crate) revoked: bool,
40 #[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#[derive(serde::Serialize, serde::Deserialize)]
60struct StoreEnvelope {
61 #[serde(default)]
63 kdf: Option<String>,
64 #[serde(default)]
66 salt: Option<String>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
71 hmac: Option<String>,
72 keys: KeyRecordMap,
74}
75
76pub struct KeyStore {
85 pub(crate) path: std::path::PathBuf,
86 pub(crate) cipher: Aes256Gcm,
87 pub(crate) integrity_key: [u8; 32],
89 pub(crate) salt: [u8; ARGON2_SALT_LEN],
90 pub(crate) records: RwLock<KeyRecordMap>,
91 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 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 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 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 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 let records = if !envelope.keys.is_empty() {
171 envelope.keys
172 } else {
173 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 let mut salt = [0u8; ARGON2_SALT_LEN];
183 OsRng.fill_bytes(&mut salt);
184
185 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 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 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
357fn 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(&tmp, path).map_err(|e| {
387 let _ = std::fs::remove_file(&tmp);
388 anyhow::anyhow!("atomically replace key store: {e}")
389 })
390}