This repository has no description
25 kB
771 lines
1use std::collections::BTreeMap;
2use std::fmt;
3use std::path::{Path, PathBuf};
4use std::sync::{Mutex, MutexGuard, RwLock};
5
6use aes_gcm::aead::Aead;
7use aes_gcm::{Aes256Gcm, KeyInit};
8use base64::Engine;
9use base64::engine::general_purpose::STANDARD;
10use hkdf::Hkdf;
11use knot_runtime::{Entropy, K256Signer, MAX_SCALAR_ATTEMPTS, PublicKeyBytes, Signer};
12use knot_types::KnotId;
13use serde::{Deserialize, Serialize};
14use sha2::Sha256;
15use zeroize::{Zeroize, ZeroizeOnDrop, Zeroizing};
16
17// why oh why didn't I just call this knot.sealed-key-store.v1. the
18// wind changed and now we're stuck with this
19const HKDF_INFO: &[u8] = b"knot2.sealed-key-store.v1";
20const NONCE_LEN: usize = 12;
21const SCALAR_LEN: usize = 32;
22const VAULT_VERSION: u32 = 1;
23const MIN_MASTER_KEY_LEN: usize = 32;
24
25#[derive(Debug, thiserror::Error)]
26pub enum SecretsError {
27 #[error("sealed key store {path}: {source}")]
28 Io {
29 path: PathBuf,
30 #[source]
31 source: std::io::Error,
32 },
33 #[error(
34 "sealed key store couldn't be decrypted. Master key may be wrong or file may be corrupt."
35 )]
36 Decrypt,
37 #[error("sealed key store is malformed: {0}")]
38 Malformed(String),
39 #[error("no signing key sealed for {0}")]
40 Missing(String),
41 #[error("signing key is already sealed for {0}")]
42 Occupied(String),
43 #[error("{len}-byte master key is shorter than the {MIN_MASTER_KEY_LEN}-byte minimum")]
44 WeakMasterKey { len: usize },
45}
46
47#[derive(Clone, Zeroize, ZeroizeOnDrop)]
48pub struct MasterKey(Vec<u8>);
49
50impl MasterKey {
51 pub fn new(bytes: impl Into<Vec<u8>>) -> Result<Self, SecretsError> {
52 let bytes = bytes.into();
53 if bytes.len() < MIN_MASTER_KEY_LEN {
54 return Err(SecretsError::WeakMasterKey { len: bytes.len() });
55 }
56 Ok(Self(bytes))
57 }
58
59 pub fn as_bytes(&self) -> &[u8] {
60 &self.0
61 }
62}
63
64impl fmt::Debug for MasterKey {
65 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66 f.write_str("MasterKey(<redacted>)")
67 }
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize)]
71#[serde(transparent)]
72pub struct SealedKeyId(String);
73
74impl SealedKeyId {
75 pub fn as_str(&self) -> &str {
76 &self.0
77 }
78}
79
80impl From<&KnotId> for SealedKeyId {
81 fn from(did: &KnotId) -> Self {
82 Self(did.as_str().to_string())
83 }
84}
85
86impl<'de> serde::Deserialize<'de> for SealedKeyId {
87 fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
88 let raw = String::deserialize(deserializer)?;
89 KnotId::new(raw)
90 .map(|did| Self::from(&did))
91 .map_err(serde::de::Error::custom)
92 }
93}
94
95#[derive(Clone, Zeroize, ZeroizeOnDrop)]
96struct SecretScalar([u8; SCALAR_LEN]);
97
98impl SecretScalar {
99 fn generate(entropy: &dyn Entropy) -> Self {
100 std::iter::repeat_with(|| {
101 let mut bytes = [0u8; SCALAR_LEN];
102 entropy.fill(&mut bytes);
103 k256::ecdsa::SigningKey::from_slice(&bytes)
104 .is_ok()
105 .then_some(bytes)
106 })
107 .take(MAX_SCALAR_ATTEMPTS)
108 .flatten()
109 .next()
110 .map(Self)
111 .unwrap_or_else(|| {
112 panic!(
113 "entropy failed to yield valid secp256k1 scalar in {MAX_SCALAR_ATTEMPTS} attempts"
114 )
115 })
116 }
117
118 fn signer(&self) -> K256Signer {
119 K256Signer::from_slice(&self.0).expect("stored scalar is always a valid signing key")
120 }
121}
122
123#[derive(Zeroize, ZeroizeOnDrop)]
124struct VaultKey([u8; 32]);
125
126impl VaultKey {
127 fn as_bytes(&self) -> &[u8; 32] {
128 &self.0
129 }
130}
131
132pub struct KeyMaterial {
133 scalar: SecretScalar,
134}
135
136impl KeyMaterial {
137 pub fn signer(&self) -> K256Signer {
138 self.scalar.signer()
139 }
140
141 pub fn public_key(&self) -> PublicKeyBytes {
142 self.scalar.signer().public_key()
143 }
144}
145
146#[derive(Zeroize, ZeroizeOnDrop, Serialize, Deserialize)]
147#[serde(transparent)]
148struct EncodedSecret(String);
149
150impl EncodedSecret {
151 fn new(value: String) -> Self {
152 Self(value)
153 }
154}
155
156#[derive(Serialize, Deserialize)]
157struct VaultFile {
158 version: u32,
159 entries: BTreeMap<SealedKeyId, EncodedSecret>,
160}
161
162pub struct SealedStore {
163 path: PathBuf,
164 enc_key: VaultKey,
165 entropy: Box<dyn Entropy>,
166 entries: RwLock<BTreeMap<SealedKeyId, SecretScalar>>,
167 persist_lock: Mutex<()>,
168}
169
170impl SealedStore {
171 pub fn open(
172 path: impl Into<PathBuf>,
173 master_key: &MasterKey,
174 entropy: Box<dyn Entropy>,
175 ) -> Result<Self, SecretsError> {
176 let path = path.into();
177 knot_resource::clear_staging(&path);
178 sweep_pre_knot_tmp_vaults(&path);
179 let enc_key = derive_enc_key(master_key);
180 let entries = match std::fs::read(&path) {
181 Ok(sealed) => decode_vault(&unseal(&enc_key, &sealed)?)?,
182 Err(error) if error.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(),
183 Err(source) => {
184 return Err(SecretsError::Io {
185 path: path.clone(),
186 source,
187 });
188 }
189 };
190 Ok(Self {
191 path,
192 enc_key,
193 entropy,
194 entries: RwLock::new(entries),
195 persist_lock: Mutex::new(()),
196 })
197 }
198
199 pub fn signer(&self, id: impl Into<SealedKeyId>) -> Result<K256Signer, SecretsError> {
200 let id = id.into();
201 self.entries
202 .read()
203 .expect("sealed store lock")
204 .get(&id)
205 .map(SecretScalar::signer)
206 .ok_or(SecretsError::Missing(id.0))
207 }
208
209 pub fn public_key(&self, id: impl Into<SealedKeyId>) -> Result<PublicKeyBytes, SecretsError> {
210 let id = id.into();
211 self.entries
212 .read()
213 .expect("sealed store lock")
214 .get(&id)
215 .map(|scalar| scalar.signer().public_key())
216 .ok_or(SecretsError::Missing(id.0))
217 }
218
219 pub fn generate(&self) -> KeyMaterial {
220 KeyMaterial {
221 scalar: SecretScalar::generate(&*self.entropy),
222 }
223 }
224
225 pub fn store(
226 &self,
227 id: impl Into<SealedKeyId>,
228 material: &KeyMaterial,
229 ) -> Result<(), SecretsError> {
230 let id = id.into();
231 let guard = self.persist_guard();
232 let mut staged = self.staged();
233 if staged.contains_key(&id) {
234 return Err(SecretsError::Occupied(id.0));
235 }
236 staged.insert(id, material.scalar.clone());
237 self.commit_locked(&guard, staged)
238 }
239
240 pub fn ensure(&self, id: impl Into<SealedKeyId>) -> Result<PublicKeyBytes, SecretsError> {
241 let id = id.into();
242 let guard = self.persist_guard();
243 if let Some(public) = self
244 .entries
245 .read()
246 .expect("sealed store lock")
247 .get(&id)
248 .map(|scalar| scalar.signer().public_key())
249 {
250 return Ok(public);
251 }
252 let material = self.generate();
253 let public = material.public_key();
254 let mut staged = self.staged();
255 staged.insert(id, material.scalar.clone());
256 self.commit_locked(&guard, staged)?;
257 Ok(public)
258 }
259
260 pub fn len(&self) -> usize {
261 self.entries.read().expect("sealed store lock").len()
262 }
263
264 pub fn is_empty(&self) -> bool {
265 self.entries.read().expect("sealed store lock").is_empty()
266 }
267
268 pub fn remove(&self, id: impl Into<SealedKeyId>) -> Result<bool, SecretsError> {
269 let id = id.into();
270 let guard = self.persist_guard();
271 let mut staged = self.staged();
272 if staged.remove(&id).is_none() {
273 return Ok(false);
274 }
275 self.commit_locked(&guard, staged)?;
276 Ok(true)
277 }
278
279 fn persist_guard(&self) -> MutexGuard<'_, ()> {
280 self.persist_lock
281 .lock()
282 .unwrap_or_else(|poisoned| poisoned.into_inner())
283 }
284
285 fn staged(&self) -> BTreeMap<SealedKeyId, SecretScalar> {
286 self.entries.read().expect("sealed store lock").clone()
287 }
288
289 fn commit_locked(
290 &self,
291 _guard: &MutexGuard<'_, ()>,
292 staged: BTreeMap<SealedKeyId, SecretScalar>,
293 ) -> Result<(), SecretsError> {
294 let plaintext = encode_vault(&staged);
295 let sealed = seal(&self.enc_key, &plaintext, &*self.entropy);
296 atomic_write(&self.path, &sealed)?;
297 *self.entries.write().expect("sealed store lock") = staged;
298 Ok(())
299 }
300}
301
302fn derive_enc_key(master_key: &MasterKey) -> VaultKey {
303 let hk = Hkdf::<Sha256>::new(None, master_key.as_bytes());
304 let mut okm = VaultKey([0u8; 32]);
305 hk.expand(HKDF_INFO, &mut okm.0)
306 .expect("32 bytes is valid HKDF-SHA256 output length");
307 okm
308}
309
310fn seal(enc_key: &VaultKey, plaintext: &[u8], entropy: &dyn Entropy) -> Vec<u8> {
311 let cipher = Aes256Gcm::new_from_slice(enc_key.as_bytes()).expect("32-byte AES-256-GCM key");
312 let mut nonce_bytes = [0u8; NONCE_LEN];
313 entropy.fill(&mut nonce_bytes);
314 let ciphertext = cipher
315 .encrypt(&nonce_bytes.into(), plaintext)
316 .expect("AES-256-GCM encryption doesn't fail on valid inputs");
317 [&nonce_bytes[..], &ciphertext[..]].concat()
318}
319
320fn unseal(enc_key: &VaultKey, sealed: &[u8]) -> Result<Zeroizing<Vec<u8>>, SecretsError> {
321 if sealed.len() < NONCE_LEN {
322 return Err(SecretsError::Decrypt);
323 }
324 let (nonce_bytes, ciphertext) = sealed.split_at(NONCE_LEN);
325 let nonce: [u8; NONCE_LEN] = nonce_bytes
326 .try_into()
327 .expect("split_at(NONCE_LEN) yields exactly NONCE_LEN bytes");
328 let cipher = Aes256Gcm::new_from_slice(enc_key.as_bytes()).expect("32-byte AES-256-GCM key");
329 cipher
330 .decrypt(&nonce.into(), ciphertext)
331 .map(Zeroizing::new)
332 .map_err(|_| SecretsError::Decrypt)
333}
334
335fn encode_vault(entries: &BTreeMap<SealedKeyId, SecretScalar>) -> Zeroizing<Vec<u8>> {
336 let file = VaultFile {
337 version: VAULT_VERSION,
338 entries: entries
339 .iter()
340 .map(|(id, scalar)| {
341 (
342 id.clone(),
343 EncodedSecret::new(STANDARD.encode(scalar.0.as_slice())),
344 )
345 })
346 .collect(),
347 };
348 Zeroizing::new(serde_json::to_vec(&file).expect("vault always serializes"))
349}
350
351fn decode_vault(plaintext: &[u8]) -> Result<BTreeMap<SealedKeyId, SecretScalar>, SecretsError> {
352 let VaultFile { version, entries } = serde_json::from_slice(plaintext)
353 .map_err(|error| SecretsError::Malformed(error.to_string()))?;
354 if version != VAULT_VERSION {
355 return Err(SecretsError::Malformed(format!(
356 "unsupported vault version {version}"
357 )));
358 }
359 entries
360 .into_iter()
361 .map(|(id, mut encoded)| {
362 let encoded = Zeroizing::new(std::mem::take(&mut encoded.0));
363 let bytes = Zeroizing::new(
364 STANDARD
365 .decode(encoded.as_bytes())
366 .map_err(|error| SecretsError::Malformed(error.to_string()))?,
367 );
368 let scalar: [u8; SCALAR_LEN] = bytes.as_slice().try_into().map_err(|_| {
369 SecretsError::Malformed(format!("scalar for {} isn't 32 bytes", id.as_str()))
370 })?;
371 k256::ecdsa::SigningKey::from_slice(&scalar).map_err(|_| {
372 SecretsError::Malformed(format!("scalar for {} isn't a valid key", id.as_str()))
373 })?;
374 Ok((id, SecretScalar(scalar)))
375 })
376 .collect()
377}
378
379fn effective_parent(path: &Path) -> &Path {
380 path.parent()
381 .filter(|parent| !parent.as_os_str().is_empty())
382 .unwrap_or_else(|| Path::new("."))
383}
384
385fn sweep_pre_knot_tmp_vaults(path: &Path) {
386 let Some(stem) = path.file_name().and_then(|name| name.to_str()) else {
387 return;
388 };
389 let Ok(listing) = std::fs::read_dir(effective_parent(path)) else {
390 return;
391 };
392 let prefix = format!(".{stem}.");
393 listing
394 .filter_map(Result::ok)
395 .map(|entry| entry.path())
396 .filter(|path| {
397 path.file_name()
398 .and_then(|name| name.to_str())
399 .is_some_and(|name| name.starts_with(&prefix) && name.ends_with(".tmp"))
400 })
401 .for_each(|path| {
402 let _ = std::fs::remove_file(path);
403 });
404}
405
406fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), SecretsError> {
407 let parent = effective_parent(path);
408 std::fs::create_dir_all(parent).map_err(|source| SecretsError::Io {
409 path: path.to_path_buf(),
410 source,
411 })?;
412 knot_resource::atomic_write_bytes(path, bytes, knot_resource::FileMode::Private)
413 .map_err(Into::into)
414}
415
416impl From<knot_resource::FsError> for SecretsError {
417 fn from(error: knot_resource::FsError) -> Self {
418 SecretsError::Io {
419 path: error.path,
420 source: error.source,
421 }
422 }
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428 use knot_runtime::{SeededEntropy, Signer, verify};
429
430 fn entropy(seed: u64) -> Box<dyn Entropy> {
431 Box::new(SeededEntropy::new(seed))
432 }
433
434 fn master() -> MasterKey {
435 MasterKey::new([7u8; 32]).unwrap()
436 }
437
438 fn kid(value: &str) -> KnotId {
439 KnotId::new(value).unwrap()
440 }
441
442 fn store_at(path: &Path, seed: u64) -> SealedStore {
443 SealedStore::open(path, &master(), entropy(seed)).unwrap()
444 }
445
446 fn store(seed: u64) -> (tempfile::TempDir, SealedStore) {
447 let dir = tempfile::tempdir().unwrap();
448 let store = store_at(&dir.path().join("keys.sealed"), seed);
449 (dir, store)
450 }
451
452 #[test]
453 fn an_absent_file_opens_as_an_empty_store() {
454 let (_dir, store) = store(1);
455 assert!(matches!(
456 store.signer(&kid("did:web:nel.pet")),
457 Err(SecretsError::Missing(_))
458 ));
459 }
460
461 #[test]
462 fn a_sealed_key_survives_a_reopen() {
463 let dir = tempfile::tempdir().unwrap();
464 let path = dir.path().join("keys.sealed");
465 let public = store_at(&path, 1).ensure(&kid("did:web:nel.pet")).unwrap();
466 let reopened = store_at(&path, 2);
467 assert_eq!(
468 reopened.public_key(&kid("did:web:nel.pet")).unwrap(),
469 public
470 );
471 }
472
473 #[test]
474 fn ensure_is_idempotent() {
475 let (_dir, store) = store(1);
476 let first = store.ensure(&kid("did:plc:squid")).unwrap();
477 let second = store.ensure(&kid("did:plc:squid")).unwrap();
478 assert_eq!(first, second);
479 }
480
481 #[test]
482 fn a_generated_key_signs_verifiably_and_round_trips_through_storage() {
483 let (_dir, store) = store(5);
484 let material = store.generate();
485 let public = material.public_key();
486 store.store(&kid("did:plc:limpet"), &material).unwrap();
487
488 let signer = store.signer(&kid("did:plc:limpet")).unwrap();
489 let signature = signer.sign(b"a meta-repo cob change");
490 assert!(verify(&public, b"a meta-repo cob change", &signature));
491 assert_eq!(signer.public_key(), public);
492 }
493
494 #[test]
495 fn storing_over_a_sealed_key_is_refused() {
496 let (_dir, store) = store(1);
497 let original = store.ensure(&kid("did:plc:limpet")).unwrap();
498 let intruder = store.generate();
499 assert!(matches!(
500 store.store(&kid("did:plc:limpet"), &intruder),
501 Err(SecretsError::Occupied(_))
502 ));
503 assert_eq!(
504 store.public_key(&kid("did:plc:limpet")).unwrap(),
505 original,
506 "refused overwrite must leave the sealed key untouched"
507 );
508 }
509
510 #[test]
511 fn master_key_length_is_enforced_at_construction() {
512 [(31usize, false), (32usize, true)]
513 .iter()
514 .for_each(|&(len, accepted)| {
515 let result = MasterKey::new(vec![7u8; len]);
516 assert_eq!(result.is_ok(), accepted, "{len}-byte master key acceptance");
517 assert!(
518 accepted
519 || matches!(result, Err(SecretsError::WeakMasterKey { len: reported }) if reported == len),
520 "a refused master key reports its short length"
521 );
522 });
523 }
524
525 #[test]
526 fn the_master_key_debug_redacts_its_bytes() {
527 let rendered = format!("{:?}", MasterKey::new([7u8; 32]).unwrap());
528 assert_eq!(rendered, "MasterKey(<redacted>)");
529 assert!(!rendered.contains('7'));
530 }
531
532 #[test]
533 fn a_wrong_master_key_fails_to_decrypt() {
534 let dir = tempfile::tempdir().unwrap();
535 let path = dir.path().join("k");
536 store_at(&path, 1).ensure(&kid("did:web:nel.pet")).unwrap();
537 assert!(matches!(
538 SealedStore::open(&path, &MasterKey::new([9u8; 32]).unwrap(), entropy(1)),
539 Err(SecretsError::Decrypt)
540 ));
541 }
542
543 #[test]
544 fn a_removed_key_is_gone_after_reopen() {
545 let dir = tempfile::tempdir().unwrap();
546 let path = dir.path().join("k");
547 let store = store_at(&path, 1);
548 store.ensure(&kid("did:plc:squid")).unwrap();
549 store.ensure(&kid("did:plc:clam")).unwrap();
550 assert!(store.remove(&kid("did:plc:squid")).unwrap());
551 assert!(!store.remove(&kid("did:plc:squid")).unwrap());
552
553 let reopened = store_at(&path, 1);
554 assert!(reopened.signer(&kid("did:plc:squid")).is_err());
555 assert!(reopened.signer(&kid("did:plc:clam")).is_ok());
556 }
557
558 #[test]
559 fn concurrent_writers_never_lose_a_sealed_key_or_error() {
560 use std::sync::Arc;
561
562 let dir = tempfile::tempdir().unwrap();
563 let path = dir.path().join("keys.sealed");
564 let store = Arc::new(
565 SealedStore::open(&path, &master(), Box::new(knot_runtime::OsEntropy)).unwrap(),
566 );
567 let dids: Vec<KnotId> = (0..64).map(|i| kid(&format!("did:plc:race{i}"))).collect();
568
569 std::thread::scope(|scope| {
570 dids.chunks(8).for_each(|chunk| {
571 let store = Arc::clone(&store);
572 let chunk = chunk.to_vec();
573 scope.spawn(move || {
574 chunk.iter().for_each(|did| {
575 store.ensure(did).expect("concurrent seal mustn't error");
576 });
577 });
578 });
579 });
580
581 let reopened = SealedStore::open(&path, &master(), entropy(99)).unwrap();
582 let missing: Vec<&KnotId> = dids
583 .iter()
584 .filter(|did| reopened.signer(*did).is_err())
585 .collect();
586 assert!(
587 missing.is_empty(),
588 "keys acknowledged in memory were lost from sealed file on disk: {missing:?}"
589 );
590 }
591
592 #[test]
593 fn concurrent_ensures_of_one_did_agree_on_a_single_key() {
594 use std::sync::Arc;
595
596 let dir = tempfile::tempdir().unwrap();
597 let store = Arc::new(
598 SealedStore::open(
599 dir.path().join("keys.sealed"),
600 &master(),
601 Box::new(knot_runtime::OsEntropy),
602 )
603 .unwrap(),
604 );
605 let contested = kid("did:plc:whelk");
606
607 let publics: Vec<PublicKeyBytes> = std::thread::scope(|scope| {
608 let handles: Vec<_> = (0..8)
609 .map(|_| {
610 let store = Arc::clone(&store);
611 let did = contested.clone();
612 scope.spawn(move || store.ensure(&did).unwrap())
613 })
614 .collect();
615 handles
616 .into_iter()
617 .map(|handle| handle.join().unwrap())
618 .collect()
619 });
620
621 assert!(
622 publics.windows(2).all(|pair| pair[0] == pair[1]),
623 "every racing ensure must acknowledge same sealed key"
624 );
625 assert_eq!(store.public_key(&contested).unwrap(), publics[0]);
626 }
627
628 #[test]
629 fn the_sealed_file_does_not_contain_raw_scalars() {
630 let dir = tempfile::tempdir().unwrap();
631 let path = dir.path().join("k");
632 let store = store_at(&path, 1);
633 let material = store.generate();
634 let scalar = material.scalar.0;
635 store.store(&kid("did:plc:squid"), &material).unwrap();
636 let sealed = std::fs::read(&path).unwrap();
637 assert!(
638 !sealed.windows(SCALAR_LEN).any(|window| window == scalar),
639 "plaintext scalar must never appear in the sealed file"
640 );
641 }
642
643 #[cfg(unix)]
644 fn set_mode(path: &Path, mode: u32) {
645 use std::os::unix::fs::PermissionsExt;
646 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
647 }
648
649 #[cfg(unix)]
650 fn make_read_only(dir: &Path) -> bool {
651 set_mode(dir, 0o555);
652 let enforced = std::fs::write(dir.join("probe"), b"probe").is_err();
653 if !enforced {
654 set_mode(dir, 0o755);
655 eprintln!("skipping permission fault injection, this user bypasses read-only modes");
656 }
657 enforced
658 }
659
660 #[cfg(unix)]
661 #[test]
662 fn a_failed_persist_acknowledges_no_key() {
663 let dir = tempfile::tempdir().unwrap();
664 let path = dir.path().join("keys.sealed");
665 let store = store_at(&path, 1);
666 store.ensure(&kid("did:plc:squid")).unwrap();
667
668 if !make_read_only(dir.path()) {
669 return;
670 }
671 let material = store.generate();
672 assert!(matches!(
673 store.store(&kid("did:plc:clam"), &material),
674 Err(SecretsError::Io { .. })
675 ));
676 assert!(
677 matches!(
678 store.signer(&kid("did:plc:clam")),
679 Err(SecretsError::Missing(_))
680 ),
681 "key whose persist failed mustn't be served from memory"
682 );
683 assert!(matches!(
684 store.ensure(&kid("did:plc:clam")),
685 Err(SecretsError::Io { .. })
686 ));
687 set_mode(dir.path(), 0o755);
688
689 let public = store.ensure(&kid("did:plc:clam")).unwrap();
690 let reopened = store_at(&path, 2);
691 assert_eq!(reopened.public_key(&kid("did:plc:clam")).unwrap(), public);
692 assert!(reopened.signer(&kid("did:plc:squid")).is_ok());
693 }
694
695 #[cfg(unix)]
696 #[test]
697 fn a_failed_removal_keeps_the_key_served_and_sealed() {
698 let dir = tempfile::tempdir().unwrap();
699 let path = dir.path().join("keys.sealed");
700 let store = store_at(&path, 1);
701 store.ensure(&kid("did:plc:squid")).unwrap();
702
703 if !make_read_only(dir.path()) {
704 return;
705 }
706 assert!(matches!(
707 store.remove(&kid("did:plc:squid")),
708 Err(SecretsError::Io { .. })
709 ));
710 assert!(
711 store.signer(&kid("did:plc:squid")).is_ok(),
712 "removal that failed to persist must leave the key in service"
713 );
714 set_mode(dir.path(), 0o755);
715
716 let reopened = store_at(&path, 2);
717 assert!(reopened.signer(&kid("did:plc:squid")).is_ok());
718 }
719
720 struct BrokenEntropy;
721
722 impl Entropy for BrokenEntropy {
723 fn next_u64(&self) -> u64 {
724 0
725 }
726
727 fn fill(&self, buffer: &mut [u8]) {
728 buffer.fill(0);
729 }
730
731 fn derive(&self, _label: u64) -> Box<dyn Entropy> {
732 Box::new(BrokenEntropy)
733 }
734 }
735
736 #[test]
737 #[should_panic(expected = "entropy failed to yield valid secp256k1 scalar")]
738 fn broken_entropy_fails_stop_instead_of_spinning() {
739 let _ = SecretScalar::generate(&BrokenEntropy);
740 }
741
742 #[test]
743 fn stale_temp_files_are_swept_on_open() {
744 let dir = tempfile::tempdir().unwrap();
745 let path = dir.path().join("keys.sealed");
746 let stale = dir.path().join(".keys.sealed.knot-tmp.4242.7");
747 let in_flight = dir.path().join(".keys.sealed.knot-tmp.4243.0");
748 let pre_rename = dir.path().join(".keys.sealed.4242.7.tmp");
749 std::fs::write(&stale, b"abandoned by a crashed run").unwrap();
750 std::fs::write(&in_flight, b"another process is sealing right now").unwrap();
751 std::fs::write(&pre_rename, b"abandoned before the staging rename").unwrap();
752 let long_ago = std::time::SystemTime::now() - std::time::Duration::from_secs(7 * 3600);
753 std::fs::File::options()
754 .write(true)
755 .open(&stale)
756 .unwrap()
757 .set_times(std::fs::FileTimes::new().set_modified(long_ago))
758 .unwrap();
759
760 store_at(&path, 1);
761 assert!(!stale.exists(), "abandoned temp file must be swept at open");
762 assert!(
763 in_flight.exists(),
764 "the sweep at open must keep the staging file a second process is filling"
765 );
766 assert!(
767 !pre_rename.exists(),
768 "a vault sealed by an older build still leaves temps this build has to reclaim"
769 );
770 }
771}