Skip to main content

tsafe_core/
trust_store.rs

1//! Pinned-pubkey trust store for `RunEvidence` signature verification —
2//! closes the Phase 5 TOFU gap.
3//!
4//! # Why this module exists
5//!
6//! Phase 5 ([`crate::sign`]) signs `RunEvidence` with an Ed25519 key and
7//! embeds the verifying key on the artifact itself. Verification via
8//! [`crate::sign::verify_signed_evidence`] is therefore **TOFU**
9//! (Trust-On-First-Use): it proves the artifact was signed by *whoever
10//! owns the embedded key*, NOT that the embedded key belongs to a
11//! producer the operator actually trusts. `sign.rs` records this as an
12//! explicit out-of-scope item ("PKI / pubkey-trust management … TOFU").
13//!
14//! This module supplies the missing half: a durable registry mapping a
15//! human-readable **identity name** to a pinned Ed25519 **public key**.
16//! Once an operator has pinned `ci-prod -> <pubkey>` out of band, a
17//! verifier can demand that an artifact's embedded pubkey match a *known*
18//! pinned identity — turning "signed by someone" into "signed by
19//! `ci-prod`", and **failing closed** for any key not on the list.
20//!
21//! # Trust model (honest disclosure)
22//!
23//! - Pinning is the operator's out-of-band act. The store does not (and
24//!   cannot) attest that a pinned key is the "right" one — it records the
25//!   operator's decision and enforces it consistently thereafter.
26//! - The store is integrity-relevant but not itself a secret: it holds
27//!   only public keys. An attacker who can rewrite the store file can
28//!   pin their own key; that is the same trust boundary as any local
29//!   allow-list and is out of scope here (defend the file with normal FS
30//!   permissions / the same posture as `config.json`).
31//! - This closes the *embedded-pubkey* TOFU gap (you no longer have to
32//!   trust the key the artifact hands you). It does NOT add a transparency
33//!   log or third-party timestamp — those remain deferred (see
34//!   `sign.rs` "Out of scope").
35//!
36//! # Wire format
37//!
38//! A single JSON file, `trust-store.json`, under the platform config
39//! root (see [`crate::profile`]). Stable, hand-auditable shape:
40//!
41//! ```json
42//! {
43//!   "schema": "tsafe.attest_trust_store.v1",
44//!   "pins": [
45//!     { "name": "ci-prod", "algo": "ed25519", "pubkey": "<base64url>" }
46//!   ]
47//! }
48//! ```
49
50use std::path::{Path, PathBuf};
51
52use serde::{Deserialize, Serialize};
53use thiserror::Error;
54
55use crate::sign::{decode_verifying_key, SignaturePayload, SIG_ALGO_ED25519};
56
57/// Schema identifier embedded in the persisted trust-store file.
58pub const TRUST_STORE_SCHEMA: &str = "tsafe.attest_trust_store.v1";
59
60/// Filename of the trust store under the config root.
61pub const TRUST_STORE_FILENAME: &str = "trust-store.json";
62
63/// A single pinned identity: a name the operator chose, bound to a
64/// base64url-encoded Ed25519 verifying key.
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
66pub struct TrustPin {
67    /// Operator-chosen identity name (e.g. `ci-prod`, `alice-laptop`).
68    pub name: String,
69    /// Signature algorithm. Always [`SIG_ALGO_ED25519`] in v1.
70    pub algo: String,
71    /// Verifying-key bytes, base64url-encoded, no padding (32 bytes
72    /// decoded) — the same encoding [`SignaturePayload::pubkey`] uses.
73    pub pubkey: String,
74}
75
76/// The persisted trust store: a schema tag plus the ordered list of pins.
77#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78pub struct TrustStore {
79    /// Schema identifier. Always [`TRUST_STORE_SCHEMA`] for files this
80    /// version writes; parsing tolerates a missing tag (legacy/empty).
81    #[serde(default = "default_schema")]
82    pub schema: String,
83    /// Pinned identities, in insertion order.
84    #[serde(default)]
85    pub pins: Vec<TrustPin>,
86}
87
88fn default_schema() -> String {
89    TRUST_STORE_SCHEMA.to_string()
90}
91
92impl Default for TrustStore {
93    fn default() -> Self {
94        TrustStore {
95            schema: default_schema(),
96            pins: Vec::new(),
97        }
98    }
99}
100
101/// Errors arising from trust-store operations.
102#[derive(Debug, Error)]
103pub enum TrustStoreError {
104    /// I/O failure reading or writing the store file.
105    #[error("trust store I/O at {path}: {source}")]
106    Io {
107        /// The path that failed.
108        path: PathBuf,
109        /// Underlying I/O error.
110        #[source]
111        source: std::io::Error,
112    },
113    /// The store file existed but did not parse as JSON.
114    #[error("trust store at {path} is corrupt: {source}")]
115    Parse {
116        /// The path that failed to parse.
117        path: PathBuf,
118        /// Underlying serde error.
119        #[source]
120        source: serde_json::Error,
121    },
122    /// Serialising the store before write failed.
123    #[error("serialise trust store: {0}")]
124    Serialize(#[source] serde_json::Error),
125    /// A pin's pubkey field was not a valid base64url Ed25519 key.
126    #[error("pin '{name}' has an invalid Ed25519 pubkey: {source}")]
127    InvalidPin {
128        /// The offending pin name.
129        name: String,
130        /// Underlying decode error.
131        #[source]
132        source: crate::sign::VerifyError,
133    },
134    /// The pin algorithm was not one this version understands.
135    #[error("pin '{name}' uses unsupported algorithm '{algo}'")]
136    UnsupportedAlgorithm {
137        /// The offending pin name.
138        name: String,
139        /// The unsupported algorithm string.
140        algo: String,
141    },
142    /// Attempted to add a pin whose name already exists.
143    #[error("a pin named '{0}' already exists; remove it first or choose another name")]
144    DuplicateName(String),
145    /// Attempted to remove a pin that does not exist.
146    #[error("no pin named '{0}' is present in the trust store")]
147    NoSuchPin(String),
148}
149
150impl TrustStore {
151    /// Default store path: `<config-root>/trust-store.json`.
152    ///
153    /// Honors `TSAFE_VAULT_DIR` via [`crate::profile::config_path`]'s
154    /// sibling logic so tests and sandboxes can redirect it.
155    pub fn default_path() -> PathBuf {
156        // Co-locate with config.json so the trust store follows the same
157        // root-override (TSAFE_VAULT_DIR) and platform-config rules.
158        crate::profile::config_path()
159            .parent()
160            .map(|p| p.join(TRUST_STORE_FILENAME))
161            .unwrap_or_else(|| PathBuf::from(TRUST_STORE_FILENAME))
162    }
163
164    /// Load the store from `path`, returning an empty store if the file
165    /// does not exist (first-run is not an error).
166    pub fn load(path: &Path) -> Result<TrustStore, TrustStoreError> {
167        match std::fs::read_to_string(path) {
168            Ok(contents) => {
169                serde_json::from_str(&contents).map_err(|source| TrustStoreError::Parse {
170                    path: path.to_path_buf(),
171                    source,
172                })
173            }
174            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(TrustStore::default()),
175            Err(source) => Err(TrustStoreError::Io {
176                path: path.to_path_buf(),
177                source,
178            }),
179        }
180    }
181
182    /// Load from the default path.
183    pub fn load_default() -> Result<TrustStore, TrustStoreError> {
184        Self::load(&Self::default_path())
185    }
186
187    /// Persist the store to `path` atomically (write-tmp + rename).
188    pub fn save(&self, path: &Path) -> Result<(), TrustStoreError> {
189        if let Some(parent) = path.parent() {
190            std::fs::create_dir_all(parent).map_err(|source| TrustStoreError::Io {
191                path: parent.to_path_buf(),
192                source,
193            })?;
194        }
195        let json = serde_json::to_string_pretty(self).map_err(TrustStoreError::Serialize)?;
196        let tmp = path.with_extension("json.tmp");
197        std::fs::write(&tmp, json).map_err(|source| TrustStoreError::Io {
198            path: tmp.clone(),
199            source,
200        })?;
201        std::fs::rename(&tmp, path).map_err(|source| TrustStoreError::Io {
202            path: path.to_path_buf(),
203            source,
204        })?;
205        Ok(())
206    }
207
208    /// Add a pin. Validates that `pubkey` decodes to a real Ed25519 key
209    /// and `algo` is supported BEFORE persisting, so a corrupt pin can
210    /// never enter the store. Rejects a duplicate name (use
211    /// [`TrustStore::remove`] first to rotate a key under an existing
212    /// name).
213    pub fn add(&mut self, name: &str, algo: &str, pubkey: &str) -> Result<(), TrustStoreError> {
214        if algo != SIG_ALGO_ED25519 {
215            return Err(TrustStoreError::UnsupportedAlgorithm {
216                name: name.to_string(),
217                algo: algo.to_string(),
218            });
219        }
220        if self.pins.iter().any(|p| p.name == name) {
221            return Err(TrustStoreError::DuplicateName(name.to_string()));
222        }
223        // Fail-closed: never persist a pin we cannot decode.
224        decode_verifying_key(pubkey).map_err(|source| TrustStoreError::InvalidPin {
225            name: name.to_string(),
226            source,
227        })?;
228        self.pins.push(TrustPin {
229            name: name.to_string(),
230            algo: algo.to_string(),
231            pubkey: pubkey.to_string(),
232        });
233        Ok(())
234    }
235
236    /// Remove the pin named `name`. Errors if no such pin exists.
237    pub fn remove(&mut self, name: &str) -> Result<TrustPin, TrustStoreError> {
238        match self.pins.iter().position(|p| p.name == name) {
239            Some(idx) => Ok(self.pins.remove(idx)),
240            None => Err(TrustStoreError::NoSuchPin(name.to_string())),
241        }
242    }
243
244    /// Look up the pinned identity that owns `pubkey`, if any.
245    ///
246    /// Comparison is on the **decoded** key bytes, not the string, so two
247    /// base64url encodings of the same key (or differing-by-padding
248    /// representations) still match.
249    pub fn identity_for_pubkey(&self, pubkey_b64url: &str) -> Option<&TrustPin> {
250        let target = decode_verifying_key(pubkey_b64url).ok()?;
251        self.pins.iter().find(|p| {
252            decode_verifying_key(&p.pubkey)
253                .map(|k| k.as_bytes() == target.as_bytes())
254                .unwrap_or(false)
255        })
256    }
257
258    /// Resolve which pinned identity (if any) a signature payload's
259    /// embedded pubkey corresponds to. This is the fail-closed gate's
260    /// core question: "is the signer a key we have pinned?"
261    pub fn identity_for_signature(&self, sig: &SignaturePayload) -> Option<&TrustPin> {
262        self.identity_for_pubkey(&sig.pubkey)
263    }
264
265    /// True when at least one pin is present.
266    pub fn is_empty(&self) -> bool {
267        self.pins.is_empty()
268    }
269
270    /// Number of pinned identities.
271    pub fn len(&self) -> usize {
272        self.pins.len()
273    }
274}
275
276#[cfg(test)]
277mod tests {
278    use super::*;
279    use crate::sign::{sign_evidence, SignaturePayload};
280    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
281    use base64::Engine as _;
282    use ed25519_dalek::SigningKey;
283    use rand::rngs::OsRng;
284
285    fn fresh_key() -> SigningKey {
286        SigningKey::generate(&mut OsRng)
287    }
288
289    fn pubkey_b64url(key: &SigningKey) -> String {
290        URL_SAFE_NO_PAD.encode(key.verifying_key().as_bytes())
291    }
292
293    #[test]
294    fn add_then_lookup_by_pubkey() {
295        let key = fresh_key();
296        let pk = pubkey_b64url(&key);
297        let mut store = TrustStore::default();
298        store.add("ci-prod", SIG_ALGO_ED25519, &pk).expect("add");
299        let hit = store.identity_for_pubkey(&pk).expect("pinned identity");
300        assert_eq!(hit.name, "ci-prod");
301    }
302
303    #[test]
304    fn unknown_pubkey_is_not_found_fail_closed() {
305        let pinned = fresh_key();
306        let stranger = fresh_key();
307        let mut store = TrustStore::default();
308        store
309            .add("ci-prod", SIG_ALGO_ED25519, &pubkey_b64url(&pinned))
310            .expect("add");
311        // A key we never pinned must resolve to no identity — the
312        // fail-closed property the gate relies on.
313        assert!(store
314            .identity_for_pubkey(&pubkey_b64url(&stranger))
315            .is_none());
316    }
317
318    #[test]
319    fn add_rejects_invalid_pubkey_and_does_not_persist() {
320        let mut store = TrustStore::default();
321        let err = store
322            .add("bad", SIG_ALGO_ED25519, "not-a-valid-key!!!")
323            .unwrap_err();
324        assert!(matches!(err, TrustStoreError::InvalidPin { .. }));
325        assert!(store.is_empty(), "a rejected pin must not enter the store");
326    }
327
328    #[test]
329    fn add_rejects_unsupported_algorithm() {
330        let key = fresh_key();
331        let mut store = TrustStore::default();
332        let err = store
333            .add("p256", "ecdsa-p256", &pubkey_b64url(&key))
334            .unwrap_err();
335        assert!(matches!(err, TrustStoreError::UnsupportedAlgorithm { .. }));
336        assert!(store.is_empty());
337    }
338
339    #[test]
340    fn add_rejects_duplicate_name() {
341        let a = fresh_key();
342        let b = fresh_key();
343        let mut store = TrustStore::default();
344        store
345            .add("ci", SIG_ALGO_ED25519, &pubkey_b64url(&a))
346            .expect("add a");
347        let err = store
348            .add("ci", SIG_ALGO_ED25519, &pubkey_b64url(&b))
349            .unwrap_err();
350        assert!(matches!(err, TrustStoreError::DuplicateName(_)));
351    }
352
353    #[test]
354    fn remove_existing_and_missing() {
355        let key = fresh_key();
356        let mut store = TrustStore::default();
357        store
358            .add("ci", SIG_ALGO_ED25519, &pubkey_b64url(&key))
359            .expect("add");
360        let removed = store.remove("ci").expect("remove");
361        assert_eq!(removed.name, "ci");
362        assert!(store.is_empty());
363        let err = store.remove("ci").unwrap_err();
364        assert!(matches!(err, TrustStoreError::NoSuchPin(_)));
365    }
366
367    #[test]
368    fn save_then_load_round_trips() {
369        let dir = tempfile::tempdir().expect("tempdir");
370        let path = dir.path().join("trust-store.json");
371        let key = fresh_key();
372        let mut store = TrustStore::default();
373        store
374            .add("ci-prod", SIG_ALGO_ED25519, &pubkey_b64url(&key))
375            .expect("add");
376        store.save(&path).expect("save");
377
378        let reloaded = TrustStore::load(&path).expect("load");
379        assert_eq!(reloaded, store);
380        assert_eq!(reloaded.schema, TRUST_STORE_SCHEMA);
381        assert_eq!(reloaded.len(), 1);
382    }
383
384    #[test]
385    fn load_missing_file_is_empty_not_error() {
386        let dir = tempfile::tempdir().expect("tempdir");
387        let path = dir.path().join("does-not-exist.json");
388        let store = TrustStore::load(&path).expect("missing file => empty store");
389        assert!(store.is_empty());
390    }
391
392    #[test]
393    fn identity_for_signature_matches_signed_artifact() {
394        // End-to-end: sign an artifact, pin the signer, confirm the
395        // store resolves the embedded signature pubkey to the identity.
396        use crate::run_evidence::{
397            blake3_hash, ContractRef, EnforcementResult, EnvironmentEvidence, MachineEvidence,
398            ProcessEvidence, RiskDelta, RunEvidence, RUN_EVIDENCE_VERSION, RUN_SCHEMA,
399        };
400        use chrono::Utc;
401
402        let now = Utc::now();
403        let evidence = RunEvidence {
404            schema: RUN_SCHEMA.to_string(),
405            tsafe_attest_version: RUN_EVIDENCE_VERSION.to_string(),
406            started_at: now,
407            finished_at: now,
408            repo_path: "/tmp/x".to_string(),
409            repo_commit: None,
410            command: vec!["true".to_string()],
411            contract: ContractRef {
412                path: "c.json".to_string(),
413                hash: blake3_hash("c"),
414            },
415            environment: EnvironmentEvidence {
416                parent_env_count: 0,
417                child_env_count: 0,
418                removed_env_count: 0,
419                safe_baseline_injected: vec![],
420                secrets_injected: vec![],
421                sensitive_env_denied: vec![],
422            },
423            process: ProcessEvidence {
424                pid: 1,
425                exit_code: 0,
426                duration_ms: 1,
427                cwd: "/tmp".to_string(),
428            },
429            machine: MachineEvidence {
430                hostname_hash: blake3_hash("h"),
431                username_hash: blake3_hash("u"),
432                os: "linux".to_string(),
433                arch: "x86_64".to_string(),
434            },
435            result: EnforcementResult {
436                contract_enforced: true,
437                violations: vec![],
438                risk_delta: RiskDelta {
439                    before_score: 0,
440                    after_score: 0,
441                },
442            },
443            signature: None,
444        };
445
446        let key = fresh_key();
447        let signed = sign_evidence(&evidence, &key).expect("sign");
448        let sig: SignaturePayload = signed.signature;
449
450        let mut store = TrustStore::default();
451        // Before pinning: signature resolves to no identity (fail-closed).
452        assert!(store.identity_for_signature(&sig).is_none());
453
454        store
455            .add("ci-prod", SIG_ALGO_ED25519, &pubkey_b64url(&key))
456            .expect("pin signer");
457        let id = store
458            .identity_for_signature(&sig)
459            .expect("pinned signer resolves");
460        assert_eq!(id.name, "ci-prod");
461    }
462}