1use std::collections::HashMap;
8use std::path::Path;
9use std::sync::RwLock;
10
11use aes_gcm::{
12 Aes256Gcm, Nonce,
13 aead::{Aead, KeyInit, OsRng},
14};
15use anyhow::{Context, Result};
16use ed25519_dalek::{SigningKey, VerifyingKey};
17use rand::RngCore;
18use sha2::{Digest, Sha256};
19use zeroize::Zeroize;
20
21mod crypto;
22mod migration;
23mod rotation;
24#[cfg(test)]
25mod tests;
26
27use crypto::{
28 compute_envelope_hmac, derive_aes_key_argon2, derive_aes_key_sha256, derive_integrity_key,
29 verify_envelope_hmac,
30};
31
32pub(crate) type KeyRecordMap = HashMap<String, KeyRecord>;
34
35const ARGON2_SALT_LEN: usize = 16;
37
38#[derive(serde::Serialize, serde::Deserialize)]
39pub(crate) struct KeyRecord {
40 pub(crate) encrypted_signing_key: Vec<u8>,
41 pub(crate) nonce: Vec<u8>,
42 pub(crate) fingerprint: String,
43 pub(crate) verifying_key_hex: String,
44 #[serde(default)]
48 pub(crate) revoked: bool,
49 #[serde(default = "default_algorithm")]
52 pub(crate) algorithm: String,
53}
54
55pub(crate) fn default_algorithm() -> String {
56 crate::jws::algorithm::EDDSA_ALG.to_owned()
57}
58
59pub struct KeyEntry {
60 pub signing_key: SigningKey,
61 pub verifying_key: VerifyingKey,
62 pub fingerprint: String,
63 pub revoked: bool,
65}
66
67impl Drop for KeyEntry {
68 fn drop(&mut self) {
69 }
71}
72
73#[derive(serde::Serialize, serde::Deserialize)]
83struct StoreEnvelope {
84 #[serde(default)]
86 kdf: Option<String>,
87 #[serde(default)]
89 salt: Option<String>,
90 #[serde(default, skip_serializing_if = "Option::is_none")]
94 hmac: Option<String>,
95 keys: KeyRecordMap,
97}
98
99pub struct KeyStore {
108 pub(crate) path: std::path::PathBuf,
109 pub(crate) cipher: Aes256Gcm,
110 pub(crate) integrity_key: [u8; 32],
112 pub(crate) salt: [u8; ARGON2_SALT_LEN],
113 pub(crate) records: RwLock<KeyRecordMap>,
114 pub(crate) needs_migration: RwLock<bool>,
117}
118
119impl KeyStore {
120 pub fn open(path: impl AsRef<Path>, passphrase: &str) -> Result<Self> {
121 if path.as_ref().exists() {
122 let bytes = std::fs::read(&path).context("Failed to read key store file")?;
123
124 let envelope: StoreEnvelope = match serde_json::from_slice(&bytes) {
128 Ok(env) => env,
129 Err(_) => {
130 let keys: KeyRecordMap = serde_json::from_slice(&bytes)
131 .context("Failed to deserialise key store")?;
132 StoreEnvelope {
133 kdf: None,
134 salt: None,
135 hmac: None,
136 keys,
137 }
138 }
139 };
140
141 if envelope.kdf.as_deref() == Some("argon2id") {
142 let salt_b64 = envelope.salt.as_deref().ok_or_else(|| {
144 anyhow::anyhow!("key store has kdf=argon2id but no salt field")
145 })?;
146 let salt_vec =
147 base64::Engine::decode(&base64::engine::general_purpose::STANDARD, salt_b64)
148 .context("invalid base64 salt in key store")?;
149 let salt: [u8; ARGON2_SALT_LEN] = salt_vec.as_slice().try_into().map_err(|_| {
150 anyhow::anyhow!(
151 "key store salt has wrong length: expected {ARGON2_SALT_LEN}, got {}",
152 salt_vec.len()
153 )
154 })?;
155 let cipher_key = derive_aes_key_argon2(passphrase, &salt)?;
156 let cipher = Aes256Gcm::new(&cipher_key);
157 let integrity_key = derive_integrity_key(passphrase, &salt)?;
158
159 if let Some(ref stored_hmac) = envelope.hmac {
162 verify_envelope_hmac(
163 &integrity_key,
164 "argon2id",
165 salt_b64,
166 &envelope.keys,
167 stored_hmac,
168 )?;
169 } else {
170 tracing::info!(
171 "key store has no HMAC — integrity check will be added on next write"
172 );
173 }
174
175 Ok(Self {
176 path: path.as_ref().to_owned(),
177 cipher,
178 integrity_key,
179 salt,
180 records: RwLock::new(envelope.keys),
181 needs_migration: RwLock::new(false),
182 })
183 } else {
184 tracing::warn!(
186 "key store at {:?} uses legacy SHA-256 KDF — will migrate to Argon2id on next write",
187 path.as_ref()
188 );
189
190 let records = if !envelope.keys.is_empty() {
194 envelope.keys
195 } else {
196 serde_json::from_slice(&bytes)
198 .context("Failed to deserialise legacy key store")?
199 };
200
201 let cipher_key = derive_aes_key_sha256(passphrase);
202 let cipher = Aes256Gcm::new(&cipher_key);
203
204 let mut salt = [0u8; ARGON2_SALT_LEN];
206 OsRng.fill_bytes(&mut salt);
207
208 let integrity_key = derive_integrity_key(passphrase, &salt)?;
210
211 Ok(Self {
212 path: path.as_ref().to_owned(),
213 cipher,
214 integrity_key,
215 salt,
216 records: RwLock::new(records),
217 needs_migration: RwLock::new(true),
218 })
219 }
220 } else {
221 let mut salt = [0u8; ARGON2_SALT_LEN];
223 OsRng.fill_bytes(&mut salt);
224 let cipher_key = derive_aes_key_argon2(passphrase, &salt)?;
225 let cipher = Aes256Gcm::new(&cipher_key);
226 let integrity_key = derive_integrity_key(passphrase, &salt)?;
227
228 Ok(Self {
229 path: path.as_ref().to_owned(),
230 cipher,
231 integrity_key,
232 salt,
233 records: RwLock::new(HashMap::new()),
234 needs_migration: RwLock::new(false),
235 })
236 }
237 }
238
239 pub fn generate_key(&self, key_id: &str) -> Result<KeyEntry> {
240 if *self.needs_migration.read().expect("lock") {
241 anyhow::bail!(
242 "key store requires KDF migration before writes are allowed — \
243 call migrate_if_needed() first"
244 );
245 }
246 let signing_key = SigningKey::generate(&mut OsRng);
247 let verifying_key = signing_key.verifying_key();
248 let fingerprint = hex::encode(Sha256::digest(verifying_key.as_bytes()));
249 let verifying_key_hex = hex::encode(verifying_key.as_bytes());
250
251 let mut nonce_bytes = [0u8; 12];
252 rand::rngs::OsRng.fill_bytes(&mut nonce_bytes);
253 let nonce = Nonce::from_slice(&nonce_bytes);
254
255 let mut raw = signing_key.to_bytes();
256 let encrypted = self
257 .cipher
258 .encrypt(nonce, raw.as_ref())
259 .map_err(|_| anyhow::anyhow!("AES-GCM encrypt failed"))?;
260 raw.zeroize();
261
262 let record = KeyRecord {
263 encrypted_signing_key: encrypted,
264 nonce: nonce_bytes.to_vec(),
265 fingerprint: fingerprint.clone(),
266 verifying_key_hex: verifying_key_hex.clone(),
267 revoked: false,
268 algorithm: default_algorithm(),
269 };
270
271 {
272 let mut map = self.records.write().expect("key store write lock poisoned");
273 map.insert(key_id.to_owned(), record);
274 self.persist_envelope(&map)?;
275 }
276
277 Ok(KeyEntry {
278 signing_key,
279 verifying_key,
280 fingerprint,
281 revoked: false,
282 })
283 }
284
285 pub fn load_key(&self, key_id: &str) -> Result<KeyEntry> {
286 let map = self.records.read().expect("key store read lock poisoned");
287 let record = map
288 .get(key_id)
289 .ok_or_else(|| anyhow::anyhow!("no key found for {key_id}"))?;
290 self.decrypt_record(record)
291 }
292
293 pub fn has_key(&self, key_id: &str) -> bool {
294 let map = self.records.read().expect("key store read lock poisoned");
295 map.contains_key(key_id)
296 }
297
298 pub fn load_archived_keys(&self, key_id: &str) -> Vec<KeyEntry> {
300 let prefix = format!("{key_id}#archived-");
301 let map = self.records.read().expect("key store read lock poisoned");
302
303 let mut entries: Vec<(&str, &KeyRecord)> = map
304 .iter()
305 .filter(|(k, _)| k.starts_with(&prefix))
306 .map(|(k, v)| (k.as_str(), v))
307 .collect();
308
309 entries.sort_by_key(|(k, _)| *k);
310
311 let mut result = Vec::with_capacity(entries.len());
312 for (key_id, record) in entries {
313 match self.decrypt_record(record) {
314 Ok(entry) => result.push(entry),
315 Err(e) => {
316 tracing::warn!(archive_key = key_id, error = %e, "failed to decrypt archived key — skipping");
317 }
318 }
319 }
320 result
321 }
322
323 fn decrypt_record(&self, record: &KeyRecord) -> Result<KeyEntry> {
324 let nonce = Nonce::from_slice(&record.nonce);
325 let mut raw = self
326 .cipher
327 .decrypt(nonce, record.encrypted_signing_key.as_ref())
328 .map_err(|_| anyhow::anyhow!("AES-GCM decrypt failed"))?;
329
330 let bytes: [u8; 32] = raw
331 .as_slice()
332 .try_into()
333 .map_err(|_| anyhow::anyhow!("unexpected key length"))?;
334 let signing_key = SigningKey::from_bytes(&bytes);
335 let verifying_key = signing_key.verifying_key();
336 raw.zeroize();
337
338 Ok(KeyEntry {
339 fingerprint: record.fingerprint.clone(),
340 signing_key,
341 verifying_key,
342 revoked: record.revoked,
343 })
344 }
345
346 pub(crate) fn persist_envelope(&self, map: &KeyRecordMap) -> Result<()> {
347 let keys_clone: KeyRecordMap = map
348 .iter()
349 .map(|(k, v)| {
350 (
351 k.clone(),
352 KeyRecord {
353 encrypted_signing_key: v.encrypted_signing_key.clone(),
354 nonce: v.nonce.clone(),
355 fingerprint: v.fingerprint.clone(),
356 verifying_key_hex: v.verifying_key_hex.clone(),
357 revoked: v.revoked,
358 algorithm: v.algorithm.clone(),
359 },
360 )
361 })
362 .collect();
363
364 let salt_b64 =
365 base64::Engine::encode(&base64::engine::general_purpose::STANDARD, self.salt);
366 let hmac_hex =
367 compute_envelope_hmac(&self.integrity_key, "argon2id", &salt_b64, &keys_clone)?;
368
369 let envelope = StoreEnvelope {
370 kdf: Some("argon2id".into()),
371 salt: Some(salt_b64),
372 hmac: Some(hmac_hex),
373 keys: keys_clone,
374 };
375 let bytes = serde_json::to_vec(&envelope).context("Failed to serialise key store")?;
376 atomic_write(&self.path, &bytes).context("Failed to write key store file")
377 }
378}
379
380fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
384 use std::io::Write;
385
386 let dir = path.parent().filter(|p| !p.as_os_str().is_empty());
387 let file_name = path
388 .file_name()
389 .and_then(|s| s.to_str())
390 .unwrap_or("keystore");
391 let tmp_name = format!(".{file_name}.tmp.{}", std::process::id());
392 let tmp = match dir {
393 Some(d) => d.join(tmp_name),
394 None => std::path::PathBuf::from(tmp_name),
395 };
396
397 let write_result = (|| -> Result<()> {
398 let mut f = std::fs::File::create(&tmp).context("create temp key store")?;
399 f.write_all(bytes).context("write temp key store")?;
400 f.sync_all().context("fsync temp key store")?;
401 Ok(())
402 })();
403 if let Err(e) = write_result {
404 let _ = std::fs::remove_file(&tmp);
405 return Err(e);
406 }
407
408 std::fs::rename(&tmp, path).map_err(|e| {
410 let _ = std::fs::remove_file(&tmp);
411 anyhow::anyhow!("atomically replace key store: {e}")
412 })
413}