1use std::{
30 fs,
31 io::{self, Read},
32 path::{Path, PathBuf},
33 sync::Once,
34};
35
36use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
37use ed25519_dalek::VerifyingKey;
38use serde::{Deserialize, Serialize};
39
40static WARN_TRUST_PATH_OVERRIDE_ONCE: Once = Once::new();
47static WARN_INSECURE_PERMS_ONCE: Once = Once::new();
48
49fn warn_trust_path_override_if_set() {
50 if let Some(p) = std::env::var_os("TREESHIP_TRUST_ROOTS") {
51 WARN_TRUST_PATH_OVERRIDE_ONCE.call_once(|| {
52 eprintln!(
53 "treeship: WARNING: trust store path overridden by TREESHIP_TRUST_ROOTS={} (not the default ~/.treeship/trust_roots.json)",
54 std::path::Path::new(&p).display(),
55 );
56 });
57 }
58}
59
60fn warn_insecure_perms_if_bypassed() {
61 if std::env::var_os("TREESHIP_ALLOW_INSECURE_KEY_PERMS")
62 .map(|v| v == "1")
63 .unwrap_or(false)
64 {
65 WARN_INSECURE_PERMS_ONCE.call_once(|| {
66 eprintln!(
67 "treeship: WARNING: trust file permission check bypassed by TREESHIP_ALLOW_INSECURE_KEY_PERMS=1 -- this opens a supply-chain hole if not a deliberate CI sandbox override"
68 );
69 });
70 }
71}
72
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum TrustRootKind {
84 HubCheckpoint,
88 Ship,
99 HubOrg,
104 CertIssuer,
106 Revoker,
108 AgentCert,
110 SessionHost,
118}
119
120impl TrustRootKind {
121 pub fn as_str(self) -> &'static str {
122 match self {
123 Self::HubCheckpoint => "hub_checkpoint",
124 Self::Ship => "ship",
125 Self::HubOrg => "hub_org",
126 Self::CertIssuer => "cert_issuer",
127 Self::Revoker => "revoker",
128 Self::AgentCert => "agent_cert",
129 Self::SessionHost => "session_host",
130 }
131 }
132
133 pub fn parse(s: &str) -> Option<Self> {
134 match s {
135 "hub_checkpoint" => Some(Self::HubCheckpoint),
136 "ship" => Some(Self::Ship),
137 "hub_org" => Some(Self::HubOrg),
138 "cert_issuer" => Some(Self::CertIssuer),
139 "revoker" => Some(Self::Revoker),
140 "agent_cert" => Some(Self::AgentCert),
141 "session_host" => Some(Self::SessionHost),
142 _ => None,
143 }
144 }
145
146 pub fn is_deprecated_ship(self) -> bool {
149 matches!(self, Self::Ship)
150 }
151}
152
153#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub struct TrustRoot {
156 pub key_id: String,
161
162 pub public_key: String,
166
167 pub kind: TrustRootKind,
169
170 #[serde(default)]
173 pub label: String,
174
175 #[serde(default)]
177 pub added_at: String,
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
183struct TrustRootFile {
184 pub version: u8,
186 pub roots: Vec<TrustRoot>,
187}
188
189const SCHEMA_VERSION: u8 = 1;
190
191#[derive(Debug, Clone, Default)]
193pub struct TrustRootStore {
194 roots: Vec<TrustRoot>,
195}
196
197#[derive(Debug)]
199pub enum TrustRootError {
200 NotConfigured { path: PathBuf },
203 Malformed { path: PathBuf, msg: String },
205 Empty { path: PathBuf },
209 PermissionsTooOpen { path: PathBuf, mode: u32 },
211 Io(io::Error),
213}
214
215impl std::fmt::Display for TrustRootError {
216 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
217 match self {
218 Self::NotConfigured { path } => write!(
219 f,
220 "no trust roots configured (looked for {}). \
221 Run `treeship trust add <key_id> <pubkey> --kind <kind>` \
222 or sync from your hub via `treeship hub sync-trust`.",
223 path.display(),
224 ),
225 Self::Malformed { path, msg } => write!(
226 f,
227 "trust root file {} is malformed: {msg}",
228 path.display(),
229 ),
230 Self::Empty { path } => write!(
231 f,
232 "trust root file {} has no roots configured. \
233 Run `treeship trust add <key_id> <pubkey> --kind <kind>` \
234 to add an issuer.",
235 path.display(),
236 ),
237 Self::PermissionsTooOpen { path, mode } => write!(
238 f,
239 "trust root file {} has insecure permissions (mode {:o}); \
240 chmod 600 the file and try again.",
241 path.display(),
242 mode & 0o777,
243 ),
244 Self::Io(e) => write!(f, "trust root io: {e}"),
245 }
246 }
247}
248
249impl std::error::Error for TrustRootError {}
250
251impl From<io::Error> for TrustRootError {
252 fn from(e: io::Error) -> Self { Self::Io(e) }
253}
254
255impl TrustRootStore {
256 pub fn default_path() -> PathBuf {
263 warn_trust_path_override_if_set();
264 std::env::var_os("TREESHIP_TRUST_ROOTS")
265 .map(PathBuf::from)
266 .unwrap_or_else(|| {
267 let home = std::env::var("HOME").unwrap_or_default();
268 PathBuf::from(home).join(".treeship").join("trust_roots.json")
269 })
270 }
271
272 pub fn empty() -> Self {
276 Self { roots: Vec::new() }
277 }
278
279 pub fn with_roots(roots: Vec<TrustRoot>) -> Self {
283 Self { roots }
284 }
285
286 pub fn open_or_empty(path: &Path) -> Result<Self, TrustRootError> {
292 match Self::open(path) {
293 Ok(s) => Ok(s),
294 Err(TrustRootError::NotConfigured { .. }) => Ok(Self::empty()),
295 Err(TrustRootError::Empty { .. }) => Ok(Self::empty()),
296 Err(e) => Err(e),
297 }
298 }
299
300 pub fn open_default_or_empty() -> Result<Self, TrustRootError> {
304 Self::open_or_empty(&Self::default_path())
305 }
306
307 pub fn open(path: &Path) -> Result<Self, TrustRootError> {
318 let mut file = match fs::File::open(path) {
319 Ok(f) => f,
320 Err(e) if e.kind() == io::ErrorKind::NotFound => {
321 return Err(TrustRootError::NotConfigured { path: path.to_path_buf() });
322 }
323 Err(e) => return Err(TrustRootError::Io(e)),
324 };
325 check_open_trust_file_perms(path, &file)?;
326 let mut bytes = Vec::new();
327 file.read_to_end(&mut bytes)?;
328 let file: TrustRootFile = serde_json::from_slice(&bytes)
329 .map_err(|e| TrustRootError::Malformed {
330 path: path.to_path_buf(),
331 msg: e.to_string(),
332 })?;
333 if file.version != SCHEMA_VERSION {
334 return Err(TrustRootError::Malformed {
335 path: path.to_path_buf(),
336 msg: format!(
337 "schema version mismatch: file has v{}, this binary supports v{}",
338 file.version, SCHEMA_VERSION,
339 ),
340 });
341 }
342 for root in &file.roots {
345 decode_ed25519_pubkey(&root.public_key)
346 .map_err(|msg| TrustRootError::Malformed {
347 path: path.to_path_buf(),
348 msg: format!("root {}: {msg}", root.key_id),
349 })?;
350 }
351 if file.roots.is_empty() {
352 return Err(TrustRootError::Empty { path: path.to_path_buf() });
353 }
354 Ok(Self { roots: file.roots })
355 }
356
357 pub fn save(&self, path: &Path) -> Result<(), TrustRootError> {
360 if let Some(parent) = path.parent() {
361 fs::create_dir_all(parent)?;
362 #[cfg(unix)]
363 {
364 use std::os::unix::fs::PermissionsExt;
365 let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
366 }
367 }
368 let file = TrustRootFile {
369 version: SCHEMA_VERSION,
370 roots: self.roots.clone(),
371 };
372 let json = serde_json::to_vec_pretty(&file)
373 .map_err(|e| TrustRootError::Malformed {
374 path: path.to_path_buf(),
375 msg: e.to_string(),
376 })?;
377 fs::write(path, &json)?;
378 #[cfg(unix)]
379 {
380 use std::os::unix::fs::PermissionsExt;
381 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
382 }
383 Ok(())
384 }
385
386 pub fn contains(&self, key: &VerifyingKey, kind: TrustRootKind) -> bool {
390 let key_bytes = key.to_bytes();
391 self.roots.iter().any(|r| {
392 r.kind == kind
393 && decode_ed25519_pubkey(&r.public_key)
394 .map(|k| k.to_bytes() == key_bytes)
395 .unwrap_or(false)
396 })
397 }
398
399 pub fn contains_bytes(&self, key_bytes: &[u8; 32], kind: TrustRootKind) -> bool {
404 match VerifyingKey::from_bytes(key_bytes) {
405 Ok(vk) => self.contains(&vk, kind),
406 Err(_) => false,
407 }
408 }
409
410 pub fn is_empty(&self) -> bool {
414 self.roots.is_empty()
415 }
416
417 pub fn is_empty_for_kind(&self, kind: TrustRootKind) -> bool {
422 !self.roots.iter().any(|r| r.kind == kind)
423 }
424
425 pub fn add(&mut self, root: TrustRoot) {
429 self.roots.retain(|r| !(r.key_id == root.key_id && r.kind == root.kind));
430 self.roots.push(root);
431 }
432
433 pub fn remove(&mut self, key_id: &str) -> bool {
436 let before = self.roots.len();
437 self.roots.retain(|r| r.key_id != key_id);
438 self.roots.len() != before
439 }
440
441 pub fn roots(&self) -> &[TrustRoot] {
443 &self.roots
444 }
445
446 pub fn len(&self) -> usize {
448 self.roots.len()
449 }
450}
451
452pub fn decode_ed25519_pubkey(s: &str) -> Result<VerifyingKey, String> {
456 let b64 = s.strip_prefix("ed25519:").unwrap_or(s);
457 let bytes = URL_SAFE_NO_PAD
458 .decode(b64)
459 .map_err(|e| format!("base64url decode failed: {e}"))?;
460 let arr: [u8; 32] = bytes
461 .as_slice()
462 .try_into()
463 .map_err(|_| format!("expected 32-byte public key, got {} bytes", bytes.len()))?;
464 VerifyingKey::from_bytes(&arr).map_err(|e| format!("not a valid Ed25519 public key: {e}"))
465}
466
467pub fn encode_ed25519_pubkey(key: &VerifyingKey) -> String {
469 format!("ed25519:{}", URL_SAFE_NO_PAD.encode(key.to_bytes()))
470}
471
472#[allow(dead_code)]
473fn check_trust_file_perms(path: &Path) -> Result<(), TrustRootError> {
474 #[cfg(unix)]
475 {
476 use std::os::unix::fs::PermissionsExt;
477 if std::env::var_os("TREESHIP_ALLOW_INSECURE_KEY_PERMS")
483 .map(|v| v == "1")
484 .unwrap_or(false)
485 {
486 warn_insecure_perms_if_bypassed();
487 return Ok(());
488 }
489 let meta = fs::metadata(path)?;
490 let mode = meta.permissions().mode();
491 if mode & 0o077 != 0 {
492 return Err(TrustRootError::PermissionsTooOpen {
493 path: path.to_path_buf(),
494 mode,
495 });
496 }
497 }
498 let _ = path;
499 Ok(())
500}
501
502#[allow(unused_variables)]
514fn check_open_trust_file_perms(path: &Path, file: &fs::File) -> Result<(), TrustRootError> {
515 #[cfg(unix)]
516 {
517 use std::os::unix::fs::PermissionsExt;
518 if std::env::var_os("TREESHIP_ALLOW_INSECURE_KEY_PERMS")
519 .map(|v| v == "1")
520 .unwrap_or(false)
521 {
522 warn_insecure_perms_if_bypassed();
523 return Ok(());
524 }
525 let meta = file.metadata()?;
526 let mode = meta.permissions().mode();
527 if mode & 0o077 != 0 {
528 return Err(TrustRootError::PermissionsTooOpen {
529 path: path.to_path_buf(),
530 mode,
531 });
532 }
533 }
534 Ok(())
535}
536
537#[cfg(test)]
542mod tests {
543 use super::*;
544 use ed25519_dalek::SigningKey;
545
546 fn tmp_dir(tag: &str) -> PathBuf {
547 let mut p = std::env::temp_dir();
548 let mut b = [0u8; 4];
549 use rand::RngCore;
550 rand::thread_rng().fill_bytes(&mut b);
551 p.push(format!("treeship-trust-test-{tag}-{}", hex::encode(b)));
552 std::fs::create_dir_all(&p).unwrap();
553 p
554 }
555
556 fn cleanup(p: &Path) {
557 let _ = fs::remove_dir_all(p);
558 }
559
560 fn fresh_root(key_id: &str, kind: TrustRootKind) -> (SigningKey, TrustRoot) {
561 let sk = SigningKey::generate(&mut rand::thread_rng());
562 let pk = sk.verifying_key();
563 let root = TrustRoot {
564 key_id: key_id.into(),
565 public_key: encode_ed25519_pubkey(&pk),
566 kind,
567 label: format!("test root {key_id}"),
568 added_at: "2026-05-15T00:00:00Z".into(),
569 };
570 (sk, root)
571 }
572
573 #[test]
577 fn legacy_ship_kind_still_parses_but_is_deprecated() {
578 assert_eq!(TrustRootKind::parse("ship"), Some(TrustRootKind::Ship));
579 assert_eq!(TrustRootKind::Ship.as_str(), "ship");
580 assert!(TrustRootKind::Ship.is_deprecated_ship());
581 for k in ["hub_org", "cert_issuer", "revoker"] {
582 let parsed = TrustRootKind::parse(k).expect("new kind must parse");
583 assert_eq!(parsed.as_str(), k);
584 assert!(!parsed.is_deprecated_ship());
585 }
586 }
587
588 #[test]
589 fn roundtrip_save_load() {
590 let dir = tmp_dir("roundtrip");
591 let path = dir.join("trust_roots.json");
592 let (_, r1) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
593 let (_, r2) = fresh_root("ship_b", TrustRootKind::Ship);
594 let store = TrustRootStore::with_roots(vec![r1.clone(), r2.clone()]);
595 store.save(&path).unwrap();
596 let loaded = TrustRootStore::open(&path).unwrap();
597 assert_eq!(loaded.roots().len(), 2);
598 assert_eq!(loaded.roots()[0], r1);
599 assert_eq!(loaded.roots()[1], r2);
600 cleanup(&dir);
601 }
602
603 #[test]
604 fn rejects_missing_file() {
605 let dir = tmp_dir("missing");
606 let path = dir.join("nope.json");
607 match TrustRootStore::open(&path).unwrap_err() {
608 TrustRootError::NotConfigured { path: p } => assert_eq!(p, path),
609 other => panic!("expected NotConfigured, got {other:?}"),
610 }
611 cleanup(&dir);
612 }
613
614 #[test]
615 fn rejects_malformed_json() {
616 let dir = tmp_dir("malformed");
617 let path = dir.join("trust_roots.json");
618 fs::write(&path, b"{ this is not json").unwrap();
619 #[cfg(unix)]
620 {
621 use std::os::unix::fs::PermissionsExt;
622 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
623 }
624 match TrustRootStore::open(&path).unwrap_err() {
625 TrustRootError::Malformed { path: p, .. } => assert_eq!(p, path),
626 other => panic!("expected Malformed, got {other:?}"),
627 }
628 cleanup(&dir);
629 }
630
631 #[test]
632 fn rejects_empty_roots() {
633 let dir = tmp_dir("empty");
634 let path = dir.join("trust_roots.json");
635 let file = serde_json::json!({"version": 1, "roots": []});
636 fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
637 #[cfg(unix)]
638 {
639 use std::os::unix::fs::PermissionsExt;
640 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
641 }
642 match TrustRootStore::open(&path).unwrap_err() {
643 TrustRootError::Empty { path: p } => assert_eq!(p, path),
644 other => panic!("expected Empty, got {other:?}"),
645 }
646 cleanup(&dir);
647 }
648
649 #[test]
650 #[cfg(unix)]
651 fn permission_too_open_warns() {
652 use std::os::unix::fs::PermissionsExt;
653 std::env::remove_var("TREESHIP_ALLOW_INSECURE_KEY_PERMS");
655
656 let dir = tmp_dir("perms");
657 let path = dir.join("trust_roots.json");
658 let (_, r) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
659 let file = TrustRootFile { version: SCHEMA_VERSION, roots: vec![r] };
660 fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
661 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
662
663 match TrustRootStore::open(&path).unwrap_err() {
664 TrustRootError::PermissionsTooOpen { path: p, mode } => {
665 assert_eq!(p, path);
666 assert_eq!(mode & 0o777, 0o644);
667 }
668 other => panic!("expected PermissionsTooOpen, got {other:?}"),
669 }
670 cleanup(&dir);
671 }
672
673 #[test]
682 #[cfg(unix)]
683 fn open_rejects_loose_perms_on_open_fd() {
684 use std::os::unix::fs::PermissionsExt;
685 std::env::remove_var("TREESHIP_ALLOW_INSECURE_KEY_PERMS");
686
687 let dir = tmp_dir("perms-fd");
688 let path = dir.join("trust_roots.json");
689 let (_, r) = fresh_root("hub_b", TrustRootKind::HubCheckpoint);
690 let file = TrustRootFile { version: SCHEMA_VERSION, roots: vec![r] };
691 fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
695 fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
696
697 let err = TrustRootStore::open(&path).unwrap_err();
698 match err {
699 TrustRootError::PermissionsTooOpen { path: p, mode } => {
700 assert_eq!(p, path);
701 assert_eq!(mode & 0o777, 0o640);
702 }
703 other => panic!("expected PermissionsTooOpen, got {other:?}"),
704 }
705 cleanup(&dir);
706 }
707
708 #[test]
709 fn contains_matches_kind_correctly() {
710 let (sk, r) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
711 let store = TrustRootStore::with_roots(vec![r]);
712 let vk = sk.verifying_key();
713
714 assert!(store.contains(&vk, TrustRootKind::HubCheckpoint),
715 "must accept matching kind");
716 assert!(!store.contains(&vk, TrustRootKind::Ship),
717 "must reject mismatching kind");
718 assert!(!store.contains(&vk, TrustRootKind::AgentCert),
719 "must reject mismatching kind");
720 }
721
722 #[test]
723 fn add_replaces_same_key_id_and_kind() {
724 let mut store = TrustRootStore::empty();
725 let (_, r1) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
726 let (_, r1b) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
727 store.add(r1);
728 store.add(r1b.clone());
729 assert_eq!(store.len(), 1, "same (id, kind) replaces previous");
730 assert_eq!(&store.roots()[0], &r1b);
731 }
732
733 #[test]
734 fn add_keeps_same_key_id_across_kinds() {
735 let mut store = TrustRootStore::empty();
736 let (_, r_hub) = fresh_root("issuer_x", TrustRootKind::HubCheckpoint);
737 let (_, r_ship) = fresh_root("issuer_x", TrustRootKind::Ship);
738 store.add(r_hub);
739 store.add(r_ship);
740 assert_eq!(store.len(), 2, "same id is allowed across different kinds");
741 }
742
743 #[test]
744 fn remove_strips_all_kinds_for_id() {
745 let mut store = TrustRootStore::empty();
746 let (_, r_hub) = fresh_root("issuer_x", TrustRootKind::HubCheckpoint);
747 let (_, r_ship) = fresh_root("issuer_x", TrustRootKind::Ship);
748 store.add(r_hub);
749 store.add(r_ship);
750 assert!(store.remove("issuer_x"));
751 assert!(store.is_empty());
752 assert!(!store.remove("issuer_x"), "second remove is a no-op");
753 }
754
755 #[test]
756 fn encode_decode_roundtrip() {
757 let sk = SigningKey::generate(&mut rand::thread_rng());
758 let pk = sk.verifying_key();
759 let encoded = encode_ed25519_pubkey(&pk);
760 assert!(encoded.starts_with("ed25519:"));
761 let decoded = decode_ed25519_pubkey(&encoded).unwrap();
762 assert_eq!(decoded.to_bytes(), pk.to_bytes());
763 }
764
765 #[test]
766 fn decode_accepts_bare_base64() {
767 let sk = SigningKey::generate(&mut rand::thread_rng());
768 let pk = sk.verifying_key();
769 let bare = URL_SAFE_NO_PAD.encode(pk.to_bytes());
770 let decoded = decode_ed25519_pubkey(&bare).unwrap();
771 assert_eq!(decoded.to_bytes(), pk.to_bytes());
772 }
773}