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