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    /// `JournalCheckpoint` of kind `hub-org` -- signed by a remote Hub to
89    /// promote a local journal claim to a global single-use claim.
90    Ship,
91    /// `AgentCertificate` issued by a ship to one of its agents.
92    AgentCert,
93    /// Phase 1 of agent invitations: the host's signing key that mints
94    /// `InvitationStatement` envelopes. Verifiers (and the
95    /// `treeship session join` flow) require the invitation's issuer
96    /// pubkey to be present in the trust root store under this kind
97    /// before honoring the invitation. Separate from `Ship` so a
98    /// machine can trust hub-org checkpoints without implicitly
99    /// trusting that hub to host multi-agent rooms.
100    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/// One pinned trust root.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct TrustRoot {
127    /// Opaque identifier. Matches the existing `KeyId` format used elsewhere,
128    /// but the trust store does not require any particular shape -- any
129    /// non-empty string is accepted so operators can use human labels like
130    /// `hub_zerker_labs`.
131    pub key_id: String,
132
133    /// Public key encoded as `ed25519:<base64url-no-pad>`. The prefix is
134    /// required so the format stays algorithm-agnostic when we add more
135    /// signature schemes; today only `ed25519` is recognized.
136    pub public_key: String,
137
138    /// What this root is allowed to verify.
139    pub kind: TrustRootKind,
140
141    /// Human-readable label. Shown by `treeship trust list`. Optional in
142    /// the file format; defaults to the empty string.
143    #[serde(default)]
144    pub label: String,
145
146    /// RFC 3339 timestamp the root was added. Useful for auditing.
147    #[serde(default)]
148    pub added_at: String,
149}
150
151/// On-disk wire format. A separate type so we can evolve the file without
152/// breaking the public `TrustRoot` API.
153#[derive(Debug, Clone, Serialize, Deserialize)]
154struct TrustRootFile {
155    /// Schema version. Currently `1`.
156    pub version: u8,
157    pub roots:   Vec<TrustRoot>,
158}
159
160const SCHEMA_VERSION: u8 = 1;
161
162/// In-memory view of the trust root file.
163#[derive(Debug, Clone, Default)]
164pub struct TrustRootStore {
165    roots: Vec<TrustRoot>,
166}
167
168/// Errors loading or operating on a trust root file.
169#[derive(Debug)]
170pub enum TrustRootError {
171    /// The file does not exist. The caller should surface the actionable
172    /// remediation: run `treeship trust add` (or sync from a hub).
173    NotConfigured { path: PathBuf },
174    /// JSON parse or schema validation failed.
175    Malformed { path: PathBuf, msg: String },
176    /// The file exists and is well-formed but contains zero roots. Treated
177    /// the same as `NotConfigured` by verifiers but kept distinct so the
178    /// CLI can show a more targeted error.
179    Empty { path: PathBuf },
180    /// File mode allows group or world access. Refuse to load.
181    PermissionsTooOpen { path: PathBuf, mode: u32 },
182    /// Underlying I/O failure (read, write, mkdir).
183    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    /// Default file location: `~/.treeship/trust_roots.json`.
228    ///
229    /// The `TREESHIP_TRUST_ROOTS` env var overrides the path. When set,
230    /// a one-time warning is emitted on stderr (deduplicated per
231    /// process via `std::sync::Once`) so CI logs show that the trust
232    /// boundary moved.
233    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    /// Construct an empty in-memory store. Useful for tests; the
244    /// verification path treats an empty store the same as a missing
245    /// file (no trust configured).
246    pub fn empty() -> Self {
247        Self { roots: Vec::new() }
248    }
249
250    /// Construct a store from an explicit list of roots. Tests use this
251    /// to thread a known trust set into the verifier; production callers
252    /// should `open` the on-disk file.
253    pub fn with_roots(roots: Vec<TrustRoot>) -> Self {
254        Self { roots }
255    }
256
257    /// Convenience wrapper for code paths that want to "load if
258    /// present, otherwise treat as no-trust-configured". Returns an
259    /// empty store on `NotConfigured`/`Empty`, propagates `Malformed`
260    /// and `PermissionsTooOpen` (operator misconfiguration that
261    /// shouldn't silently downgrade to empty).
262    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    /// Convenience: open the default-path file or return empty if it's
272    /// missing. Loud on malformed/perms errors. Suitable for the
273    /// "thread trust through internal verify pipelines" use case.
274    pub fn open_default_or_empty() -> Result<Self, TrustRootError> {
275        Self::open_or_empty(&Self::default_path())
276    }
277
278    /// Open the trust root file at `path`. Returns `NotConfigured` if it
279    /// does not exist, `Empty` if it exists but has zero roots.
280    ///
281    /// TOCTOU note: the file is opened ONCE, then the perm check runs
282    /// on the resulting `File` (fstat on the fd), and the JSON bytes
283    /// are read from the SAME fd. The path is never re-resolved after
284    /// the open, so an attacker with write access to `~/.treeship/`
285    /// cannot swap `trust_roots.json` between the perm gate and the
286    /// content read. Mirrors the keystore single-open shape in
287    /// `keys/mod.rs::read_entry_with_perm_check`.
288    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        // Validate every embedded public key parses now -- catch a
314        // malformed key at load time rather than at verify time.
315        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    /// Save the store to `path`. Creates parent directories with mode
329    /// 0o700 and writes the file with mode 0o600.
330    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    /// Returns true if `key` is pinned for `kind`. The CLI helper does
358    /// not pre-decode; callers that already hold a `VerifyingKey` should
359    /// use this directly.
360    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    /// Convenience: lookup against a raw 32-byte Ed25519 key without first
371    /// constructing a `VerifyingKey`. Returns false if the bytes are not
372    /// a valid public key (mirrors the verifier's reject-on-decode-failure
373    /// behavior).
374    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    /// True when the store carries zero pinned roots. Verifiers reject
382    /// any artifact when this returns true with a clear "configure trust"
383    /// error.
384    pub fn is_empty(&self) -> bool {
385        self.roots.is_empty()
386    }
387
388    /// True when the store has no pinned root of `kind`. Used by
389    /// verifiers to surface a kind-specific error message when an
390    /// operator has set up `agent_cert` trust but is verifying a
391    /// `hub_checkpoint` (or vice versa).
392    pub fn is_empty_for_kind(&self, kind: TrustRootKind) -> bool {
393        !self.roots.iter().any(|r| r.kind == kind)
394    }
395
396    /// Append a root. Idempotent: re-adding the same `(key_id, kind)`
397    /// pair replaces the previous entry. The CLI `treeship trust add`
398    /// goes through here.
399    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    /// Remove a root by `key_id`. Returns true if a root was removed.
405    /// Removes every entry matching the id across all kinds.
406    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    /// Iterate over every root.
413    pub fn roots(&self) -> &[TrustRoot] {
414        &self.roots
415    }
416
417    /// Number of roots configured.
418    pub fn len(&self) -> usize {
419        self.roots.len()
420    }
421}
422
423/// Decode an `ed25519:<base64url>` or bare base64url public key into a
424/// `VerifyingKey`. The `ed25519:` prefix is the canonical form; the bare
425/// form is accepted for forward-compatibility with operator-typed input.
426pub 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
438/// Encode a `VerifyingKey` into the canonical `ed25519:<base64url>` form.
439pub 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        // Honour the same bypass the keystore honors -- CI sandboxes and
449        // recovery flows occasionally need to load on a loose-perm file.
450        // Audit lane J fix-up: this bypass is a supply-chain hole if
451        // set by a malicious build script. Emit a one-time stderr
452        // warning every time it's honoured so CI logs surface it.
453        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/// Race-free perm gate for the trust root file: fstat on the
474/// already-open `File`. The caller opens the file once, hands the
475/// resulting `File` to this function, then reads JSON from the SAME
476/// `File`. The path is never re-resolved, so a swap between the perm
477/// check and the read cannot influence which bytes back the trust
478/// roots we hand to the verifier.
479///
480/// `path` is carried only for error reporting; the gate operates on
481/// the fd's inode, not the path. Bypass via
482/// `TREESHIP_ALLOW_INSECURE_KEY_PERMS=1` is honored identically to
483/// `check_trust_file_perms`.
484#[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// ---------------------------------------------------------------------------
509// Tests
510// ---------------------------------------------------------------------------
511
512#[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        // Ensure the bypass env var isn't leaking in from the host.
610        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    /// v0.10.4 P2 sibling fix: the trust root loader now opens the
630    /// file ONCE and fstat's the resulting fd, mirroring the keystore
631    /// single-open shape. This test pins the gate behavior on a
632    /// loose-perm file: the single-open `open()` path must reject
633    /// without ever parsing the body. (The pre-fix path-based check
634    /// also rejected on loose perms; what changed is that the gate
635    /// now runs on the SAME inode the body read would use, closing
636    /// the TOCTOU window.)
637    #[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        // Valid JSON body -- proves the gate stops us before we
648        // parse, since a successful parse would have returned a
649        // populated store rather than the perms error.
650        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}