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)]
76#[serde(rename_all = "snake_case")]
77pub enum TrustRootKind {
78 HubCheckpoint,
82 Ship,
85 AgentCert,
87}
88
89impl TrustRootKind {
90 pub fn as_str(self) -> &'static str {
91 match self {
92 Self::HubCheckpoint => "hub_checkpoint",
93 Self::Ship => "ship",
94 Self::AgentCert => "agent_cert",
95 }
96 }
97
98 pub fn parse(s: &str) -> Option<Self> {
99 match s {
100 "hub_checkpoint" => Some(Self::HubCheckpoint),
101 "ship" => Some(Self::Ship),
102 "agent_cert" => Some(Self::AgentCert),
103 _ => None,
104 }
105 }
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct TrustRoot {
111 pub key_id: String,
116
117 pub public_key: String,
121
122 pub kind: TrustRootKind,
124
125 #[serde(default)]
128 pub label: String,
129
130 #[serde(default)]
132 pub added_at: String,
133}
134
135#[derive(Debug, Clone, Serialize, Deserialize)]
138struct TrustRootFile {
139 pub version: u8,
141 pub roots: Vec<TrustRoot>,
142}
143
144const SCHEMA_VERSION: u8 = 1;
145
146#[derive(Debug, Clone, Default)]
148pub struct TrustRootStore {
149 roots: Vec<TrustRoot>,
150}
151
152#[derive(Debug)]
154pub enum TrustRootError {
155 NotConfigured { path: PathBuf },
158 Malformed { path: PathBuf, msg: String },
160 Empty { path: PathBuf },
164 PermissionsTooOpen { path: PathBuf, mode: u32 },
166 Io(io::Error),
168}
169
170impl std::fmt::Display for TrustRootError {
171 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172 match self {
173 Self::NotConfigured { path } => write!(
174 f,
175 "no trust roots configured (looked for {}). \
176 Run `treeship trust add <key_id> <pubkey> --kind <kind>` \
177 or sync from your hub via `treeship hub sync-trust`.",
178 path.display(),
179 ),
180 Self::Malformed { path, msg } => write!(
181 f,
182 "trust root file {} is malformed: {msg}",
183 path.display(),
184 ),
185 Self::Empty { path } => write!(
186 f,
187 "trust root file {} has no roots configured. \
188 Run `treeship trust add <key_id> <pubkey> --kind <kind>` \
189 to add an issuer.",
190 path.display(),
191 ),
192 Self::PermissionsTooOpen { path, mode } => write!(
193 f,
194 "trust root file {} has insecure permissions (mode {:o}); \
195 chmod 600 the file and try again.",
196 path.display(),
197 mode & 0o777,
198 ),
199 Self::Io(e) => write!(f, "trust root io: {e}"),
200 }
201 }
202}
203
204impl std::error::Error for TrustRootError {}
205
206impl From<io::Error> for TrustRootError {
207 fn from(e: io::Error) -> Self { Self::Io(e) }
208}
209
210impl TrustRootStore {
211 pub fn default_path() -> PathBuf {
218 warn_trust_path_override_if_set();
219 std::env::var_os("TREESHIP_TRUST_ROOTS")
220 .map(PathBuf::from)
221 .unwrap_or_else(|| {
222 let home = std::env::var("HOME").unwrap_or_default();
223 PathBuf::from(home).join(".treeship").join("trust_roots.json")
224 })
225 }
226
227 pub fn empty() -> Self {
231 Self { roots: Vec::new() }
232 }
233
234 pub fn with_roots(roots: Vec<TrustRoot>) -> Self {
238 Self { roots }
239 }
240
241 pub fn open_or_empty(path: &Path) -> Result<Self, TrustRootError> {
247 match Self::open(path) {
248 Ok(s) => Ok(s),
249 Err(TrustRootError::NotConfigured { .. }) => Ok(Self::empty()),
250 Err(TrustRootError::Empty { .. }) => Ok(Self::empty()),
251 Err(e) => Err(e),
252 }
253 }
254
255 pub fn open_default_or_empty() -> Result<Self, TrustRootError> {
259 Self::open_or_empty(&Self::default_path())
260 }
261
262 pub fn open(path: &Path) -> Result<Self, TrustRootError> {
273 let mut file = match fs::File::open(path) {
274 Ok(f) => f,
275 Err(e) if e.kind() == io::ErrorKind::NotFound => {
276 return Err(TrustRootError::NotConfigured { path: path.to_path_buf() });
277 }
278 Err(e) => return Err(TrustRootError::Io(e)),
279 };
280 check_open_trust_file_perms(path, &file)?;
281 let mut bytes = Vec::new();
282 file.read_to_end(&mut bytes)?;
283 let file: TrustRootFile = serde_json::from_slice(&bytes)
284 .map_err(|e| TrustRootError::Malformed {
285 path: path.to_path_buf(),
286 msg: e.to_string(),
287 })?;
288 if file.version != SCHEMA_VERSION {
289 return Err(TrustRootError::Malformed {
290 path: path.to_path_buf(),
291 msg: format!(
292 "schema version mismatch: file has v{}, this binary supports v{}",
293 file.version, SCHEMA_VERSION,
294 ),
295 });
296 }
297 for root in &file.roots {
300 decode_ed25519_pubkey(&root.public_key)
301 .map_err(|msg| TrustRootError::Malformed {
302 path: path.to_path_buf(),
303 msg: format!("root {}: {msg}", root.key_id),
304 })?;
305 }
306 if file.roots.is_empty() {
307 return Err(TrustRootError::Empty { path: path.to_path_buf() });
308 }
309 Ok(Self { roots: file.roots })
310 }
311
312 pub fn save(&self, path: &Path) -> Result<(), TrustRootError> {
315 if let Some(parent) = path.parent() {
316 fs::create_dir_all(parent)?;
317 #[cfg(unix)]
318 {
319 use std::os::unix::fs::PermissionsExt;
320 let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
321 }
322 }
323 let file = TrustRootFile {
324 version: SCHEMA_VERSION,
325 roots: self.roots.clone(),
326 };
327 let json = serde_json::to_vec_pretty(&file)
328 .map_err(|e| TrustRootError::Malformed {
329 path: path.to_path_buf(),
330 msg: e.to_string(),
331 })?;
332 fs::write(path, &json)?;
333 #[cfg(unix)]
334 {
335 use std::os::unix::fs::PermissionsExt;
336 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
337 }
338 Ok(())
339 }
340
341 pub fn contains(&self, key: &VerifyingKey, kind: TrustRootKind) -> bool {
345 let key_bytes = key.to_bytes();
346 self.roots.iter().any(|r| {
347 r.kind == kind
348 && decode_ed25519_pubkey(&r.public_key)
349 .map(|k| k.to_bytes() == key_bytes)
350 .unwrap_or(false)
351 })
352 }
353
354 pub fn contains_bytes(&self, key_bytes: &[u8; 32], kind: TrustRootKind) -> bool {
359 match VerifyingKey::from_bytes(key_bytes) {
360 Ok(vk) => self.contains(&vk, kind),
361 Err(_) => false,
362 }
363 }
364
365 pub fn is_empty(&self) -> bool {
369 self.roots.is_empty()
370 }
371
372 pub fn is_empty_for_kind(&self, kind: TrustRootKind) -> bool {
377 !self.roots.iter().any(|r| r.kind == kind)
378 }
379
380 pub fn add(&mut self, root: TrustRoot) {
384 self.roots.retain(|r| !(r.key_id == root.key_id && r.kind == root.kind));
385 self.roots.push(root);
386 }
387
388 pub fn remove(&mut self, key_id: &str) -> bool {
391 let before = self.roots.len();
392 self.roots.retain(|r| r.key_id != key_id);
393 self.roots.len() != before
394 }
395
396 pub fn roots(&self) -> &[TrustRoot] {
398 &self.roots
399 }
400
401 pub fn len(&self) -> usize {
403 self.roots.len()
404 }
405}
406
407pub fn decode_ed25519_pubkey(s: &str) -> Result<VerifyingKey, String> {
411 let b64 = s.strip_prefix("ed25519:").unwrap_or(s);
412 let bytes = URL_SAFE_NO_PAD
413 .decode(b64)
414 .map_err(|e| format!("base64url decode failed: {e}"))?;
415 let arr: [u8; 32] = bytes
416 .as_slice()
417 .try_into()
418 .map_err(|_| format!("expected 32-byte public key, got {} bytes", bytes.len()))?;
419 VerifyingKey::from_bytes(&arr).map_err(|e| format!("not a valid Ed25519 public key: {e}"))
420}
421
422pub fn encode_ed25519_pubkey(key: &VerifyingKey) -> String {
424 format!("ed25519:{}", URL_SAFE_NO_PAD.encode(key.to_bytes()))
425}
426
427#[allow(dead_code)]
428fn check_trust_file_perms(path: &Path) -> Result<(), TrustRootError> {
429 #[cfg(unix)]
430 {
431 use std::os::unix::fs::PermissionsExt;
432 if std::env::var_os("TREESHIP_ALLOW_INSECURE_KEY_PERMS")
438 .map(|v| v == "1")
439 .unwrap_or(false)
440 {
441 warn_insecure_perms_if_bypassed();
442 return Ok(());
443 }
444 let meta = fs::metadata(path)?;
445 let mode = meta.permissions().mode();
446 if mode & 0o077 != 0 {
447 return Err(TrustRootError::PermissionsTooOpen {
448 path: path.to_path_buf(),
449 mode,
450 });
451 }
452 }
453 let _ = path;
454 Ok(())
455}
456
457#[allow(unused_variables)]
469fn check_open_trust_file_perms(path: &Path, file: &fs::File) -> Result<(), TrustRootError> {
470 #[cfg(unix)]
471 {
472 use std::os::unix::fs::PermissionsExt;
473 if std::env::var_os("TREESHIP_ALLOW_INSECURE_KEY_PERMS")
474 .map(|v| v == "1")
475 .unwrap_or(false)
476 {
477 warn_insecure_perms_if_bypassed();
478 return Ok(());
479 }
480 let meta = file.metadata()?;
481 let mode = meta.permissions().mode();
482 if mode & 0o077 != 0 {
483 return Err(TrustRootError::PermissionsTooOpen {
484 path: path.to_path_buf(),
485 mode,
486 });
487 }
488 }
489 Ok(())
490}
491
492#[cfg(test)]
497mod tests {
498 use super::*;
499 use ed25519_dalek::SigningKey;
500
501 fn tmp_dir(tag: &str) -> PathBuf {
502 let mut p = std::env::temp_dir();
503 let mut b = [0u8; 4];
504 use rand::RngCore;
505 rand::thread_rng().fill_bytes(&mut b);
506 p.push(format!("treeship-trust-test-{tag}-{}", hex::encode(b)));
507 std::fs::create_dir_all(&p).unwrap();
508 p
509 }
510
511 fn cleanup(p: &Path) {
512 let _ = fs::remove_dir_all(p);
513 }
514
515 fn fresh_root(key_id: &str, kind: TrustRootKind) -> (SigningKey, TrustRoot) {
516 let sk = SigningKey::generate(&mut rand::thread_rng());
517 let pk = sk.verifying_key();
518 let root = TrustRoot {
519 key_id: key_id.into(),
520 public_key: encode_ed25519_pubkey(&pk),
521 kind,
522 label: format!("test root {key_id}"),
523 added_at: "2026-05-15T00:00:00Z".into(),
524 };
525 (sk, root)
526 }
527
528 #[test]
529 fn roundtrip_save_load() {
530 let dir = tmp_dir("roundtrip");
531 let path = dir.join("trust_roots.json");
532 let (_, r1) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
533 let (_, r2) = fresh_root("ship_b", TrustRootKind::Ship);
534 let store = TrustRootStore::with_roots(vec![r1.clone(), r2.clone()]);
535 store.save(&path).unwrap();
536 let loaded = TrustRootStore::open(&path).unwrap();
537 assert_eq!(loaded.roots().len(), 2);
538 assert_eq!(loaded.roots()[0], r1);
539 assert_eq!(loaded.roots()[1], r2);
540 cleanup(&dir);
541 }
542
543 #[test]
544 fn rejects_missing_file() {
545 let dir = tmp_dir("missing");
546 let path = dir.join("nope.json");
547 match TrustRootStore::open(&path).unwrap_err() {
548 TrustRootError::NotConfigured { path: p } => assert_eq!(p, path),
549 other => panic!("expected NotConfigured, got {other:?}"),
550 }
551 cleanup(&dir);
552 }
553
554 #[test]
555 fn rejects_malformed_json() {
556 let dir = tmp_dir("malformed");
557 let path = dir.join("trust_roots.json");
558 fs::write(&path, b"{ this is not json").unwrap();
559 #[cfg(unix)]
560 {
561 use std::os::unix::fs::PermissionsExt;
562 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
563 }
564 match TrustRootStore::open(&path).unwrap_err() {
565 TrustRootError::Malformed { path: p, .. } => assert_eq!(p, path),
566 other => panic!("expected Malformed, got {other:?}"),
567 }
568 cleanup(&dir);
569 }
570
571 #[test]
572 fn rejects_empty_roots() {
573 let dir = tmp_dir("empty");
574 let path = dir.join("trust_roots.json");
575 let file = serde_json::json!({"version": 1, "roots": []});
576 fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
577 #[cfg(unix)]
578 {
579 use std::os::unix::fs::PermissionsExt;
580 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
581 }
582 match TrustRootStore::open(&path).unwrap_err() {
583 TrustRootError::Empty { path: p } => assert_eq!(p, path),
584 other => panic!("expected Empty, got {other:?}"),
585 }
586 cleanup(&dir);
587 }
588
589 #[test]
590 #[cfg(unix)]
591 fn permission_too_open_warns() {
592 use std::os::unix::fs::PermissionsExt;
593 std::env::remove_var("TREESHIP_ALLOW_INSECURE_KEY_PERMS");
595
596 let dir = tmp_dir("perms");
597 let path = dir.join("trust_roots.json");
598 let (_, r) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
599 let file = TrustRootFile { version: SCHEMA_VERSION, roots: vec![r] };
600 fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
601 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
602
603 match TrustRootStore::open(&path).unwrap_err() {
604 TrustRootError::PermissionsTooOpen { path: p, mode } => {
605 assert_eq!(p, path);
606 assert_eq!(mode & 0o777, 0o644);
607 }
608 other => panic!("expected PermissionsTooOpen, got {other:?}"),
609 }
610 cleanup(&dir);
611 }
612
613 #[test]
622 #[cfg(unix)]
623 fn open_rejects_loose_perms_on_open_fd() {
624 use std::os::unix::fs::PermissionsExt;
625 std::env::remove_var("TREESHIP_ALLOW_INSECURE_KEY_PERMS");
626
627 let dir = tmp_dir("perms-fd");
628 let path = dir.join("trust_roots.json");
629 let (_, r) = fresh_root("hub_b", TrustRootKind::HubCheckpoint);
630 let file = TrustRootFile { version: SCHEMA_VERSION, roots: vec![r] };
631 fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
635 fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap();
636
637 let err = TrustRootStore::open(&path).unwrap_err();
638 match err {
639 TrustRootError::PermissionsTooOpen { path: p, mode } => {
640 assert_eq!(p, path);
641 assert_eq!(mode & 0o777, 0o640);
642 }
643 other => panic!("expected PermissionsTooOpen, got {other:?}"),
644 }
645 cleanup(&dir);
646 }
647
648 #[test]
649 fn contains_matches_kind_correctly() {
650 let (sk, r) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
651 let store = TrustRootStore::with_roots(vec![r]);
652 let vk = sk.verifying_key();
653
654 assert!(store.contains(&vk, TrustRootKind::HubCheckpoint),
655 "must accept matching kind");
656 assert!(!store.contains(&vk, TrustRootKind::Ship),
657 "must reject mismatching kind");
658 assert!(!store.contains(&vk, TrustRootKind::AgentCert),
659 "must reject mismatching kind");
660 }
661
662 #[test]
663 fn add_replaces_same_key_id_and_kind() {
664 let mut store = TrustRootStore::empty();
665 let (_, r1) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
666 let (_, r1b) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
667 store.add(r1);
668 store.add(r1b.clone());
669 assert_eq!(store.len(), 1, "same (id, kind) replaces previous");
670 assert_eq!(&store.roots()[0], &r1b);
671 }
672
673 #[test]
674 fn add_keeps_same_key_id_across_kinds() {
675 let mut store = TrustRootStore::empty();
676 let (_, r_hub) = fresh_root("issuer_x", TrustRootKind::HubCheckpoint);
677 let (_, r_ship) = fresh_root("issuer_x", TrustRootKind::Ship);
678 store.add(r_hub);
679 store.add(r_ship);
680 assert_eq!(store.len(), 2, "same id is allowed across different kinds");
681 }
682
683 #[test]
684 fn remove_strips_all_kinds_for_id() {
685 let mut store = TrustRootStore::empty();
686 let (_, r_hub) = fresh_root("issuer_x", TrustRootKind::HubCheckpoint);
687 let (_, r_ship) = fresh_root("issuer_x", TrustRootKind::Ship);
688 store.add(r_hub);
689 store.add(r_ship);
690 assert!(store.remove("issuer_x"));
691 assert!(store.is_empty());
692 assert!(!store.remove("issuer_x"), "second remove is a no-op");
693 }
694
695 #[test]
696 fn encode_decode_roundtrip() {
697 let sk = SigningKey::generate(&mut rand::thread_rng());
698 let pk = sk.verifying_key();
699 let encoded = encode_ed25519_pubkey(&pk);
700 assert!(encoded.starts_with("ed25519:"));
701 let decoded = decode_ed25519_pubkey(&encoded).unwrap();
702 assert_eq!(decoded.to_bytes(), pk.to_bytes());
703 }
704
705 #[test]
706 fn decode_accepts_bare_base64() {
707 let sk = SigningKey::generate(&mut rand::thread_rng());
708 let pk = sk.verifying_key();
709 let bare = URL_SAFE_NO_PAD.encode(pk.to_bytes());
710 let decoded = decode_ed25519_pubkey(&bare).unwrap();
711 assert_eq!(decoded.to_bytes(), pk.to_bytes());
712 }
713}