This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

core / knot2 / crates / knot-secrets / src / lib.rs
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}