1use std::collections::HashMap;
4use std::path::Path;
5use std::sync::RwLock;
6
7use aes_gcm::{
8 Aes256Gcm, Nonce,
9 aead::{Aead, KeyInit, consts::U12},
10};
11use anyhow::{Context, Result};
12use ed25519_dalek::SigningKey;
13use rand::Rng;
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(Clone, 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
46impl KeyRecord {
47 pub(crate) fn new(
49 encrypted_signing_key: Vec<u8>,
50 nonce: Vec<u8>,
51 fingerprint: String,
52 verifying_key_hex: String,
53 ) -> Self {
54 Self {
55 encrypted_signing_key,
56 nonce,
57 fingerprint,
58 verifying_key_hex,
59 revoked: false,
60 algorithm: default_algorithm(),
61 }
62 }
63}
64
65pub(crate) fn default_algorithm() -> String {
66 crate::jws::algorithm::EDDSA_ALG.to_owned()
67}
68
69pub(crate) struct PublicKeyInfo {
75 pub(crate) verifying_key_hex: String,
76 pub(crate) revoked: bool,
77}
78
79impl From<&KeyRecord> for PublicKeyInfo {
80 fn from(record: &KeyRecord) -> Self {
81 Self {
82 verifying_key_hex: record.verifying_key_hex.clone(),
83 revoked: record.revoked,
84 }
85 }
86}
87
88#[derive(serde::Serialize, serde::Deserialize)]
98struct StoreEnvelope {
99 #[serde(default)]
101 kdf: Option<String>,
102 #[serde(default)]
104 salt: Option<String>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
109 hmac: Option<String>,
110 keys: KeyRecordMap,
112}
113
114pub struct KeyStore {
123 pub(crate) path: std::path::PathBuf,
124 pub(crate) cipher: Aes256Gcm,
125 pub(crate) integrity_key: [u8; 32],
127 pub(crate) salt: [u8; ARGON2_SALT_LEN],
128 pub(crate) records: RwLock<KeyRecordMap>,
129 pub(crate) needs_migration: RwLock<bool>,
132}
133
134impl KeyStore {
135 pub fn open(path: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
136 if path.as_ref().exists() {
137 let bytes = std::fs::read(&path).context("Failed to read key store file")?;
138
139 let envelope: StoreEnvelope = match serde_json::from_slice(&bytes) {
143 Ok(env) => env,
144 Err(_) => {
145 let keys: KeyRecordMap = serde_json::from_slice(&bytes)
146 .context("Failed to deserialise key store")?;
147 StoreEnvelope {
148 kdf: None,
149 salt: None,
150 hmac: None,
151 keys,
152 }
153 }
154 };
155
156 if envelope.kdf.as_deref() == Some("argon2id") {
157 let salt_b64 = envelope.salt.as_deref().ok_or_else(|| {
159 anyhow::anyhow!("key store has kdf=argon2id but no salt field")
160 })?;
161 let salt_vec =
162 base64::Engine::decode(&base64::engine::general_purpose::STANDARD, salt_b64)
163 .context("invalid base64 salt in key store")?;
164 let salt: [u8; ARGON2_SALT_LEN] = salt_vec.as_slice().try_into().map_err(|_| {
165 anyhow::anyhow!(
166 "key store salt has wrong length: expected {ARGON2_SALT_LEN}, got {}",
167 salt_vec.len()
168 )
169 })?;
170 let cipher_key = derive_aes_key_argon2(passphrase, &salt)?;
171 let cipher = Aes256Gcm::new(&cipher_key);
172 let integrity_key = derive_integrity_key(passphrase, &salt)?;
173
174 if let Some(ref stored_hmac) = envelope.hmac {
177 verify_envelope_hmac(
178 &integrity_key,
179 "argon2id",
180 salt_b64,
181 &envelope.keys,
182 stored_hmac,
183 )?;
184 } else {
185 tracing::info!(
186 "key store has no HMAC — integrity check will be added on next write"
187 );
188 }
189
190 Ok(Self {
191 path: path.as_ref().to_owned(),
192 cipher,
193 integrity_key,
194 salt,
195 records: RwLock::new(envelope.keys),
196 needs_migration: RwLock::new(false),
197 })
198 } else {
199 tracing::warn!(
201 "key store at {:?} uses legacy SHA-256 KDF — will migrate to Argon2id on next write",
202 path.as_ref()
203 );
204
205 let records = if !envelope.keys.is_empty() {
209 envelope.keys
210 } else {
211 serde_json::from_slice(&bytes)
213 .context("Failed to deserialise legacy key store")?
214 };
215
216 let cipher_key = derive_aes_key_sha256(passphrase);
217 let cipher = Aes256Gcm::new(&cipher_key);
218
219 let mut salt = [0u8; ARGON2_SALT_LEN];
221 crate::os_rng().fill_bytes(&mut salt);
222
223 let integrity_key = derive_integrity_key(passphrase, &salt)?;
225
226 Ok(Self {
227 path: path.as_ref().to_owned(),
228 cipher,
229 integrity_key,
230 salt,
231 records: RwLock::new(records),
232 needs_migration: RwLock::new(true),
233 })
234 }
235 } else {
236 let mut salt = [0u8; ARGON2_SALT_LEN];
238 crate::os_rng().fill_bytes(&mut salt);
239 let cipher_key = derive_aes_key_argon2(passphrase, &salt)?;
240 let cipher = Aes256Gcm::new(&cipher_key);
241 let integrity_key = derive_integrity_key(passphrase, &salt)?;
242
243 Ok(Self {
244 path: path.as_ref().to_owned(),
245 cipher,
246 integrity_key,
247 salt,
248 records: RwLock::new(HashMap::new()),
249 needs_migration: RwLock::new(false),
250 })
251 }
252 }
253
254 pub fn generate_key(&self, key_id: &str) -> Result<KeyEntry> {
255 if *self.needs_migration.read().expect("lock") {
256 anyhow::bail!(
257 "key store requires KDF migration before writes are allowed — \
258 call migrate_if_needed() first"
259 );
260 }
261 let signing_key = SigningKey::generate(&mut crate::os_rng());
262 let verifying_key = signing_key.verifying_key();
263 let fingerprint = hex::encode(Sha256::digest(verifying_key.as_bytes()));
264 let verifying_key_hex = hex::encode(verifying_key.as_bytes());
265
266 let mut nonce_bytes = [0u8; 12];
267 crate::os_rng().fill_bytes(&mut nonce_bytes);
268 let nonce = <&Nonce<U12>>::from(&nonce_bytes);
269
270 let mut raw = signing_key.to_bytes();
271 let encrypted = self
272 .cipher
273 .encrypt(nonce, raw.as_ref())
274 .map_err(|_| anyhow::anyhow!("AES-GCM encrypt failed"))?;
275 raw.zeroize();
276
277 let record = KeyRecord::new(
278 encrypted,
279 nonce_bytes.to_vec(),
280 fingerprint.clone(),
281 verifying_key_hex,
282 );
283
284 {
285 let mut map = self.records.write().expect("key store write lock poisoned");
286 map.insert(key_id.to_owned(), record);
287 self.persist_envelope(&map)?;
288 }
289
290 Ok(KeyEntry {
291 signing_key,
292 verifying_key,
293 fingerprint,
294 revoked: false,
295 })
296 }
297
298 pub fn load_key(&self, key_id: &str) -> Result<KeyEntry> {
299 let map = self.records.read().expect("key store read lock poisoned");
300 let record = map
301 .get(key_id)
302 .ok_or_else(|| anyhow::anyhow!("no key found for {key_id}"))?;
303 self.decrypt_record(record)
304 }
305
306 pub fn has_key(&self, key_id: &str) -> bool {
307 let map = self.records.read().expect("key store read lock poisoned");
308 map.contains_key(key_id)
309 }
310
311 pub(crate) fn public_key(&self, key_id: &str) -> Option<PublicKeyInfo> {
314 let map = self.records.read().expect("key store read lock poisoned");
315 map.get(key_id).map(PublicKeyInfo::from)
316 }
317
318 pub(crate) fn archived_public_keys(&self, key_id: &str) -> Vec<PublicKeyInfo> {
322 let prefix = format!("{key_id}#archived-");
323 let map = self.records.read().expect("key store read lock poisoned");
324
325 let mut entries: Vec<(&str, &KeyRecord)> = map
326 .iter()
327 .filter(|(k, _)| k.starts_with(&prefix))
328 .map(|(k, v)| (k.as_str(), v))
329 .collect();
330 entries.sort_by_key(|(k, _)| *k);
331
332 entries
333 .into_iter()
334 .map(|(_, record)| PublicKeyInfo::from(record))
335 .collect()
336 }
337
338 pub fn load_archived_keys(&self, key_id: &str) -> Vec<KeyEntry> {
340 let prefix = format!("{key_id}#archived-");
341 let map = self.records.read().expect("key store read lock poisoned");
342
343 let mut entries: Vec<(&str, &KeyRecord)> = map
344 .iter()
345 .filter(|(k, _)| k.starts_with(&prefix))
346 .map(|(k, v)| (k.as_str(), v))
347 .collect();
348
349 entries.sort_by_key(|(k, _)| *k);
350
351 let mut result = Vec::with_capacity(entries.len());
352 for (key_id, record) in entries {
353 match self.decrypt_record(record) {
354 Ok(entry) => result.push(entry),
355 Err(e) => {
356 tracing::warn!(archive_key = key_id, error = %e, "failed to decrypt archived key — skipping");
357 }
358 }
359 }
360 result
361 }
362
363 fn decrypt_record(&self, record: &KeyRecord) -> Result<KeyEntry> {
364 let nonce = <&Nonce<U12>>::try_from(record.nonce.as_slice()).map_err(|_| {
365 anyhow::anyhow!(
366 "stored nonce is not 12 bytes ({} bytes) — corrupt or legacy key record",
367 record.nonce.len()
368 )
369 })?;
370 let mut raw = self
371 .cipher
372 .decrypt(nonce, record.encrypted_signing_key.as_ref())
373 .map_err(|_| anyhow::anyhow!("AES-GCM decrypt failed"))?;
374
375 let bytes: [u8; 32] = raw
376 .as_slice()
377 .try_into()
378 .map_err(|_| anyhow::anyhow!("unexpected key length"))?;
379 let signing_key = SigningKey::from_bytes(&bytes);
380 let verifying_key = signing_key.verifying_key();
381 raw.zeroize();
382
383 Ok(KeyEntry {
384 fingerprint: record.fingerprint.clone(),
385 signing_key,
386 verifying_key,
387 revoked: record.revoked,
388 })
389 }
390
391 pub(crate) fn persist_envelope(&self, map: &KeyRecordMap) -> Result<()> {
392 let keys_clone: KeyRecordMap = map.clone();
393
394 let salt_b64 =
395 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, self.salt);
396 let hmac_hex =
397 compute_envelope_hmac(&self.integrity_key, "argon2id", &salt_b64, &keys_clone)?;
398
399 let envelope = StoreEnvelope {
400 kdf: Some("argon2id".into()),
401 salt: Some(salt_b64),
402 hmac: Some(hmac_hex),
403 keys: keys_clone,
404 };
405 let bytes = serde_json::to_vec(&envelope).context("Failed to serialise key store")?;
406 atomic_write(&self.path, &bytes).context("Failed to write key store file")
407 }
408}
409
410fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
414 use std::io::Write;
415
416 let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
417 let file_name = path
418 .file_name()
419 .and_then(|s| s.to_str())
420 .unwrap_or("keystore");
421 let tmp_name = format!(".{file_name}.tmp.{}", std::process::id());
422 let tmp = match dir {
423 Some(d) => d.join(tmp_name),
424 None => std::path::PathBuf::from(tmp_name),
425 };
426
427 let write_result = (|| -> Result<()> {
428 let mut f = std::fs::File::create(&tmp).context("create temp key store")?;
429 f.write_all(bytes).context("write temp key store")?;
430 f.sync_all().context("fsync temp key store")?;
431 Ok(())
432 })();
433 if let Err(e) = write_result {
434 let _ = std::fs::remove_file(&tmp);
435 return Err(e);
436 }
437
438 std::fs::rename(&tmp, path).map_err(|e| {
440 let _ = std::fs::remove_file(&tmp);
441 anyhow::anyhow!("atomically replace key store: {e}")
442 })
443}