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,
91 AgentCert,
93 SessionHost,
101}
102
103impl TrustRootKind {
104 pub fn as_str(self) -> &'static str {
105 match self {
106 Self::HubCheckpoint => "hub_checkpoint",
107 Self::Ship => "ship",
108 Self::AgentCert => "agent_cert",
109 Self::SessionHost => "session_host",
110 }
111 }
112
113 pub fn parse(s: &str) -> Option<Self> {
114 match s {
115 "hub_checkpoint" => Some(Self::HubCheckpoint),
116 "ship" => Some(Self::Ship),
117 "agent_cert" => Some(Self::AgentCert),
118 "session_host" => Some(Self::SessionHost),
119 _ => None,
120 }
121 }
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct TrustRoot {
127 pub key_id: String,
132
133 pub public_key: String,
137
138 pub kind: TrustRootKind,
140
141 #[serde(default)]
144 pub label: String,
145
146 #[serde(default)]
148 pub added_at: String,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
154struct TrustRootFile {
155 pub version: u8,
157 pub roots: Vec<TrustRoot>,
158}
159
160const SCHEMA_VERSION: u8 = 1;
161
162#[derive(Debug, Clone, Default)]
164pub struct TrustRootStore {
165 roots: Vec<TrustRoot>,
166}
167
168#[derive(Debug)]
170pub enum TrustRootError {
171 NotConfigured { path: PathBuf },
174 Malformed { path: PathBuf, msg: String },
176 Empty { path: PathBuf },
180 PermissionsTooOpen { path: PathBuf, mode: u32 },
182 Io(io::Error),
184}
185
186impl std::fmt::Display for TrustRootError {
187 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
188 match self {
189 Self::NotConfigured { path } => write!(
190 f,
191 "no trust roots configured (looked for {}). \
192 Run `treeship trust add <key_id> <pubkey> --kind <kind>` \
193 or sync from your hub via `treeship hub sync-trust`.",
194 path.display(),
195 ),
196 Self::Malformed { path, msg } => write!(
197 f,
198 "trust root file {} is malformed: {msg}",
199 path.display(),
200 ),
201 Self::Empty { path } => write!(
202 f,
203 "trust root file {} has no roots configured. \
204 Run `treeship trust add <key_id> <pubkey> --kind <kind>` \
205 to add an issuer.",
206 path.display(),
207 ),
208 Self::PermissionsTooOpen { path, mode } => write!(
209 f,
210 "trust root file {} has insecure permissions (mode {:o}); \
211 chmod 600 the file and try again.",
212 path.display(),
213 mode & 0o777,
214 ),
215 Self::Io(e) => write!(f, "trust root io: {e}"),
216 }
217 }
218}
219
220impl std::error::Error for TrustRootError {}
221
222impl From<io::Error> for TrustRootError {
223 fn from(e: io::Error) -> Self { Self::Io(e) }
224}
225
226impl TrustRootStore {
227 pub fn default_path() -> PathBuf {
234 warn_trust_path_override_if_set();
235 std::env::var_os("TREESHIP_TRUST_ROOTS")
236 .map(PathBuf::from)
237 .unwrap_or_else(|| {
238 let home = std::env::var("HOME").unwrap_or_default();
239 PathBuf::from(home).join(".treeship").join("trust_roots.json")
240 })
241 }
242
243 pub fn empty() -> Self {
247 Self { roots: Vec::new() }
248 }
249
250 pub fn with_roots(roots: Vec<TrustRoot>) -> Self {
254 Self { roots }
255 }
256
257 pub fn open_or_empty(path: &Path) -> Result<Self, TrustRootError> {
263 match Self::open(path) {
264 Ok(s) => Ok(s),
265 Err(TrustRootError::NotConfigured { .. }) => Ok(Self::empty()),
266 Err(TrustRootError::Empty { .. }) => Ok(Self::empty()),
267 Err(e) => Err(e),
268 }
269 }
270
271 pub fn open_default_or_empty() -> Result<Self, TrustRootError> {
275 Self::open_or_empty(&Self::default_path())
276 }
277
278 pub fn open(path: &Path) -> Result<Self, TrustRootError> {
289 let mut file = match fs::File::open(path) {
290 Ok(f) => f,
291 Err(e) if e.kind() == io::ErrorKind::NotFound => {
292 return Err(TrustRootError::NotConfigured { path: path.to_path_buf() });
293 }
294 Err(e) => return Err(TrustRootError::Io(e)),
295 };
296 check_open_trust_file_perms(path, &file)?;
297 let mut bytes = Vec::new();
298 file.read_to_end(&mut bytes)?;
299 let file: TrustRootFile = serde_json::from_slice(&bytes)
300 .map_err(|e| TrustRootError::Malformed {
301 path: path.to_path_buf(),
302 msg: e.to_string(),
303 })?;
304 if file.version != SCHEMA_VERSION {
305 return Err(TrustRootError::Malformed {
306 path: path.to_path_buf(),
307 msg: format!(
308 "schema version mismatch: file has v{}, this binary supports v{}",
309 file.version, SCHEMA_VERSION,
310 ),
311 });
312 }
313 for root in &file.roots {
316 decode_ed25519_pubkey(&root.public_key)
317 .map_err(|msg| TrustRootError::Malformed {
318 path: path.to_path_buf(),
319 msg: format!("root {}: {msg}", root.key_id),
320 })?;
321 }
322 if file.roots.is_empty() {
323 return Err(TrustRootError::Empty { path: path.to_path_buf() });
324 }
325 Ok(Self { roots: file.roots })
326 }
327
328 pub fn save(&self, path: &Path) -> Result<(), TrustRootError> {
331 if let Some(parent) = path.parent() {
332 fs::create_dir_all(parent)?;
333 #[cfg(unix)]
334 {
335 use std::os::unix::fs::PermissionsExt;
336 let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
337 }
338 }
339 let file = TrustRootFile {
340 version: SCHEMA_VERSION,
341 roots: self.roots.clone(),
342 };
343 let json = serde_json::to_vec_pretty(&file)
344 .map_err(|e| TrustRootError::Malformed {
345 path: path.to_path_buf(),
346 msg: e.to_string(),
347 })?;
348 fs::write(path, &json)?;
349 #[cfg(unix)]
350 {
351 use std::os::unix::fs::PermissionsExt;
352 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
353 }
354 Ok(())
355 }
356
357 pub fn contains(&self, key: &VerifyingKey, kind: TrustRootKind) -> bool {
361 let key_bytes = key.to_bytes();
362 self.roots.iter().any(|r| {
363 r.kind == kind
364 && decode_ed25519_pubkey(&r.public_key)
365 .map(|k| k.to_bytes() == key_bytes)
366 .unwrap_or(false)
367 })
368 }
369
370 pub fn contains_bytes(&self, key_bytes: &[u8; 32], kind: TrustRootKind) -> bool {
375 match VerifyingKey::from_bytes(key_bytes) {
376 Ok(vk) => self.contains(&vk, kind),
377 Err(_) => false,
378 }
379 }
380
381 pub fn is_empty(&self) -> bool {
385 self.roots.is_empty()
386 }
387
388 pub fn is_empty_for_kind(&self, kind: TrustRootKind) -> bool {
393 !self.roots.iter().any(|r| r.kind == kind)
394 }
395
396 pub fn add(&mut self, root: TrustRoot) {
400 self.roots.retain(|r| !(r.key_id == root.key_id && r.kind == root.kind));
401 self.roots.push(root);
402 }
403
404 pub fn remove(&mut self, key_id: &str) -> bool {
407 let before = self.roots.len();
408 self.roots.retain(|r| r.key_id != key_id);
409 self.roots.len() != before
410 }
411
412 pub fn roots(&self) -> &[TrustRoot] {
414 &self.roots
415 }
416
417 pub fn len(&self) -> usize {
419 self.roots.len()
420 }
421}
422
423pub fn decode_ed25519_pubkey(s: &str) -> Result<VerifyingKey, String> {
427 let b64 = s.strip_prefix("ed25519:").unwrap_or(s);
428 let bytes = URL_SAFE_NO_PAD
429 .decode(b64)
430 .map_err(|e| format!("base64url decode failed: {e}"))?;
431 let arr: [u8; 32] = bytes
432 .as_slice()
433 .try_into()
434 .map_err(|_| format!("expected 32-byte public key, got {} bytes", bytes.len()))?;
435 VerifyingKey::from_bytes(&arr).map_err(|e| format!("not a valid Ed25519 public key: {e}"))
436}
437
438pub fn encode_ed25519_pubkey(key: &VerifyingKey) -> String {
440 format!("ed25519:{}", URL_SAFE_NO_PAD.encode(key.to_bytes()))
441}
442
443#[allow(dead_code)]
444fn check_trust_file_perms(path: &Path) -> Result<(), TrustRootError> {
445 #[cfg(unix)]
446 {
447 use std::os::unix::fs::PermissionsExt;
448 if std::env::var_os("TREESHIP_ALLOW_INSECURE_KEY_PERMS")
454 .map(|v| v == "1")
455 .unwrap_or(false)
456 {
457 warn_insecure_perms_if_bypassed();
458 return Ok(());
459 }
460 let meta = fs::metadata(path)?;
461 let mode = meta.permissions().mode();
462 if mode & 0o077 != 0 {
463 return Err(TrustRootError::PermissionsTooOpen {
464 path: path.to_path_buf(),
465 mode,
466 });
467 }
468 }
469 let _ = path;
470 Ok(())
471}
472
473#[allow(unused_variables)]
485fn check_open_trust_file_perms(path: &Path, file: &fs::File) -> Result<(), TrustRootError> {
486 #[cfg(unix)]
487 {
488 use std::os::unix::fs::PermissionsExt;
489 if std::env::var_os("TREESHIP_ALLOW_INSECURE_KEY_PERMS")
490 .map(|v| v == "1")
491 .unwrap_or(false)
492 {
493 warn_insecure_perms_if_bypassed();
494 return Ok(());
495 }
496 let meta = file.metadata()?;
497 let mode = meta.permissions().mode();
498 if mode & 0o077 != 0 {
499 return Err(TrustRootError::PermissionsTooOpen {
500 path: path.to_path_buf(),
501 mode,
502 });
503 }
504 }
505 Ok(())
506}
507
508#[cfg(test)]
513mod tests {
514 use super::*;
515 use ed25519_dalek::SigningKey;
516
517 fn tmp_dir(tag: &str) -> PathBuf {
518 let mut p = std::env::temp_dir();
519 let mut b = [0u8; 4];
520 use rand::RngCore;
521 rand::thread_rng().fill_bytes(&mut b);
522 p.push(format!("treeship-trust-test-{tag}-{}", hex::encode(b)));
523 std::fs::create_dir_all(&p).unwrap();
524 p
525 }
526
527 fn cleanup(p: &Path) {
528 let _ = fs::remove_dir_all(p);
529 }
530
531 fn fresh_root(key_id: &str, kind: TrustRootKind) -> (SigningKey, TrustRoot) {
532 let sk = SigningKey::generate(&mut rand::thread_rng());
533 let pk = sk.verifying_key();
534 let root = TrustRoot {
535 key_id: key_id.into(),
536 public_key: encode_ed25519_pubkey(&pk),
537 kind,
538 label: format!("test root {key_id}"),
539 added_at: "2026-05-15T00:00:00Z".into(),
540 };
541 (sk, root)
542 }
543
544 #[test]
545 fn roundtrip_save_load() {
546 let dir = tmp_dir("roundtrip");
547 let path = dir.join("trust_roots.json");
548 let (_, r1) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
549 let (_, r2) = fresh_root("ship_b", TrustRootKind::Ship);
550 let store = TrustRootStore::with_roots(vec![r1.clone(), r2.clone()]);
551 store.save(&path).unwrap();
552 let loaded = TrustRootStore::open(&path).unwrap();
553 assert_eq!(loaded.roots().len(), 2);
554 assert_eq!(loaded.roots()[0], r1);
555 assert_eq!(loaded.roots()[1], r2);
556 cleanup(&dir);
557 }
558
559 #[test]
560 fn rejects_missing_file() {
561 let dir = tmp_dir("missing");
562 let path = dir.join("nope.json");
563 match TrustRootStore::open(&path).unwrap_err() {
564 TrustRootError::NotConfigured { path: p } => assert_eq!(p, path),
565 other => panic!("expected NotConfigured, got {other:?}"),
566 }
567 cleanup(&dir);
568 }
569
570 #[test]
571 fn rejects_malformed_json() {
572 let dir = tmp_dir("malformed");
573 let path = dir.join("trust_roots.json");
574 fs::write(&path, b"{ this is not json").unwrap();
575 #[cfg(unix)]
576 {
577 use std::os::unix::fs::PermissionsExt;
578 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
579 }
580 match TrustRootStore::open(&path).unwrap_err() {
581 TrustRootError::Malformed { path: p, .. } => assert_eq!(p, path),
582 other => panic!("expected Malformed, got {other:?}"),
583 }
584 cleanup(&dir);
585 }
586
587 #[test]
588 fn rejects_empty_roots() {
589 let dir = tmp_dir("empty");
590 let path = dir.join("trust_roots.json");
591 let file = serde_json::json!({"version": 1, "roots": []});
592 fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
593 #[cfg(unix)]
594 {
595 use std::os::unix::fs::PermissionsExt;
596 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
597 }
598 match TrustRootStore::open(&path).unwrap_err() {
599 TrustRootError::Empty { path: p } => assert_eq!(p, path),
600 other => panic!("expected Empty, got {other:?}"),
601 }
602 cleanup(&dir);
603 }
604
605 #[test]
606 #[cfg(unix)]
607 fn permission_too_open_warns() {
608 use std::os::unix::fs::PermissionsExt;
609 std::env::remove_var("TREESHIP_ALLOW_INSECURE_KEY_PERMS");
611
612 let dir = tmp_dir("perms");
613 let path = dir.join("trust_roots.json");
614 let (_, r) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
615 let file = TrustRootFile { version: SCHEMA_VERSION, roots: vec![r] };
616 fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
617 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
618
619 match TrustRootStore::open(&path).unwrap_err() {
620 TrustRootError::PermissionsTooOpen { path: p, mode } => {
621 assert_eq!(p, path);
622 assert_eq!(mode & 0o777, 0o644);
623 }
624 other => panic!("expected PermissionsTooOpen, got {other:?}"),
625 }
626 cleanup(&dir);
627 }
628
629 #[test]
638 #[cfg(unix)]
639 fn open_rejects_loose_perms_on_open_fd() {
640 use std::os::unix::fs::PermissionsExt;
641 std::env::remove_var("TREESHIP_ALLOW_INSECURE_KEY_PERMS");
642
643 let dir = tmp_dir("perms-fd");
644 let path = dir.join("trust_roots.json");
645 let (_, r) = fresh_root("hub_b", TrustRootKind::HubCheckpoint);
646 let file = TrustRootFile { version: SCHEMA_VERSION, roots: vec![r] };
647 fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
651 fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
652
653 let err = TrustRootStore::open(&path).unwrap_err();
654 match err {
655 TrustRootError::PermissionsTooOpen { path: p, mode } => {
656 assert_eq!(p, path);
657 assert_eq!(mode & 0o777, 0o640);
658 }
659 other => panic!("expected PermissionsTooOpen, got {other:?}"),
660 }
661 cleanup(&dir);
662 }
663
664 #[test]
665 fn contains_matches_kind_correctly() {
666 let (sk, r) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
667 let store = TrustRootStore::with_roots(vec![r]);
668 let vk = sk.verifying_key();
669
670 assert!(store.contains(&vk, TrustRootKind::HubCheckpoint),
671 "must accept matching kind");
672 assert!(!store.contains(&vk, TrustRootKind::Ship),
673 "must reject mismatching kind");
674 assert!(!store.contains(&vk, TrustRootKind::AgentCert),
675 "must reject mismatching kind");
676 }
677
678 #[test]
679 fn add_replaces_same_key_id_and_kind() {
680 let mut store = TrustRootStore::empty();
681 let (_, r1) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
682 let (_, r1b) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
683 store.add(r1);
684 store.add(r1b.clone());
685 assert_eq!(store.len(), 1, "same (id, kind) replaces previous");
686 assert_eq!(&store.roots()[0], &r1b);
687 }
688
689 #[test]
690 fn add_keeps_same_key_id_across_kinds() {
691 let mut store = TrustRootStore::empty();
692 let (_, r_hub) = fresh_root("issuer_x", TrustRootKind::HubCheckpoint);
693 let (_, r_ship) = fresh_root("issuer_x", TrustRootKind::Ship);
694 store.add(r_hub);
695 store.add(r_ship);
696 assert_eq!(store.len(), 2, "same id is allowed across different kinds");
697 }
698
699 #[test]
700 fn remove_strips_all_kinds_for_id() {
701 let mut store = TrustRootStore::empty();
702 let (_, r_hub) = fresh_root("issuer_x", TrustRootKind::HubCheckpoint);
703 let (_, r_ship) = fresh_root("issuer_x", TrustRootKind::Ship);
704 store.add(r_hub);
705 store.add(r_ship);
706 assert!(store.remove("issuer_x"));
707 assert!(store.is_empty());
708 assert!(!store.remove("issuer_x"), "second remove is a no-op");
709 }
710
711 #[test]
712 fn encode_decode_roundtrip() {
713 let sk = SigningKey::generate(&mut rand::thread_rng());
714 let pk = sk.verifying_key();
715 let encoded = encode_ed25519_pubkey(&pk);
716 assert!(encoded.starts_with("ed25519:"));
717 let decoded = decode_ed25519_pubkey(&encoded).unwrap();
718 assert_eq!(decoded.to_bytes(), pk.to_bytes());
719 }
720
721 #[test]
722 fn decode_accepts_bare_base64() {
723 let sk = SigningKey::generate(&mut rand::thread_rng());
724 let pk = sk.verifying_key();
725 let bare = URL_SAFE_NO_PAD.encode(pk.to_bytes());
726 let decoded = decode_ed25519_pubkey(&bare).unwrap();
727 assert_eq!(decoded.to_bytes(), pk.to_bytes());
728 }
729}