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,
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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
76#[serde(rename_all = "snake_case")]
77pub enum TrustRootKind {
78    /// Merkle `Checkpoint` produced by `treeship merkle checkpoint`. This is
79    /// the ship-local journal checkpoint, distinct from the hub-org
80    /// JournalCheckpoint kind below.
81    HubCheckpoint,
82    /// `JournalCheckpoint` of kind `hub-org` -- signed by a remote Hub to
83    /// promote a local journal claim to a global single-use claim.
84    Ship,
85    /// `AgentCertificate` issued by a ship to one of its agents.
86    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/// One pinned trust root.
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110pub struct TrustRoot {
111    /// Opaque identifier. Matches the existing `KeyId` format used elsewhere,
112    /// but the trust store does not require any particular shape -- any
113    /// non-empty string is accepted so operators can use human labels like
114    /// `hub_zerker_labs`.
115    pub key_id: String,
116
117    /// Public key encoded as `ed25519:<base64url-no-pad>`. The prefix is
118    /// required so the format stays algorithm-agnostic when we add more
119    /// signature schemes; today only `ed25519` is recognized.
120    pub public_key: String,
121
122    /// What this root is allowed to verify.
123    pub kind: TrustRootKind,
124
125    /// Human-readable label. Shown by `treeship trust list`. Optional in
126    /// the file format; defaults to the empty string.
127    #[serde(default)]
128    pub label: String,
129
130    /// RFC 3339 timestamp the root was added. Useful for auditing.
131    #[serde(default)]
132    pub added_at: String,
133}
134
135/// On-disk wire format. A separate type so we can evolve the file without
136/// breaking the public `TrustRoot` API.
137#[derive(Debug, Clone, Serialize, Deserialize)]
138struct TrustRootFile {
139    /// Schema version. Currently `1`.
140    pub version: u8,
141    pub roots:   Vec<TrustRoot>,
142}
143
144const SCHEMA_VERSION: u8 = 1;
145
146/// In-memory view of the trust root file.
147#[derive(Debug, Clone, Default)]
148pub struct TrustRootStore {
149    roots: Vec<TrustRoot>,
150}
151
152/// Errors loading or operating on a trust root file.
153#[derive(Debug)]
154pub enum TrustRootError {
155    /// The file does not exist. The caller should surface the actionable
156    /// remediation: run `treeship trust add` (or sync from a hub).
157    NotConfigured { path: PathBuf },
158    /// JSON parse or schema validation failed.
159    Malformed { path: PathBuf, msg: String },
160    /// The file exists and is well-formed but contains zero roots. Treated
161    /// the same as `NotConfigured` by verifiers but kept distinct so the
162    /// CLI can show a more targeted error.
163    Empty { path: PathBuf },
164    /// File mode allows group or world access. Refuse to load.
165    PermissionsTooOpen { path: PathBuf, mode: u32 },
166    /// Underlying I/O failure (read, write, mkdir).
167    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    /// Default file location: `~/.treeship/trust_roots.json`.
212    ///
213    /// The `TREESHIP_TRUST_ROOTS` env var overrides the path. When set,
214    /// a one-time warning is emitted on stderr (deduplicated per
215    /// process via `std::sync::Once`) so CI logs show that the trust
216    /// boundary moved.
217    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    /// Construct an empty in-memory store. Useful for tests; the
228    /// verification path treats an empty store the same as a missing
229    /// file (no trust configured).
230    pub fn empty() -> Self {
231        Self { roots: Vec::new() }
232    }
233
234    /// Construct a store from an explicit list of roots. Tests use this
235    /// to thread a known trust set into the verifier; production callers
236    /// should `open` the on-disk file.
237    pub fn with_roots(roots: Vec<TrustRoot>) -> Self {
238        Self { roots }
239    }
240
241    /// Convenience wrapper for code paths that want to "load if
242    /// present, otherwise treat as no-trust-configured". Returns an
243    /// empty store on `NotConfigured`/`Empty`, propagates `Malformed`
244    /// and `PermissionsTooOpen` (operator misconfiguration that
245    /// shouldn't silently downgrade to empty).
246    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    /// Convenience: open the default-path file or return empty if it's
256    /// missing. Loud on malformed/perms errors. Suitable for the
257    /// "thread trust through internal verify pipelines" use case.
258    pub fn open_default_or_empty() -> Result<Self, TrustRootError> {
259        Self::open_or_empty(&Self::default_path())
260    }
261
262    /// Open the trust root file at `path`. Returns `NotConfigured` if it
263    /// does not exist, `Empty` if it exists but has zero roots.
264    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        // Validate every embedded public key parses now -- catch a
285        // malformed key at load time rather than at verify time.
286        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    /// Save the store to `path`. Creates parent directories with mode
300    /// 0o700 and writes the file with mode 0o600.
301    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    /// Returns true if `key` is pinned for `kind`. The CLI helper does
329    /// not pre-decode; callers that already hold a `VerifyingKey` should
330    /// use this directly.
331    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    /// Convenience: lookup against a raw 32-byte Ed25519 key without first
342    /// constructing a `VerifyingKey`. Returns false if the bytes are not
343    /// a valid public key (mirrors the verifier's reject-on-decode-failure
344    /// behavior).
345    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    /// True when the store carries zero pinned roots. Verifiers reject
353    /// any artifact when this returns true with a clear "configure trust"
354    /// error.
355    pub fn is_empty(&self) -> bool {
356        self.roots.is_empty()
357    }
358
359    /// True when the store has no pinned root of `kind`. Used by
360    /// verifiers to surface a kind-specific error message when an
361    /// operator has set up `agent_cert` trust but is verifying a
362    /// `hub_checkpoint` (or vice versa).
363    pub fn is_empty_for_kind(&self, kind: TrustRootKind) -> bool {
364        !self.roots.iter().any(|r| r.kind == kind)
365    }
366
367    /// Append a root. Idempotent: re-adding the same `(key_id, kind)`
368    /// pair replaces the previous entry. The CLI `treeship trust add`
369    /// goes through here.
370    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    /// Remove a root by `key_id`. Returns true if a root was removed.
376    /// Removes every entry matching the id across all kinds.
377    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    /// Iterate over every root.
384    pub fn roots(&self) -> &[TrustRoot] {
385        &self.roots
386    }
387
388    /// Number of roots configured.
389    pub fn len(&self) -> usize {
390        self.roots.len()
391    }
392}
393
394/// Decode an `ed25519:<base64url>` or bare base64url public key into a
395/// `VerifyingKey`. The `ed25519:` prefix is the canonical form; the bare
396/// form is accepted for forward-compatibility with operator-typed input.
397pub 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
409/// Encode a `VerifyingKey` into the canonical `ed25519:<base64url>` form.
410pub 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        // Honour the same bypass the keystore honors -- CI sandboxes and
419        // recovery flows occasionally need to load on a loose-perm file.
420        // Audit lane J fix-up: this bypass is a supply-chain hole if
421        // set by a malicious build script. Emit a one-time stderr
422        // warning every time it's honoured so CI logs surface it.
423        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// ---------------------------------------------------------------------------
444// Tests
445// ---------------------------------------------------------------------------
446
447#[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        // Ensure the bypass env var isn't leaking in from the host.
545        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}