1use std::path::{Path, PathBuf};
51
52use serde::{Deserialize, Serialize};
53use thiserror::Error;
54
55use crate::sign::{decode_verifying_key, SignaturePayload, SIG_ALGO_ED25519};
56
57pub const TRUST_STORE_SCHEMA: &str = "tsafe.attest_trust_store.v1";
59
60pub const TRUST_STORE_FILENAME: &str = "trust-store.json";
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
66pub struct TrustPin {
67 pub name: String,
69 pub algo: String,
71 pub pubkey: String,
74}
75
76#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
78pub struct TrustStore {
79 #[serde(default = "default_schema")]
82 pub schema: String,
83 #[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#[derive(Debug, Error)]
103pub enum TrustStoreError {
104 #[error("trust store I/O at {path}: {source}")]
106 Io {
107 path: PathBuf,
109 #[source]
111 source: std::io::Error,
112 },
113 #[error("trust store at {path} is corrupt: {source}")]
115 Parse {
116 path: PathBuf,
118 #[source]
120 source: serde_json::Error,
121 },
122 #[error("serialise trust store: {0}")]
124 Serialize(#[source] serde_json::Error),
125 #[error("pin '{name}' has an invalid Ed25519 pubkey: {source}")]
127 InvalidPin {
128 name: String,
130 #[source]
132 source: crate::sign::VerifyError,
133 },
134 #[error("pin '{name}' uses unsupported algorithm '{algo}'")]
136 UnsupportedAlgorithm {
137 name: String,
139 algo: String,
141 },
142 #[error("a pin named '{0}' already exists; remove it first or choose another name")]
144 DuplicateName(String),
145 #[error("no pin named '{0}' is present in the trust store")]
147 NoSuchPin(String),
148}
149
150impl TrustStore {
151 pub fn default_path() -> PathBuf {
156 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 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 pub fn load_default() -> Result<TrustStore, TrustStoreError> {
184 Self::load(&Self::default_path())
185 }
186
187 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 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 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 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 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 pub fn identity_for_signature(&self, sig: &SignaturePayload) -> Option<&TrustPin> {
262 self.identity_for_pubkey(&sig.pubkey)
263 }
264
265 pub fn is_empty(&self) -> bool {
267 self.pins.is_empty()
268 }
269
270 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 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 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 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}