1use std::path::{Path, PathBuf};
31
32use ed25519_dalek::SigningKey as Ed25519SigningKey;
33use ed25519_dalek::pkcs8::EncodePublicKey;
34use ed25519_dalek::pkcs8::spki::der::pem::LineEnding;
35use ed25519_dalek::pkcs8::{DecodePrivateKey, EncodePrivateKey};
36use greentic_distributor_client::signing::{SigningError, key_id_for_public_key_pem};
37use rand::TryRngCore;
38use rand::rngs::OsRng;
39use thiserror::Error;
40use zeroize::Zeroizing;
41
42pub const OPERATOR_KEY_PATH_ENV: &str = "GTC_OPERATOR_KEY_PATH";
45
46#[cfg(unix)]
50fn dirs_home() -> Option<PathBuf> {
51 std::env::var_os("HOME").map(PathBuf::from)
52}
53
54#[cfg(windows)]
55fn dirs_home() -> Option<PathBuf> {
56 std::env::var_os("USERPROFILE").map(PathBuf::from)
57}
58
59#[cfg(not(any(unix, windows)))]
60fn dirs_home() -> Option<PathBuf> {
61 None
62}
63
64#[derive(Debug, Error)]
65pub enum OperatorKeyError {
66 #[error(
67 "cannot resolve operator key path: `${OPERATOR_KEY_PATH_ENV}` is unset and no home directory is available"
68 )]
69 NoHome,
70 #[error("operator key io on {path}: {source}")]
71 Io {
72 path: PathBuf,
73 #[source]
74 source: std::io::Error,
75 },
76 #[error("operator key parse: {0}")]
77 KeyDecode(String),
78 #[error("operator key derivation: {0}")]
79 Signing(#[from] SigningError),
80 #[error(
81 "operator public key `{pub_path}` is stale: id `{pub_id}` does not match private-key id `{priv_id}` — delete the `.pub` and re-run to regenerate, or restore the matching private key"
82 )]
83 StalePublicKey {
84 pub_path: PathBuf,
85 pub_id: String,
86 priv_id: String,
87 },
88 #[error("operator key entropy: {0}")]
89 Entropy(String),
90 #[error(
94 "operator key `{path}` has insecure permissions (mode {mode:#o}); expected mode `0600` (owner-only). Restore with `chmod 600 {path}` or delete and regenerate."
95 )]
96 InsecurePermissions { path: PathBuf, mode: u32 },
97 #[error(
101 "operator key `{path}` is not a regular file (symlinks, directories, FIFOs etc. are rejected)"
102 )]
103 NotRegularFile { path: PathBuf },
104 #[error(
111 "operator key path `{path}`: ancestor `{ancestor}` is a symlink. Re-create the directory as a real path (e.g. `mv {ancestor} {ancestor}.symlink && mkdir -p {ancestor}`) to prevent intermediate-symlink redirection."
112 )]
113 SymlinkInAncestor { path: PathBuf, ancestor: PathBuf },
114}
115
116pub struct OperatorKey {
118 pub path: PathBuf,
120 pub private_pem: Zeroizing<String>,
122 pub public_pem: String,
124 pub key_id: String,
126}
127
128impl std::fmt::Debug for OperatorKey {
129 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
130 f.debug_struct("OperatorKey")
131 .field("path", &self.path)
132 .field("private_pem", &"[REDACTED]")
133 .field("public_pem_len", &self.public_pem.len())
134 .field("key_id", &self.key_id)
135 .finish()
136 }
137}
138
139pub fn resolve_path() -> Result<PathBuf, OperatorKeyError> {
141 resolve_path_with(std::env::var_os(OPERATOR_KEY_PATH_ENV), dirs_home())
142}
143
144pub(crate) fn resolve_path_with(
148 override_path: Option<std::ffi::OsString>,
149 home: Option<PathBuf>,
150) -> Result<PathBuf, OperatorKeyError> {
151 if let Some(p) = override_path
152 && !p.is_empty()
153 {
154 return Ok(PathBuf::from(p));
155 }
156 home.map(|h| h.join(".greentic").join("operator").join("key.pem"))
157 .ok_or(OperatorKeyError::NoHome)
158}
159
160pub fn load_or_generate() -> Result<OperatorKey, OperatorKeyError> {
175 let path = resolve_path()?;
176 load_or_generate_at(&path)
177}
178
179pub fn load_existing_only() -> Result<OperatorKey, OperatorKeyError> {
186 let path = resolve_path()?;
187 refuse_symlink_in_ancestors(&path)?;
188 let pem = read_existing_securely(&path)?;
189 load_existing(&path, pem)
190}
191
192pub fn read_signing_key_at(path: &Path) -> Result<(Zeroizing<String>, String), OperatorKeyError> {
201 refuse_symlink_in_ancestors(path)?;
202 let private_pem = read_existing_securely(path)?;
203 let (_public_pem, key_id) = derive_public_pem_and_key_id(&private_pem)?;
204 Ok((private_pem, key_id))
205}
206
207pub fn load_or_generate_at(path: &Path) -> Result<OperatorKey, OperatorKeyError> {
210 refuse_symlink_in_ancestors(path)?;
216 match read_existing_securely(path) {
217 Ok(private_pem) => load_existing(path, private_pem),
218 Err(OperatorKeyError::Io { source, .. })
219 if source.kind() == std::io::ErrorKind::NotFound =>
220 {
221 generate_at(path)
222 }
223 Err(other) => Err(other),
224 }
225}
226
227fn refuse_symlink_in_ancestors(path: &Path) -> Result<(), OperatorKeyError> {
231 let mut ancestor = path.parent();
232 while let Some(p) = ancestor {
233 if p.as_os_str().is_empty() {
234 break;
235 }
236 match std::fs::symlink_metadata(p) {
237 Ok(meta) => {
238 if meta.file_type().is_symlink() {
239 return Err(OperatorKeyError::SymlinkInAncestor {
240 path: path.to_path_buf(),
241 ancestor: p.to_path_buf(),
242 });
243 }
244 }
245 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
246 }
248 Err(source) => {
249 return Err(OperatorKeyError::Io {
250 path: p.to_path_buf(),
251 source,
252 });
253 }
254 }
255 ancestor = p.parent();
256 }
257 Ok(())
258}
259
260fn read_existing_securely(path: &Path) -> Result<Zeroizing<String>, OperatorKeyError> {
270 let file = open_no_follow(path)?;
271 let meta = file.metadata().map_err(|source| OperatorKeyError::Io {
272 path: path.to_path_buf(),
273 source,
274 })?;
275 if !meta.is_file() {
276 return Err(OperatorKeyError::NotRegularFile {
277 path: path.to_path_buf(),
278 });
279 }
280 check_mode(path, &meta)?;
281 let len = meta.len().try_into().unwrap_or(usize::MAX);
286 let mut contents = Zeroizing::new(String::with_capacity(len.saturating_add(8)));
287 use std::io::Read;
288 {
289 let mut handle = file;
290 handle
291 .read_to_string(&mut contents)
292 .map_err(|source| OperatorKeyError::Io {
293 path: path.to_path_buf(),
294 source,
295 })?;
296 }
297 Ok(contents)
298}
299
300#[cfg(unix)]
301fn open_no_follow(path: &Path) -> Result<std::fs::File, OperatorKeyError> {
302 use std::os::unix::fs::OpenOptionsExt;
303 let result = std::fs::OpenOptions::new()
307 .read(true)
308 .custom_flags(libc::O_NOFOLLOW)
309 .open(path);
310 match result {
311 Ok(f) => Ok(f),
312 Err(e) => {
313 #[allow(clippy::manual_map)]
316 if let Some(raw) = e.raw_os_error()
317 && raw == libc::ELOOP
318 {
319 return Err(OperatorKeyError::NotRegularFile {
320 path: path.to_path_buf(),
321 });
322 }
323 Err(OperatorKeyError::Io {
324 path: path.to_path_buf(),
325 source: e,
326 })
327 }
328 }
329}
330
331#[cfg(not(unix))]
332fn open_no_follow(path: &Path) -> Result<std::fs::File, OperatorKeyError> {
333 std::fs::OpenOptions::new()
337 .read(true)
338 .open(path)
339 .map_err(|source| OperatorKeyError::Io {
340 path: path.to_path_buf(),
341 source,
342 })
343}
344
345#[cfg(unix)]
346fn check_mode(path: &Path, meta: &std::fs::Metadata) -> Result<(), OperatorKeyError> {
347 use std::os::unix::fs::MetadataExt;
348 let mode = meta.mode() & 0o777;
349 if mode & 0o077 != 0 {
350 return Err(OperatorKeyError::InsecurePermissions {
351 path: path.to_path_buf(),
352 mode,
353 });
354 }
355 Ok(())
356}
357
358#[cfg(not(unix))]
359fn check_mode(_path: &Path, _meta: &std::fs::Metadata) -> Result<(), OperatorKeyError> {
360 Ok(())
361}
362
363fn derive_public_pem_and_key_id(private_pem: &str) -> Result<(String, String), OperatorKeyError> {
367 let sk = Ed25519SigningKey::from_pkcs8_pem(private_pem)
368 .map_err(|e| OperatorKeyError::KeyDecode(format!("PKCS#8 private PEM: {e}")))?;
369 let vk = sk.verifying_key();
370 let public_pem = vk
371 .to_public_key_pem(LineEnding::LF)
372 .map_err(|e| OperatorKeyError::KeyDecode(format!("derive SPKI PEM: {e}")))?;
373 drop(sk);
374 let key_id = key_id_for_public_key_pem(&public_pem)?;
375 Ok((public_pem, key_id))
376}
377
378fn load_existing(
379 path: &Path,
380 private_pem: Zeroizing<String>,
381) -> Result<OperatorKey, OperatorKeyError> {
382 let (public_pem, key_id) = derive_public_pem_and_key_id(&private_pem)?;
383
384 let pub_path = public_sibling(path);
385 match std::fs::read_to_string(&pub_path) {
386 Ok(existing_pub) => {
387 let existing_id = key_id_for_public_key_pem(&existing_pub).map_err(|e| {
388 OperatorKeyError::KeyDecode(format!(
389 "`.pub` sibling at {}: {e}",
390 pub_path.display()
391 ))
392 })?;
393 if !existing_id.eq_ignore_ascii_case(&key_id) {
394 return Err(OperatorKeyError::StalePublicKey {
395 pub_path,
396 pub_id: existing_id,
397 priv_id: key_id,
398 });
399 }
400 }
401 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
402 write_public_sibling(&pub_path, &public_pem)?;
405 }
406 Err(source) => {
407 return Err(OperatorKeyError::Io {
408 path: pub_path,
409 source,
410 });
411 }
412 }
413
414 Ok(OperatorKey {
415 path: path.to_path_buf(),
416 private_pem,
417 public_pem,
418 key_id,
419 })
420}
421
422fn generate_at(path: &Path) -> Result<OperatorKey, OperatorKeyError> {
423 let mut seed = Zeroizing::new([0u8; 32]);
429 OsRng
430 .try_fill_bytes(&mut seed[..])
431 .map_err(|e| OperatorKeyError::Entropy(e.to_string()))?;
432 let sk = Ed25519SigningKey::from_bytes(&seed);
433 let vk = sk.verifying_key();
434 let private_pem: Zeroizing<String> = Zeroizing::new(
439 sk.to_pkcs8_pem(LineEnding::LF)
440 .map_err(|e| OperatorKeyError::KeyDecode(format!("encode PKCS#8 PEM: {e}")))?
441 .to_string(),
442 );
443 let public_pem = vk
444 .to_public_key_pem(LineEnding::LF)
445 .map_err(|e| OperatorKeyError::KeyDecode(format!("encode SPKI PEM: {e}")))?;
446 drop(sk);
447 let key_id = key_id_for_public_key_pem(&public_pem)?;
448
449 if let Some(parent) = path.parent()
450 && !parent.as_os_str().is_empty()
451 {
452 std::fs::create_dir_all(parent).map_err(|source| OperatorKeyError::Io {
453 path: parent.to_path_buf(),
454 source,
455 })?;
456 }
457 match write_private_exclusive(path, &private_pem) {
461 Ok(()) => {
462 let pub_path = public_sibling(path);
463 let _ = write_public_sibling_exclusive(&pub_path, &public_pem);
465 Ok(OperatorKey {
466 path: path.to_path_buf(),
467 private_pem,
468 public_pem,
469 key_id,
470 })
471 }
472 Err(OperatorKeyError::Io { source, .. })
473 if source.kind() == std::io::ErrorKind::AlreadyExists =>
474 {
475 let existing = read_existing_securely(path)?;
483 load_existing(path, existing)
484 }
485 Err(other) => Err(other),
486 }
487}
488
489fn public_sibling(private_path: &Path) -> PathBuf {
490 let mut s = private_path.as_os_str().to_owned();
491 s.push(".pub");
492 PathBuf::from(s)
493}
494
495fn write_exclusive(path: &Path, contents: &str, mode: u32) -> Result<(), OperatorKeyError> {
500 use std::io::Write;
501 let parent = path
502 .parent()
503 .filter(|p| !p.as_os_str().is_empty())
504 .map(|p| p.to_path_buf())
505 .unwrap_or_else(|| std::path::Path::new(".").to_path_buf());
506 let mut tmp =
507 tempfile::NamedTempFile::new_in(&parent).map_err(|source| OperatorKeyError::Io {
508 path: parent.clone(),
509 source,
510 })?;
511 tmp.write_all(contents.as_bytes())
512 .map_err(|source| OperatorKeyError::Io {
513 path: tmp.path().to_path_buf(),
514 source,
515 })?;
516 tmp.flush().map_err(|source| OperatorKeyError::Io {
517 path: tmp.path().to_path_buf(),
518 source,
519 })?;
520 set_mode(&tmp, mode)?;
521 tmp.as_file()
522 .sync_all()
523 .map_err(|source| OperatorKeyError::Io {
524 path: tmp.path().to_path_buf(),
525 source,
526 })?;
527 tmp.persist_noclobber(path)
528 .map_err(|e| OperatorKeyError::Io {
529 path: path.to_path_buf(),
530 source: e.error,
531 })?;
532 Ok(())
533}
534
535#[cfg(unix)]
536fn set_mode(tmp: &tempfile::NamedTempFile, mode: u32) -> Result<(), OperatorKeyError> {
537 use std::os::unix::fs::PermissionsExt;
538 let perms = std::fs::Permissions::from_mode(mode);
539 std::fs::set_permissions(tmp.path(), perms).map_err(|source| OperatorKeyError::Io {
540 path: tmp.path().to_path_buf(),
541 source,
542 })
543}
544
545#[cfg(not(unix))]
546fn set_mode(_tmp: &tempfile::NamedTempFile, _mode: u32) -> Result<(), OperatorKeyError> {
547 Ok(())
548}
549
550fn write_private_exclusive(path: &Path, contents: &str) -> Result<(), OperatorKeyError> {
551 write_exclusive(path, contents, 0o600)
552}
553
554fn write_public_sibling_exclusive(path: &Path, contents: &str) -> Result<(), OperatorKeyError> {
555 write_exclusive(path, contents, 0o644)
556}
557
558fn write_public_sibling(path: &Path, contents: &str) -> Result<(), OperatorKeyError> {
562 match write_public_sibling_exclusive(path, contents) {
566 Ok(()) => Ok(()),
567 Err(OperatorKeyError::Io { source, .. })
568 if source.kind() == std::io::ErrorKind::AlreadyExists =>
569 {
570 Ok(())
571 }
572 Err(other) => Err(other),
573 }
574}
575
576#[cfg(test)]
577mod tests {
578 use super::*;
579 use tempfile::tempdir;
580
581 #[cfg(unix)]
582 fn chmod(path: &Path, mode: u32) {
583 use std::os::unix::fs::PermissionsExt;
584 std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode)).unwrap();
585 }
586
587 #[test]
588 fn generate_creates_keypair_with_canonical_id() {
589 let dir = tempdir().unwrap();
590 let path = dir.path().join("key.pem");
591 let key = load_or_generate_at(&path).unwrap();
592 assert_eq!(key.key_id.len(), 32);
593 assert!(key.private_pem.contains("BEGIN PRIVATE KEY"));
594 assert!(key.public_pem.contains("BEGIN PUBLIC KEY"));
595 assert!(path.is_file(), "private key file must exist");
596 let pub_path = public_sibling(&path);
597 assert!(pub_path.is_file(), "public sibling must exist");
598 let pub_id =
599 key_id_for_public_key_pem(&std::fs::read_to_string(&pub_path).unwrap()).unwrap();
600 assert_eq!(pub_id, key.key_id);
601 }
602
603 #[test]
604 fn load_is_idempotent_after_generate() {
605 let dir = tempdir().unwrap();
606 let path = dir.path().join("key.pem");
607 let first = load_or_generate_at(&path).unwrap();
608 let second = load_or_generate_at(&path).unwrap();
609 assert_eq!(first.key_id, second.key_id);
610 assert_eq!(first.public_pem, second.public_pem);
611 }
612
613 #[cfg(unix)]
614 #[test]
615 fn private_key_is_mode_0600() {
616 use std::os::unix::fs::PermissionsExt;
617 let dir = tempdir().unwrap();
618 let path = dir.path().join("key.pem");
619 load_or_generate_at(&path).unwrap();
620 let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
621 assert_eq!(mode, 0o600);
622 }
623
624 #[test]
625 fn stale_pub_sibling_is_rejected() {
626 let dir = tempdir().unwrap();
627 let path = dir.path().join("key.pem");
628 load_or_generate_at(&path).unwrap();
629 let other_sk = Ed25519SigningKey::from_bytes(&[7u8; 32]);
631 let other_pub = other_sk
632 .verifying_key()
633 .to_public_key_pem(LineEnding::LF)
634 .unwrap();
635 let pub_path = public_sibling(&path);
636 std::fs::write(&pub_path, &other_pub).unwrap();
637
638 let err = load_or_generate_at(&path).expect_err("stale pub must be rejected");
639 assert!(matches!(err, OperatorKeyError::StalePublicKey { .. }));
640 }
641
642 #[test]
643 fn missing_pub_sibling_is_regenerated_on_load() {
644 let dir = tempdir().unwrap();
645 let path = dir.path().join("key.pem");
646 load_or_generate_at(&path).unwrap();
647 let pub_path = public_sibling(&path);
648 std::fs::remove_file(&pub_path).unwrap();
649
650 let key = load_or_generate_at(&path).unwrap();
651 assert!(pub_path.is_file(), "pub sibling must be regenerated");
652 let pub_id =
653 key_id_for_public_key_pem(&std::fs::read_to_string(&pub_path).unwrap()).unwrap();
654 assert_eq!(pub_id, key.key_id);
655 }
656
657 #[test]
658 fn env_override_takes_precedence() {
659 let dir = tempdir().unwrap();
660 let path = dir.path().join("override.pem");
661 let resolved = resolve_path_with(
662 Some(path.as_os_str().to_owned()),
663 Some(PathBuf::from("/should-not-be-used")),
664 )
665 .unwrap();
666 assert_eq!(resolved, path);
667 }
668
669 #[test]
670 fn empty_env_override_falls_through_to_home() {
671 let home = PathBuf::from("/home/op");
672 let resolved =
673 resolve_path_with(Some(std::ffi::OsString::new()), Some(home.clone())).unwrap();
674 assert_eq!(
675 resolved,
676 home.join(".greentic").join("operator").join("key.pem")
677 );
678 }
679
680 #[test]
681 fn missing_env_and_missing_home_is_no_home_error() {
682 let err = resolve_path_with(None, None).expect_err("must error");
683 assert!(matches!(err, OperatorKeyError::NoHome));
684 }
685
686 #[test]
687 fn debug_redacts_private_pem() {
688 let dir = tempdir().unwrap();
689 let path = dir.path().join("key.pem");
690 let key = load_or_generate_at(&path).unwrap();
691 let dbg = format!("{key:?}");
692 assert!(dbg.contains("[REDACTED]"));
693 assert!(!dbg.contains("BEGIN PRIVATE KEY"));
694 }
695
696 #[test]
697 fn concurrent_generates_converge_on_one_key() {
698 let dir = tempdir().unwrap();
702 let path = std::sync::Arc::new(dir.path().join("key.pem"));
703 let handles: Vec<_> = (0..8)
704 .map(|_| {
705 let p = path.clone();
706 std::thread::spawn(move || load_or_generate_at(&p).unwrap().key_id)
707 })
708 .collect();
709 let ids: Vec<String> = handles.into_iter().map(|h| h.join().unwrap()).collect();
710 let first = &ids[0];
711 for id in &ids {
712 assert_eq!(id, first, "all racers must adopt one canonical key");
713 }
714 }
715
716 #[cfg(unix)]
717 #[test]
718 fn existing_key_with_world_readable_mode_is_rejected() {
719 let dir = tempdir().unwrap();
720 let path = dir.path().join("key.pem");
721 load_or_generate_at(&path).unwrap();
722 chmod(&path, 0o644);
723 let err = load_or_generate_at(&path).expect_err("0644 must be rejected");
724 match err {
725 OperatorKeyError::InsecurePermissions { mode, .. } => assert_eq!(mode, 0o644),
726 other => panic!("expected InsecurePermissions, got {other:?}"),
727 }
728 }
729
730 #[cfg(unix)]
731 #[test]
732 fn existing_key_with_group_readable_mode_is_rejected() {
733 let dir = tempdir().unwrap();
734 let path = dir.path().join("key.pem");
735 load_or_generate_at(&path).unwrap();
736 chmod(&path, 0o660);
737 let err = load_or_generate_at(&path).expect_err("0660 must be rejected");
738 match err {
739 OperatorKeyError::InsecurePermissions { mode, .. } => assert_eq!(mode, 0o660),
740 other => panic!("expected InsecurePermissions, got {other:?}"),
741 }
742 }
743
744 #[cfg(unix)]
745 #[test]
746 fn existing_key_with_mode_0600_is_accepted() {
747 let dir = tempdir().unwrap();
748 let path = dir.path().join("key.pem");
749 let first = load_or_generate_at(&path).unwrap();
750 chmod(&path, 0o600);
751 let second = load_or_generate_at(&path).unwrap();
752 assert_eq!(first.key_id, second.key_id);
753 }
754
755 #[cfg(unix)]
756 #[test]
757 fn symlinked_key_path_is_rejected() {
758 let dir = tempdir().unwrap();
759 let real_path = dir.path().join("real-key.pem");
760 load_or_generate_at(&real_path).unwrap();
761 let link_path = dir.path().join("via-link.pem");
762 std::os::unix::fs::symlink(&real_path, &link_path).unwrap();
763 let err = load_or_generate_at(&link_path).expect_err("symlink target must be rejected");
764 assert!(
765 matches!(err, OperatorKeyError::NotRegularFile { .. }),
766 "expected NotRegularFile, got {err:?}"
767 );
768 }
769
770 #[cfg(unix)]
771 #[test]
772 fn symlinked_intermediate_directory_is_rejected() {
773 let dir = tempdir().unwrap();
778 let attacker_root = dir.path().join("evil");
779 std::fs::create_dir_all(&attacker_root).unwrap();
780 let symlinked_parent = dir.path().join("operator");
781 std::os::unix::fs::symlink(&attacker_root, &symlinked_parent).unwrap();
782 let key_path = symlinked_parent.join("key.pem");
783 let err =
784 load_or_generate_at(&key_path).expect_err("intermediate symlink must be rejected");
785 match err {
786 OperatorKeyError::SymlinkInAncestor { ancestor, .. } => {
787 assert_eq!(ancestor, symlinked_parent);
788 }
789 other => panic!("expected SymlinkInAncestor, got {other:?}"),
790 }
791 assert!(
793 !attacker_root.join("key.pem").exists(),
794 "load must refuse before any write reaches the symlinked dir"
795 );
796 }
797
798 #[cfg(unix)]
799 #[test]
800 fn directory_at_key_path_is_rejected() {
801 let dir = tempdir().unwrap();
802 let path = dir.path().join("key.pem");
803 std::fs::create_dir(&path).unwrap();
804 let err = load_or_generate_at(&path).expect_err("directory must be rejected");
805 assert!(
807 matches!(err, OperatorKeyError::NotRegularFile { .. }),
808 "expected NotRegularFile, got {err:?}"
809 );
810 }
811
812 #[test]
813 fn corrupted_private_pem_is_rejected() {
814 let dir = tempdir().unwrap();
815 let path = dir.path().join("key.pem");
816 std::fs::write(
817 &path,
818 "-----BEGIN PRIVATE KEY-----\nnope\n-----END PRIVATE KEY-----\n",
819 )
820 .unwrap();
821 #[cfg(unix)]
824 chmod(&path, 0o600);
825 let err = load_or_generate_at(&path).expect_err("bad PEM must reject");
826 assert!(matches!(err, OperatorKeyError::KeyDecode(_)));
827 }
828
829 #[test]
832 fn read_signing_key_at_valid_key_matches_load_or_generate() {
833 let dir = tempdir().unwrap();
834 let path = dir.path().join("key.pem");
835 let generated = load_or_generate_at(&path).unwrap();
836 let (pem, kid) = read_signing_key_at(&path).unwrap();
837 assert_eq!(kid.len(), 32, "key_id must be a 32-hex-char string");
838 assert_eq!(
839 kid, generated.key_id,
840 "key_id must match load_or_generate_at"
841 );
842 assert_eq!(*pem, *generated.private_pem);
843 }
844
845 #[cfg(unix)]
846 #[test]
847 fn read_signing_key_at_rejects_mode_0644() {
848 let dir = tempdir().unwrap();
849 let path = dir.path().join("key.pem");
850 load_or_generate_at(&path).unwrap();
851 chmod(&path, 0o644);
852 let err = read_signing_key_at(&path).expect_err("0644 must be rejected");
853 assert!(
854 matches!(err, OperatorKeyError::InsecurePermissions { .. }),
855 "expected InsecurePermissions, got {err:?}"
856 );
857 }
858
859 #[cfg(unix)]
860 #[test]
861 fn read_signing_key_at_rejects_symlinked_key() {
862 let dir = tempdir().unwrap();
863 let real = dir.path().join("real.pem");
864 load_or_generate_at(&real).unwrap();
865 let link = dir.path().join("link.pem");
866 std::os::unix::fs::symlink(&real, &link).unwrap();
867 let err = read_signing_key_at(&link).expect_err("symlink must be rejected");
868 assert!(
869 matches!(err, OperatorKeyError::NotRegularFile { .. }),
870 "expected NotRegularFile, got {err:?}"
871 );
872 }
873
874 #[cfg(unix)]
875 #[test]
876 fn read_signing_key_at_rejects_symlinked_ancestor() {
877 let dir = tempdir().unwrap();
878 let real_dir = dir.path().join("real-dir");
879 std::fs::create_dir_all(&real_dir).unwrap();
880 let key_in_real = real_dir.join("key.pem");
881 load_or_generate_at(&key_in_real).unwrap();
882 let sym_dir = dir.path().join("sym-dir");
883 std::os::unix::fs::symlink(&real_dir, &sym_dir).unwrap();
884 let key_via_sym = sym_dir.join("key.pem");
885 let err = read_signing_key_at(&key_via_sym).expect_err("ancestor symlink must reject");
886 match err {
887 OperatorKeyError::SymlinkInAncestor { ancestor, .. } => {
888 assert_eq!(ancestor, sym_dir);
889 }
890 other => panic!("expected SymlinkInAncestor, got {other:?}"),
891 }
892 }
893
894 #[test]
895 fn read_signing_key_at_does_not_write_pub_sibling() {
896 let dir = tempdir().unwrap();
897 let path = dir.path().join("key.pem");
898 load_or_generate_at(&path).unwrap();
899 let pub_path = public_sibling(&path);
900 assert!(pub_path.exists());
901 std::fs::remove_file(&pub_path).unwrap();
902 let (_pem, _kid) = read_signing_key_at(&path).unwrap();
903 assert!(
904 !pub_path.exists(),
905 "read_signing_key_at must not create a .pub sibling"
906 );
907 }
908
909 #[test]
910 fn read_signing_key_at_nonexistent_yields_not_found() {
911 let dir = tempdir().unwrap();
912 let path = dir.path().join("does-not-exist.pem");
913 let err = read_signing_key_at(&path).expect_err("missing file must error");
914 match err {
915 OperatorKeyError::Io { source, .. } => {
916 assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
917 }
918 other => panic!("expected Io with NotFound, got {other:?}"),
919 }
920 }
921}