Skip to main content

treeship_core/trust/
mod.rs

1//! Trust root pinning for self-signed verification surfaces.
2//!
3//! Three verification paths in Treeship trust a public key that travels
4//! inside the artifact they're verifying:
5//!
6//! 1. `Checkpoint::verify` — the Merkle checkpoint's `public_key` field.
7//! 2. `verify_hub_checkpoint_signature` — the `hub_public_key` field of a
8//!    `JournalCheckpoint` of kind `hub-org`.
9//! 3. `verify_certificate` — the Agent Certificate's
10//!    `signature.public_key` field.
11//!
12//! Without an external pin every one of these is self-signed: an attacker
13//! who mints a new keypair, embeds the public key in the artifact, signs
14//! over the canonical bytes, and presents the result will verify.
15//!
16//! `TrustRootStore` is the pin: a small JSON file at
17//! `~/.treeship/trust_roots.json` listing every public key the operator
18//! has decided to trust as an issuer, keyed by `kind`. The three
19//! verification functions reject any embedded public key that is not in
20//! the store for the matching kind.
21//!
22//! The store deliberately mirrors the keystore: same `~/.treeship`
23//! directory, same `0o600` permission expectation, same JSON-on-disk
24//! shape. There is no remote sync in this release — operators add roots
25//! by hand via `treeship trust add` after verifying the key fingerprint
26//! out-of-band (`treeship hub sync-trust` is referenced in error
27//! messages as the forward-looking automation hook).
28
29use 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
40// Audit lane J fix-up: warn once per process when an env var override
41// is in effect. Silently honoring TREESHIP_TRUST_ROOTS or
42// TREESHIP_ALLOW_INSECURE_KEY_PERMS is exactly the kind of thing a
43// supply-chain attacker would set in a CI runner to redirect trust to
44// a key they control. The warning shows up in stderr at every load
45// (once, deduplicated) so it lands in CI logs.
46static 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/// What this trust root is allowed to verify. Encoded kebab-case in JSON
74/// because the rest of the codebase (CheckpointKind, etc.) does the same.
75///
76/// Adding a variant is a wire-format event: every JSON consumer that
77/// matches exhaustively on this enum must add the new arm in the same
78/// release. Phase 1 of the agent-invitations spec adds `SessionHost`
79/// for invitation issuer pinning; that addition is called out as a
80/// breaking change in the CHANGELOG for the same release.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
82#[serde(rename_all = "snake_case")]
83pub enum TrustRootKind {
84    /// Merkle `Checkpoint` produced by `treeship merkle checkpoint`. This is
85    /// the ship-local journal checkpoint, distinct from the hub-org
86    /// JournalCheckpoint kind below.
87    HubCheckpoint,
88    /// DEPRECATED, INERT (Batch 5 / trust-split). A single `ship` pin used to
89    /// authorize THREE unrelated powers at once: promoting a local journal
90    /// claim to a global single-use claim (hub checkpoints), issuing agent
91    /// certificates, and revoking capabilities. Pinning a hub for dedup thus
92    /// silently let it mint certs and kill capabilities. The powers are now
93    /// split across `HubOrg` / `CertIssuer` / `Revoker`, and NO verifier
94    /// accepts `ship` anymore. The variant is retained ONLY so an existing
95    /// `trust.json` that still contains `"kind":"ship"` parses (rather than
96    /// failing the whole file); such a pin is inert until re-pinned under the
97    /// specific kind. `treeship trust add --kind ship` is rejected.
98    Ship,
99    /// `JournalCheckpoint` of kind `hub-org` -- signed by a remote Hub to
100    /// promote a local journal claim to a global single-use claim. Split out
101    /// of `Ship` so trusting a hub for single-use dedup does not also let it
102    /// issue certificates or revoke capabilities.
103    HubOrg,
104    /// A ship key trusted to ISSUE agent certificates. Split out of `Ship`.
105    CertIssuer,
106    /// A ship key trusted to REVOKE capabilities it issued. Split out of `Ship`.
107    Revoker,
108    /// `AgentCertificate` issued by a ship to one of its agents.
109    AgentCert,
110    /// Phase 1 of agent invitations: the host's signing key that mints
111    /// `InvitationStatement` envelopes. Verifiers (and the
112    /// `treeship session join` flow) require the invitation's issuer
113    /// pubkey to be present in the trust root store under this kind
114    /// before honoring the invitation. Separate from the ship kinds so a
115    /// machine can trust hub-org checkpoints without implicitly
116    /// trusting that hub to host multi-agent rooms.
117    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    /// True for the deprecated, inert `ship` kind that no verifier honors.
147    /// Kept parseable only so legacy trust files still load.
148    pub fn is_deprecated_ship(self) -> bool {
149        matches!(self, Self::Ship)
150    }
151}
152
153/// One pinned trust root.
154#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
155pub struct TrustRoot {
156    /// Opaque identifier. Matches the existing `KeyId` format used elsewhere,
157    /// but the trust store does not require any particular shape -- any
158    /// non-empty string is accepted so operators can use human labels like
159    /// `hub_zerker_labs`.
160    pub key_id: String,
161
162    /// Public key encoded as `ed25519:<base64url-no-pad>`. The prefix is
163    /// required so the format stays algorithm-agnostic when we add more
164    /// signature schemes; today only `ed25519` is recognized.
165    pub public_key: String,
166
167    /// What this root is allowed to verify.
168    pub kind: TrustRootKind,
169
170    /// Human-readable label. Shown by `treeship trust list`. Optional in
171    /// the file format; defaults to the empty string.
172    #[serde(default)]
173    pub label: String,
174
175    /// RFC 3339 timestamp the root was added. Useful for auditing.
176    #[serde(default)]
177    pub added_at: String,
178}
179
180/// On-disk wire format. A separate type so we can evolve the file without
181/// breaking the public `TrustRoot` API.
182#[derive(Debug, Clone, Serialize, Deserialize)]
183struct TrustRootFile {
184    /// Schema version. Currently `1`.
185    pub version: u8,
186    pub roots: Vec<TrustRoot>,
187}
188
189const SCHEMA_VERSION: u8 = 1;
190
191/// In-memory view of the trust root file.
192#[derive(Debug, Clone, Default)]
193pub struct TrustRootStore {
194    roots: Vec<TrustRoot>,
195}
196
197/// Errors loading or operating on a trust root file.
198#[derive(Debug)]
199pub enum TrustRootError {
200    /// The file does not exist. The caller should surface the actionable
201    /// remediation: run `treeship trust add` (or sync from a hub).
202    NotConfigured { path: PathBuf },
203    /// JSON parse or schema validation failed.
204    Malformed { path: PathBuf, msg: String },
205    /// The file exists and is well-formed but contains zero roots. Treated
206    /// the same as `NotConfigured` by verifiers but kept distinct so the
207    /// CLI can show a more targeted error.
208    Empty { path: PathBuf },
209    /// File mode allows group or world access. Refuse to load.
210    PermissionsTooOpen { path: PathBuf, mode: u32 },
211    /// Underlying I/O failure (read, write, mkdir).
212    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    /// Default file location: `~/.treeship/trust_roots.json`.
257    ///
258    /// The `TREESHIP_TRUST_ROOTS` env var overrides the path. When set,
259    /// a one-time warning is emitted on stderr (deduplicated per
260    /// process via `std::sync::Once`) so CI logs show that the trust
261    /// boundary moved.
262    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    /// Construct an empty in-memory store. Useful for tests; the
275    /// verification path treats an empty store the same as a missing
276    /// file (no trust configured).
277    pub fn empty() -> Self {
278        Self { roots: Vec::new() }
279    }
280
281    /// Construct a store from an explicit list of roots. Tests use this
282    /// to thread a known trust set into the verifier; production callers
283    /// should `open` the on-disk file.
284    pub fn with_roots(roots: Vec<TrustRoot>) -> Self {
285        Self { roots }
286    }
287
288    /// Convenience wrapper for code paths that want to "load if
289    /// present, otherwise treat as no-trust-configured". Returns an
290    /// empty store on `NotConfigured`/`Empty`, propagates `Malformed`
291    /// and `PermissionsTooOpen` (operator misconfiguration that
292    /// shouldn't silently downgrade to empty).
293    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    /// Convenience: open the default-path file or return empty if it's
303    /// missing. Loud on malformed/perms errors. Suitable for the
304    /// "thread trust through internal verify pipelines" use case.
305    pub fn open_default_or_empty() -> Result<Self, TrustRootError> {
306        Self::open_or_empty(&Self::default_path())
307    }
308
309    /// Open the trust root file at `path`. Returns `NotConfigured` if it
310    /// does not exist, `Empty` if it exists but has zero roots.
311    ///
312    /// TOCTOU note: the file is opened ONCE, then the perm check runs
313    /// on the resulting `File` (fstat on the fd), and the JSON bytes
314    /// are read from the SAME fd. The path is never re-resolved after
315    /// the open, so an attacker with write access to `~/.treeship/`
316    /// cannot swap `trust_roots.json` between the perm gate and the
317    /// content read. Mirrors the keystore single-open shape in
318    /// `keys/mod.rs::read_entry_with_perm_check`.
319    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        // Validate every embedded public key parses now -- catch a
347        // malformed key at load time rather than at verify time.
348        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    /// Save the store to `path`. Creates parent directories with mode
363    /// 0o700 and writes the file with mode 0o600.
364    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    /// Returns true if `key` is pinned for `kind`. The CLI helper does
391    /// not pre-decode; callers that already hold a `VerifyingKey` should
392    /// use this directly.
393    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    /// Convenience: lookup against a raw 32-byte Ed25519 key without first
404    /// constructing a `VerifyingKey`. Returns false if the bytes are not
405    /// a valid public key (mirrors the verifier's reject-on-decode-failure
406    /// behavior).
407    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    /// True when the store carries zero pinned roots. Verifiers reject
415    /// any artifact when this returns true with a clear "configure trust"
416    /// error.
417    pub fn is_empty(&self) -> bool {
418        self.roots.is_empty()
419    }
420
421    /// True when the store has no pinned root of `kind`. Used by
422    /// verifiers to surface a kind-specific error message when an
423    /// operator has set up `agent_cert` trust but is verifying a
424    /// `hub_checkpoint` (or vice versa).
425    pub fn is_empty_for_kind(&self, kind: TrustRootKind) -> bool {
426        !self.roots.iter().any(|r| r.kind == kind)
427    }
428
429    /// Append a root. Idempotent: re-adding the same `(key_id, kind)`
430    /// pair replaces the previous entry. The CLI `treeship trust add`
431    /// goes through here.
432    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    /// Remove a root by `key_id`. Returns true if a root was removed.
439    /// Removes every entry matching the id across all kinds.
440    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    /// Iterate over every root.
447    pub fn roots(&self) -> &[TrustRoot] {
448        &self.roots
449    }
450
451    /// Number of roots configured.
452    pub fn len(&self) -> usize {
453        self.roots.len()
454    }
455}
456
457/// Decode an `ed25519:<base64url>` or bare base64url public key into a
458/// `VerifyingKey`. The `ed25519:` prefix is the canonical form; the bare
459/// form is accepted for forward-compatibility with operator-typed input.
460pub 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
472/// Encode a `VerifyingKey` into the canonical `ed25519:<base64url>` form.
473pub 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        // Honour the same bypass the keystore honors -- CI sandboxes and
483        // recovery flows occasionally need to load on a loose-perm file.
484        // Audit lane J fix-up: this bypass is a supply-chain hole if
485        // set by a malicious build script. Emit a one-time stderr
486        // warning every time it's honoured so CI logs surface it.
487        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/// Race-free perm gate for the trust root file: fstat on the
508/// already-open `File`. The caller opens the file once, hands the
509/// resulting `File` to this function, then reads JSON from the SAME
510/// `File`. The path is never re-resolved, so a swap between the perm
511/// check and the read cannot influence which bytes back the trust
512/// roots we hand to the verifier.
513///
514/// `path` is carried only for error reporting; the gate operates on
515/// the fd's inode, not the path. Bypass via
516/// `TREESHIP_ALLOW_INSECURE_KEY_PERMS=1` is honored identically to
517/// `check_trust_file_perms`.
518#[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// ---------------------------------------------------------------------------
543// Tests
544// ---------------------------------------------------------------------------
545
546#[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    // Batch 5: legacy `ship` must still PARSE (so an existing trust.json loads
579    // rather than failing the whole file), and round-trip through as_str, and
580    // report as the deprecated kind. The new kinds parse too.
581    #[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        // Ensure the bypass env var isn't leaking in from the host.
659        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    /// v0.10.4 P2 sibling fix: the trust root loader now opens the
682    /// file ONCE and fstat's the resulting fd, mirroring the keystore
683    /// single-open shape. This test pins the gate behavior on a
684    /// loose-perm file: the single-open `open()` path must reject
685    /// without ever parsing the body. (The pre-fix path-based check
686    /// also rejected on loose perms; what changed is that the gate
687    /// now runs on the SAME inode the body read would use, closing
688    /// the TOCTOU window.)
689    #[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        // Valid JSON body -- proves the gate stops us before we
703        // parse, since a successful parse would have returned a
704        // populated store rather than the perms error.
705        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}