1use std::{
30 fs,
31 io,
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> {
265 if !path.exists() {
266 return Err(TrustRootError::NotConfigured { path: path.to_path_buf() });
267 }
268 check_trust_file_perms(path)?;
269 let bytes = fs::read(path)?;
270 let file: TrustRootFile = serde_json::from_slice(&bytes)
271 .map_err(|e| TrustRootError::Malformed {
272 path: path.to_path_buf(),
273 msg: e.to_string(),
274 })?;
275 if file.version != SCHEMA_VERSION {
276 return Err(TrustRootError::Malformed {
277 path: path.to_path_buf(),
278 msg: format!(
279 "schema version mismatch: file has v{}, this binary supports v{}",
280 file.version, SCHEMA_VERSION,
281 ),
282 });
283 }
284 for root in &file.roots {
287 decode_ed25519_pubkey(&root.public_key)
288 .map_err(|msg| TrustRootError::Malformed {
289 path: path.to_path_buf(),
290 msg: format!("root {}: {msg}", root.key_id),
291 })?;
292 }
293 if file.roots.is_empty() {
294 return Err(TrustRootError::Empty { path: path.to_path_buf() });
295 }
296 Ok(Self { roots: file.roots })
297 }
298
299 pub fn save(&self, path: &Path) -> Result<(), TrustRootError> {
302 if let Some(parent) = path.parent() {
303 fs::create_dir_all(parent)?;
304 #[cfg(unix)]
305 {
306 use std::os::unix::fs::PermissionsExt;
307 let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
308 }
309 }
310 let file = TrustRootFile {
311 version: SCHEMA_VERSION,
312 roots: self.roots.clone(),
313 };
314 let json = serde_json::to_vec_pretty(&file)
315 .map_err(|e| TrustRootError::Malformed {
316 path: path.to_path_buf(),
317 msg: e.to_string(),
318 })?;
319 fs::write(path, &json)?;
320 #[cfg(unix)]
321 {
322 use std::os::unix::fs::PermissionsExt;
323 fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
324 }
325 Ok(())
326 }
327
328 pub fn contains(&self, key: &VerifyingKey, kind: TrustRootKind) -> bool {
332 let key_bytes = key.to_bytes();
333 self.roots.iter().any(|r| {
334 r.kind == kind
335 && decode_ed25519_pubkey(&r.public_key)
336 .map(|k| k.to_bytes() == key_bytes)
337 .unwrap_or(false)
338 })
339 }
340
341 pub fn contains_bytes(&self, key_bytes: &[u8; 32], kind: TrustRootKind) -> bool {
346 match VerifyingKey::from_bytes(key_bytes) {
347 Ok(vk) => self.contains(&vk, kind),
348 Err(_) => false,
349 }
350 }
351
352 pub fn is_empty(&self) -> bool {
356 self.roots.is_empty()
357 }
358
359 pub fn is_empty_for_kind(&self, kind: TrustRootKind) -> bool {
364 !self.roots.iter().any(|r| r.kind == kind)
365 }
366
367 pub fn add(&mut self, root: TrustRoot) {
371 self.roots.retain(|r| !(r.key_id == root.key_id && r.kind == root.kind));
372 self.roots.push(root);
373 }
374
375 pub fn remove(&mut self, key_id: &str) -> bool {
378 let before = self.roots.len();
379 self.roots.retain(|r| r.key_id != key_id);
380 self.roots.len() != before
381 }
382
383 pub fn roots(&self) -> &[TrustRoot] {
385 &self.roots
386 }
387
388 pub fn len(&self) -> usize {
390 self.roots.len()
391 }
392}
393
394pub fn decode_ed25519_pubkey(s: &str) -> Result<VerifyingKey, String> {
398 let b64 = s.strip_prefix("ed25519:").unwrap_or(s);
399 let bytes = URL_SAFE_NO_PAD
400 .decode(b64)
401 .map_err(|e| format!("base64url decode failed: {e}"))?;
402 let arr: [u8; 32] = bytes
403 .as_slice()
404 .try_into()
405 .map_err(|_| format!("expected 32-byte public key, got {} bytes", bytes.len()))?;
406 VerifyingKey::from_bytes(&arr).map_err(|e| format!("not a valid Ed25519 public key: {e}"))
407}
408
409pub fn encode_ed25519_pubkey(key: &VerifyingKey) -> String {
411 format!("ed25519:{}", URL_SAFE_NO_PAD.encode(key.to_bytes()))
412}
413
414fn check_trust_file_perms(path: &Path) -> Result<(), TrustRootError> {
415 #[cfg(unix)]
416 {
417 use std::os::unix::fs::PermissionsExt;
418 if std::env::var_os("TREESHIP_ALLOW_INSECURE_KEY_PERMS")
424 .map(|v| v == "1")
425 .unwrap_or(false)
426 {
427 warn_insecure_perms_if_bypassed();
428 return Ok(());
429 }
430 let meta = fs::metadata(path)?;
431 let mode = meta.permissions().mode();
432 if mode & 0o077 != 0 {
433 return Err(TrustRootError::PermissionsTooOpen {
434 path: path.to_path_buf(),
435 mode,
436 });
437 }
438 }
439 let _ = path;
440 Ok(())
441}
442
443#[cfg(test)]
448mod tests {
449 use super::*;
450 use ed25519_dalek::SigningKey;
451
452 fn tmp_dir(tag: &str) -> PathBuf {
453 let mut p = std::env::temp_dir();
454 let mut b = [0u8; 4];
455 use rand::RngCore;
456 rand::thread_rng().fill_bytes(&mut b);
457 p.push(format!("treeship-trust-test-{tag}-{}", hex::encode(b)));
458 std::fs::create_dir_all(&p).unwrap();
459 p
460 }
461
462 fn cleanup(p: &Path) {
463 let _ = fs::remove_dir_all(p);
464 }
465
466 fn fresh_root(key_id: &str, kind: TrustRootKind) -> (SigningKey, TrustRoot) {
467 let sk = SigningKey::generate(&mut rand::thread_rng());
468 let pk = sk.verifying_key();
469 let root = TrustRoot {
470 key_id: key_id.into(),
471 public_key: encode_ed25519_pubkey(&pk),
472 kind,
473 label: format!("test root {key_id}"),
474 added_at: "2026-05-15T00:00:00Z".into(),
475 };
476 (sk, root)
477 }
478
479 #[test]
480 fn roundtrip_save_load() {
481 let dir = tmp_dir("roundtrip");
482 let path = dir.join("trust_roots.json");
483 let (_, r1) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
484 let (_, r2) = fresh_root("ship_b", TrustRootKind::Ship);
485 let store = TrustRootStore::with_roots(vec![r1.clone(), r2.clone()]);
486 store.save(&path).unwrap();
487 let loaded = TrustRootStore::open(&path).unwrap();
488 assert_eq!(loaded.roots().len(), 2);
489 assert_eq!(loaded.roots()[0], r1);
490 assert_eq!(loaded.roots()[1], r2);
491 cleanup(&dir);
492 }
493
494 #[test]
495 fn rejects_missing_file() {
496 let dir = tmp_dir("missing");
497 let path = dir.join("nope.json");
498 match TrustRootStore::open(&path).unwrap_err() {
499 TrustRootError::NotConfigured { path: p } => assert_eq!(p, path),
500 other => panic!("expected NotConfigured, got {other:?}"),
501 }
502 cleanup(&dir);
503 }
504
505 #[test]
506 fn rejects_malformed_json() {
507 let dir = tmp_dir("malformed");
508 let path = dir.join("trust_roots.json");
509 fs::write(&path, b"{ this is not json").unwrap();
510 #[cfg(unix)]
511 {
512 use std::os::unix::fs::PermissionsExt;
513 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
514 }
515 match TrustRootStore::open(&path).unwrap_err() {
516 TrustRootError::Malformed { path: p, .. } => assert_eq!(p, path),
517 other => panic!("expected Malformed, got {other:?}"),
518 }
519 cleanup(&dir);
520 }
521
522 #[test]
523 fn rejects_empty_roots() {
524 let dir = tmp_dir("empty");
525 let path = dir.join("trust_roots.json");
526 let file = serde_json::json!({"version": 1, "roots": []});
527 fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
528 #[cfg(unix)]
529 {
530 use std::os::unix::fs::PermissionsExt;
531 fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap();
532 }
533 match TrustRootStore::open(&path).unwrap_err() {
534 TrustRootError::Empty { path: p } => assert_eq!(p, path),
535 other => panic!("expected Empty, got {other:?}"),
536 }
537 cleanup(&dir);
538 }
539
540 #[test]
541 #[cfg(unix)]
542 fn permission_too_open_warns() {
543 use std::os::unix::fs::PermissionsExt;
544 std::env::remove_var("TREESHIP_ALLOW_INSECURE_KEY_PERMS");
546
547 let dir = tmp_dir("perms");
548 let path = dir.join("trust_roots.json");
549 let (_, r) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
550 let file = TrustRootFile { version: SCHEMA_VERSION, roots: vec![r] };
551 fs::write(&path, serde_json::to_vec_pretty(&file).unwrap()).unwrap();
552 fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap();
553
554 match TrustRootStore::open(&path).unwrap_err() {
555 TrustRootError::PermissionsTooOpen { path: p, mode } => {
556 assert_eq!(p, path);
557 assert_eq!(mode & 0o777, 0o644);
558 }
559 other => panic!("expected PermissionsTooOpen, got {other:?}"),
560 }
561 cleanup(&dir);
562 }
563
564 #[test]
565 fn contains_matches_kind_correctly() {
566 let (sk, r) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
567 let store = TrustRootStore::with_roots(vec![r]);
568 let vk = sk.verifying_key();
569
570 assert!(store.contains(&vk, TrustRootKind::HubCheckpoint),
571 "must accept matching kind");
572 assert!(!store.contains(&vk, TrustRootKind::Ship),
573 "must reject mismatching kind");
574 assert!(!store.contains(&vk, TrustRootKind::AgentCert),
575 "must reject mismatching kind");
576 }
577
578 #[test]
579 fn add_replaces_same_key_id_and_kind() {
580 let mut store = TrustRootStore::empty();
581 let (_, r1) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
582 let (_, r1b) = fresh_root("hub_a", TrustRootKind::HubCheckpoint);
583 store.add(r1);
584 store.add(r1b.clone());
585 assert_eq!(store.len(), 1, "same (id, kind) replaces previous");
586 assert_eq!(&store.roots()[0], &r1b);
587 }
588
589 #[test]
590 fn add_keeps_same_key_id_across_kinds() {
591 let mut store = TrustRootStore::empty();
592 let (_, r_hub) = fresh_root("issuer_x", TrustRootKind::HubCheckpoint);
593 let (_, r_ship) = fresh_root("issuer_x", TrustRootKind::Ship);
594 store.add(r_hub);
595 store.add(r_ship);
596 assert_eq!(store.len(), 2, "same id is allowed across different kinds");
597 }
598
599 #[test]
600 fn remove_strips_all_kinds_for_id() {
601 let mut store = TrustRootStore::empty();
602 let (_, r_hub) = fresh_root("issuer_x", TrustRootKind::HubCheckpoint);
603 let (_, r_ship) = fresh_root("issuer_x", TrustRootKind::Ship);
604 store.add(r_hub);
605 store.add(r_ship);
606 assert!(store.remove("issuer_x"));
607 assert!(store.is_empty());
608 assert!(!store.remove("issuer_x"), "second remove is a no-op");
609 }
610
611 #[test]
612 fn encode_decode_roundtrip() {
613 let sk = SigningKey::generate(&mut rand::thread_rng());
614 let pk = sk.verifying_key();
615 let encoded = encode_ed25519_pubkey(&pk);
616 assert!(encoded.starts_with("ed25519:"));
617 let decoded = decode_ed25519_pubkey(&encoded).unwrap();
618 assert_eq!(decoded.to_bytes(), pk.to_bytes());
619 }
620
621 #[test]
622 fn decode_accepts_bare_base64() {
623 let sk = SigningKey::generate(&mut rand::thread_rng());
624 let pk = sk.verifying_key();
625 let bare = URL_SAFE_NO_PAD.encode(pk.to_bytes());
626 let decoded = decode_ed25519_pubkey(&bare).unwrap();
627 assert_eq!(decoded.to_bytes(), pk.to_bytes());
628 }
629}