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 } => {
226 write!(f, "trust root file {} is malformed: {msg}", path.display(),)
227 }
228 Self::Empty { path } => write!(
229 f,
230 "trust root file {} has no roots configured. \
231 Run `treeship trust add <key_id> <pubkey> --kind <kind>` \
232 to add an issuer.",
233 path.display(),
234 ),
235 Self::PermissionsTooOpen { path, mode } => write!(
236 f,
237 "trust root file {} has insecure permissions (mode {:o}); \
238 chmod 600 the file and try again.",
239 path.display(),
240 mode & 0o777,
241 ),
242 Self::Io(e) => write!(f, "trust root io: {e}"),
243 }
244 }
245}
246
247impl std::error::Error for TrustRootError {}
248
249impl From<io::Error> for TrustRootError {
250 fn from(e: io::Error) -> Self {
251 Self::Io(e)
252 }
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)
269 .join(".treeship")
270 .join("trust_roots.json")
271 })
272 }
273
274 pub fn empty() -> Self {
278 Self { roots: Vec::new() }
279 }
280
281 pub fn with_roots(roots: Vec<TrustRoot>) -> Self {
285 Self { roots }
286 }
287
288 pub fn open_or_empty(path: &Path) -> Result<Self, TrustRootError> {
294 match Self::open(path) {
295 Ok(s) => Ok(s),
296 Err(TrustRootError::NotConfigured { .. }) => Ok(Self::empty()),
297 Err(TrustRootError::Empty { .. }) => Ok(Self::empty()),
298 Err(e) => Err(e),
299 }
300 }
301
302 pub fn open_default_or_empty() -> Result<Self, TrustRootError> {
306 Self::open_or_empty(&Self::default_path())
307 }
308
309 pub fn open(path: &Path) -> Result<Self, TrustRootError> {
320 let mut file = match fs::File::open(path) {
321 Ok(f) => f,
322 Err(e) if e.kind() == io::ErrorKind::NotFound => {
323 return Err(TrustRootError::NotConfigured {
324 path: path.to_path_buf(),
325 });
326 }
327 Err(e) => return Err(TrustRootError::Io(e)),
328 };
329 check_open_trust_file_perms(path, &file)?;
330 let mut bytes = Vec::new();
331 file.read_to_end(&mut bytes)?;
332 let file: TrustRootFile =
333 serde_json::from_slice(&bytes).map_err(|e| TrustRootError::Malformed {
334 path: path.to_path_buf(),
335 msg: e.to_string(),
336 })?;
337 if file.version != SCHEMA_VERSION {
338 return Err(TrustRootError::Malformed {
339 path: path.to_path_buf(),
340 msg: format!(
341 "schema version mismatch: file has v{}, this binary supports v{}",
342 file.version, SCHEMA_VERSION,
343 ),
344 });
345 }
346 for root in &file.roots {
349 decode_ed25519_pubkey(&root.public_key).map_err(|msg| TrustRootError::Malformed {
350 path: path.to_path_buf(),
351 msg: format!("root {}: {msg}", root.key_id),
352 })?;
353 }
354 if file.roots.is_empty() {
355 return Err(TrustRootError::Empty {
356 path: path.to_path_buf(),
357 });
358 }
359 Ok(Self { roots: file.roots })
360 }
361
362 pub fn save(&self, path: &Path) -> Result<(), TrustRootError> {
365 if let Some(parent) = path.parent() {
366 fs::create_dir_all(parent)?;
367 #[cfg(unix)]
368 {
369 use std::os::unix::fs::PermissionsExt;
370 let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
371 }
372 }
373 let file = TrustRootFile {
374 version: SCHEMA_VERSION,
375 roots: self.roots.clone(),
376 };
377 let json = serde_json::to_vec_pretty(&file).map_err(|e| TrustRootError::Malformed {
378 path: path.to_path_buf(),
379 msg: e.to_string(),
380 })?;
381 fs::write(path, &json)?;
382 #[cfg(unix)]
383 {
384 use std::os::unix::fs::PermissionsExt;
385 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
386 }
387 Ok(())
388 }
389
390 pub fn contains(&self, key: &VerifyingKey, kind: TrustRootKind) -> bool {
394 let key_bytes = key.to_bytes();
395 self.roots.iter().any(|r| {
396 r.kind == kind
397 && decode_ed25519_pubkey(&r.public_key)
398 .map(|k| k.to_bytes() == key_bytes)
399 .unwrap_or(false)
400 })
401 }
402
403 pub fn contains_bytes(&self, key_bytes: &[u8; 32], kind: TrustRootKind) -> bool {
408 match VerifyingKey::from_bytes(key_bytes) {
409 Ok(vk) => self.contains(&vk, kind),
410 Err(_) => false,
411 }
412 }
413
414 pub fn is_empty(&self) -> bool {
418 self.roots.is_empty()
419 }
420
421 pub fn is_empty_for_kind(&self, kind: TrustRootKind) -> bool {
426 !self.roots.iter().any(|r| r.kind == kind)
427 }
428
429 pub fn add(&mut self, root: TrustRoot) {
433 self.roots
434 .retain(|r| !(r.key_id == root.key_id && r.kind == root.kind));
435 self.roots.push(root);
436 }
437
438 pub fn remove(&mut self, key_id: &str) -> bool {
441 let before = self.roots.len();
442 self.roots.retain(|r| r.key_id != key_id);
443 self.roots.len() != before
444 }
445
446 pub fn roots(&self) -> &[TrustRoot] {
448 &self.roots
449 }
450
451 pub fn len(&self) -> usize {
453 self.roots.len()
454 }
455}
456
457pub fn decode_ed25519_pubkey(s: &str) -> Result<VerifyingKey, String> {
461 let b64 = s.strip_prefix("ed25519:").unwrap_or(s);
462 let bytes = URL_SAFE_NO_PAD
463 .decode(b64)
464 .map_err(|e| format!("base64url decode failed: {e}"))?;
465 let arr: [u8; 32] = bytes
466 .as_slice()
467 .try_into()
468 .map_err(|_| format!("expected 32-byte public key, got {} bytes", bytes.len()))?;
469 VerifyingKey::from_bytes(&arr).map_err(|e| format!("not a valid Ed25519 public key: {e}"))
470}
471
472pub fn encode_ed25519_pubkey(key: &VerifyingKey) -> String {
474 format!("ed25519:{}", URL_SAFE_NO_PAD.encode(key.to_bytes()))
475}
476
477#[allow(dead_code)]
478fn check_trust_file_perms(path: &Path) -> Result<(), TrustRootError> {
479 #[cfg(unix)]
480 {
481 use std::os::unix::fs::PermissionsExt;
482 if std::env::var_os("TREESHIP_ALLOW_INSECURE_KEY_PERMS")
488 .map(|v| v == "1")
489 .unwrap_or(false)
490 {
491 warn_insecure_perms_if_bypassed();
492 return Ok(());
493 }
494 let meta = fs::metadata(path)?;
495 let mode = meta.permissions().mode();
496 if mode & 0o077 != 0 {
497 return Err(TrustRootError::PermissionsTooOpen {
498 path: path.to_path_buf(),
499 mode,
500 });
501 }
502 }
503 let _ = path;
504 Ok(())
505}
506
507#[allow(unused_variables)]
519fn check_open_trust_file_perms(path: &Path, file: &fs::File) -> Result<(), TrustRootError> {
520 #[cfg(unix)]
521 {
522 use std::os::unix::fs::PermissionsExt;
523 if std::env::var_os("TREESHIP_ALLOW_INSECURE_KEY_PERMS")
524 .map(|v| v == "1")
525 .unwrap_or(false)
526 {
527 warn_insecure_perms_if_bypassed();
528 return Ok(());
529 }
530 let meta = file.metadata()?;
531 let mode = meta.permissions().mode();
532 if mode & 0o077 != 0 {
533 return Err(TrustRootError::PermissionsTooOpen {
534 path: path.to_path_buf(),
535 mode,
536 });
537 }
538 }
539 Ok(())
540}
541
542#[cfg(test)]
547mod tests {
548 use super::*;
549 use ed25519_dalek::SigningKey;
550
551 fn tmp_dir(tag: &str) -> PathBuf {
552 let mut p = std::env::temp_dir();
553 let mut b = [0u8; 4];
554 use rand::RngCore;
555 rand::thread_rng().fill_bytes(&mut b);
556 p.push(format!("treeship-trust-test-{tag}-{}", hex::encode(b)));
557 std::fs::create_dir_all(&p).unwrap();
558 p
559 }
560
561 fn cleanup(p: &Path) {
562 let _ = fs::remove_dir_all(p);
563 }
564
565 fn fresh_root(key_id: &str, kind: TrustRootKind) -> (SigningKey, TrustRoot) {
566 let sk = SigningKey::generate(&mut rand::thread_rng());
567 let pk = sk.verifying_key();
568 let root = TrustRoot {
569 key_id: key_id.into(),
570 public_key: encode_ed25519_pubkey(&pk),
571 kind,
572 label: format!("test root {key_id}"),
573 added_at: "2026-05-15T00:00:00Z".into(),
574 };
575 (sk, root)
576 }
577
578 #[test]
582 fn legacy_ship_kind_still_parses_but_is_deprecated() {
583 assert_eq!(TrustRootKind::parse("ship"), Some(TrustRootKind::Ship));
584 assert_eq!(TrustRootKind::Ship.as_str(), "ship");
585 assert!(TrustRootKind::Ship.is_deprecated_ship());
586 for k in ["hub_org", "cert_issuer", "revoker"] {
587 let parsed = TrustRootKind::parse(k).expect("new kind must parse");
588 assert_eq!(parsed.as_str(), k);
589 assert!(!parsed.is_deprecated_ship());
590 }
591 }
592
593 #[test]
594 fn roundtrip_save_load() {
595 let dir = tmp_dir("roundtrip");
596 let path = dir.join("trust_roots.json");
597 let (_, r1) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
598 let (_, r2) = fresh_root("ship_b", TrustRootKind::Ship);
599 let store = TrustRootStore::with_roots(vec![r1.clone(), r2.clone()]);
600 store.save(&path).unwrap();
601 let loaded = TrustRootStore::open(&path).unwrap();
602 assert_eq!(loaded.roots().len(), 2);
603 assert_eq!(loaded.roots()[0], r1);
604 assert_eq!(loaded.roots()[1], r2);
605 cleanup(&dir);
606 }
607
608 #[test]
609 fn rejects_missing_file() {
610 let dir = tmp_dir("missing");
611 let path = dir.join("nope.json");
612 match TrustRootStore::open(&path).unwrap_err() {
613 TrustRootError::NotConfigured { path: p } => assert_eq!(p, path),
614 other => panic!("expected NotConfigured, got {other:?}"),
615 }
616 cleanup(&dir);
617 }
618
619 #[test]
620 fn rejects_malformed_json() {
621 let dir = tmp_dir("malformed");
622 let path = dir.join("trust_roots.json");
623 fs::write(&path, b"{ this is not json").unwrap();
624 #[cfg(unix)]
625 {
626 use std::os::unix::fs::PermissionsExt;
627 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
628 }
629 match TrustRootStore::open(&path).unwrap_err() {
630 TrustRootError::Malformed { path: p, .. } => assert_eq!(p, path),
631 other => panic!("expected Malformed, got {other:?}"),
632 }
633 cleanup(&dir);
634 }
635
636 #[test]
637 fn rejects_empty_roots() {
638 let dir = tmp_dir("empty");
639 let path = dir.join("trust_roots.json");
640 let file = serde_json::json!({"version": 1, "roots": []});
641 fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
642 #[cfg(unix)]
643 {
644 use std::os::unix::fs::PermissionsExt;
645 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
646 }
647 match TrustRootStore::open(&path).unwrap_err() {
648 TrustRootError::Empty { path: p } => assert_eq!(p, path),
649 other => panic!("expected Empty, got {other:?}"),
650 }
651 cleanup(&dir);
652 }
653
654 #[test]
655 #[cfg(unix)]
656 fn permission_too_open_warns() {
657 use std::os::unix::fs::PermissionsExt;
658 std::env::remove_var("TREESHIP_ALLOW_INSECURE_KEY_PERMS");
660
661 let dir = tmp_dir("perms");
662 let path = dir.join("trust_roots.json");
663 let (_, r) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
664 let file = TrustRootFile {
665 version: SCHEMA_VERSION,
666 roots: vec![r],
667 };
668 fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
669 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
670
671 match TrustRootStore::open(&path).unwrap_err() {
672 TrustRootError::PermissionsTooOpen { path: p, mode } => {
673 assert_eq!(p, path);
674 assert_eq!(mode & 0o777, 0o644);
675 }
676 other => panic!("expected PermissionsTooOpen, got {other:?}"),
677 }
678 cleanup(&dir);
679 }
680
681 #[test]
690 #[cfg(unix)]
691 fn open_rejects_loose_perms_on_open_fd() {
692 use std::os::unix::fs::PermissionsExt;
693 std::env::remove_var("TREESHIP_ALLOW_INSECURE_KEY_PERMS");
694
695 let dir = tmp_dir("perms-fd");
696 let path = dir.join("trust_roots.json");
697 let (_, r) = fresh_root("hub_b", TrustRootKind::HubCheckpoint);
698 let file = TrustRootFile {
699 version: SCHEMA_VERSION,
700 roots: vec![r],
701 };
702 fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
706 fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
707
708 let err = TrustRootStore::open(&path).unwrap_err();
709 match err {
710 TrustRootError::PermissionsTooOpen { path: p, mode } => {
711 assert_eq!(p, path);
712 assert_eq!(mode & 0o777, 0o640);
713 }
714 other => panic!("expected PermissionsTooOpen, got {other:?}"),
715 }
716 cleanup(&dir);
717 }
718
719 #[test]
720 fn contains_matches_kind_correctly() {
721 let (sk, r) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
722 let store = TrustRootStore::with_roots(vec![r]);
723 let vk = sk.verifying_key();
724
725 assert!(
726 store.contains(&vk, TrustRootKind::HubCheckpoint),
727 "must accept matching kind"
728 );
729 assert!(
730 !store.contains(&vk, TrustRootKind::Ship),
731 "must reject mismatching kind"
732 );
733 assert!(
734 !store.contains(&vk, TrustRootKind::AgentCert),
735 "must reject mismatching kind"
736 );
737 }
738
739 #[test]
740 fn add_replaces_same_key_id_and_kind() {
741 let mut store = TrustRootStore::empty();
742 let (_, r1) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
743 let (_, r1b) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
744 store.add(r1);
745 store.add(r1b.clone());
746 assert_eq!(store.len(), 1, "same (id, kind) replaces previous");
747 assert_eq!(&store.roots()[0], &r1b);
748 }
749
750 #[test]
751 fn add_keeps_same_key_id_across_kinds() {
752 let mut store = TrustRootStore::empty();
753 let (_, r_hub) = fresh_root("issuer_x", TrustRootKind::HubCheckpoint);
754 let (_, r_ship) = fresh_root("issuer_x", TrustRootKind::Ship);
755 store.add(r_hub);
756 store.add(r_ship);
757 assert_eq!(store.len(), 2, "same id is allowed across different kinds");
758 }
759
760 #[test]
761 fn remove_strips_all_kinds_for_id() {
762 let mut store = TrustRootStore::empty();
763 let (_, r_hub) = fresh_root("issuer_x", TrustRootKind::HubCheckpoint);
764 let (_, r_ship) = fresh_root("issuer_x", TrustRootKind::Ship);
765 store.add(r_hub);
766 store.add(r_ship);
767 assert!(store.remove("issuer_x"));
768 assert!(store.is_empty());
769 assert!(!store.remove("issuer_x"), "second remove is a no-op");
770 }
771
772 #[test]
773 fn encode_decode_roundtrip() {
774 let sk = SigningKey::generate(&mut rand::thread_rng());
775 let pk = sk.verifying_key();
776 let encoded = encode_ed25519_pubkey(&pk);
777 assert!(encoded.starts_with("ed25519:"));
778 let decoded = decode_ed25519_pubkey(&encoded).unwrap();
779 assert_eq!(decoded.to_bytes(), pk.to_bytes());
780 }
781
782 #[test]
783 fn decode_accepts_bare_base64() {
784 let sk = SigningKey::generate(&mut rand::thread_rng());
785 let pk = sk.verifying_key();
786 let bare = URL_SAFE_NO_PAD.encode(pk.to_bytes());
787 let decoded = decode_ed25519_pubkey(&bare).unwrap();
788 assert_eq!(decoded.to_bytes(), pk.to_bytes());
789 }
790}