use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::sign::{decode_verifying_key, SignaturePayload, SIG_ALGO_ED25519};
pub const TRUST_STORE_SCHEMA: &str = "tsafe.attest_trust_store.v1";
pub const TRUST_STORE_FILENAME: &str = "trust-store.json";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TrustPin {
pub name: String,
pub algo: String,
pub pubkey: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct TrustStore {
#[serde(default = "default_schema")]
pub schema: String,
#[serde(default)]
pub pins: Vec<TrustPin>,
}
fn default_schema() -> String {
TRUST_STORE_SCHEMA.to_string()
}
impl Default for TrustStore {
fn default() -> Self {
TrustStore {
schema: default_schema(),
pins: Vec::new(),
}
}
}
#[derive(Debug, Error)]
pub enum TrustStoreError {
#[error("trust store I/O at {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("trust store at {path} is corrupt: {source}")]
Parse {
path: PathBuf,
#[source]
source: serde_json::Error,
},
#[error("serialise trust store: {0}")]
Serialize(#[source] serde_json::Error),
#[error("pin '{name}' has an invalid Ed25519 pubkey: {source}")]
InvalidPin {
name: String,
#[source]
source: crate::sign::VerifyError,
},
#[error("pin '{name}' uses unsupported algorithm '{algo}'")]
UnsupportedAlgorithm {
name: String,
algo: String,
},
#[error("a pin named '{0}' already exists; remove it first or choose another name")]
DuplicateName(String),
#[error("no pin named '{0}' is present in the trust store")]
NoSuchPin(String),
}
impl TrustStore {
pub fn default_path() -> PathBuf {
crate::profile::config_path()
.parent()
.map(|p| p.join(TRUST_STORE_FILENAME))
.unwrap_or_else(|| PathBuf::from(TRUST_STORE_FILENAME))
}
pub fn load(path: &Path) -> Result<TrustStore, TrustStoreError> {
match std::fs::read_to_string(path) {
Ok(contents) => {
serde_json::from_str(&contents).map_err(|source| TrustStoreError::Parse {
path: path.to_path_buf(),
source,
})
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(TrustStore::default()),
Err(source) => Err(TrustStoreError::Io {
path: path.to_path_buf(),
source,
}),
}
}
pub fn load_default() -> Result<TrustStore, TrustStoreError> {
Self::load(&Self::default_path())
}
pub fn save(&self, path: &Path) -> Result<(), TrustStoreError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|source| TrustStoreError::Io {
path: parent.to_path_buf(),
source,
})?;
}
let json = serde_json::to_string_pretty(self).map_err(TrustStoreError::Serialize)?;
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, json).map_err(|source| TrustStoreError::Io {
path: tmp.clone(),
source,
})?;
std::fs::rename(&tmp, path).map_err(|source| TrustStoreError::Io {
path: path.to_path_buf(),
source,
})?;
Ok(())
}
pub fn add(&mut self, name: &str, algo: &str, pubkey: &str) -> Result<(), TrustStoreError> {
if algo != SIG_ALGO_ED25519 {
return Err(TrustStoreError::UnsupportedAlgorithm {
name: name.to_string(),
algo: algo.to_string(),
});
}
if self.pins.iter().any(|p| p.name == name) {
return Err(TrustStoreError::DuplicateName(name.to_string()));
}
decode_verifying_key(pubkey).map_err(|source| TrustStoreError::InvalidPin {
name: name.to_string(),
source,
})?;
self.pins.push(TrustPin {
name: name.to_string(),
algo: algo.to_string(),
pubkey: pubkey.to_string(),
});
Ok(())
}
pub fn remove(&mut self, name: &str) -> Result<TrustPin, TrustStoreError> {
match self.pins.iter().position(|p| p.name == name) {
Some(idx) => Ok(self.pins.remove(idx)),
None => Err(TrustStoreError::NoSuchPin(name.to_string())),
}
}
pub fn identity_for_pubkey(&self, pubkey_b64url: &str) -> Option<&TrustPin> {
let target = decode_verifying_key(pubkey_b64url).ok()?;
self.pins.iter().find(|p| {
decode_verifying_key(&p.pubkey)
.map(|k| k.as_bytes() == target.as_bytes())
.unwrap_or(false)
})
}
pub fn identity_for_signature(&self, sig: &SignaturePayload) -> Option<&TrustPin> {
self.identity_for_pubkey(&sig.pubkey)
}
pub fn is_empty(&self) -> bool {
self.pins.is_empty()
}
pub fn len(&self) -> usize {
self.pins.len()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sign::{sign_evidence, SignaturePayload};
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
fn fresh_key() -> SigningKey {
SigningKey::generate(&mut OsRng)
}
fn pubkey_b64url(key: &SigningKey) -> String {
URL_SAFE_NO_PAD.encode(key.verifying_key().as_bytes())
}
#[test]
fn add_then_lookup_by_pubkey() {
let key = fresh_key();
let pk = pubkey_b64url(&key);
let mut store = TrustStore::default();
store.add("ci-prod", SIG_ALGO_ED25519, &pk).expect("add");
let hit = store.identity_for_pubkey(&pk).expect("pinned identity");
assert_eq!(hit.name, "ci-prod");
}
#[test]
fn unknown_pubkey_is_not_found_fail_closed() {
let pinned = fresh_key();
let stranger = fresh_key();
let mut store = TrustStore::default();
store
.add("ci-prod", SIG_ALGO_ED25519, &pubkey_b64url(&pinned))
.expect("add");
assert!(store
.identity_for_pubkey(&pubkey_b64url(&stranger))
.is_none());
}
#[test]
fn add_rejects_invalid_pubkey_and_does_not_persist() {
let mut store = TrustStore::default();
let err = store
.add("bad", SIG_ALGO_ED25519, "not-a-valid-key!!!")
.unwrap_err();
assert!(matches!(err, TrustStoreError::InvalidPin { .. }));
assert!(store.is_empty(), "a rejected pin must not enter the store");
}
#[test]
fn add_rejects_unsupported_algorithm() {
let key = fresh_key();
let mut store = TrustStore::default();
let err = store
.add("p256", "ecdsa-p256", &pubkey_b64url(&key))
.unwrap_err();
assert!(matches!(err, TrustStoreError::UnsupportedAlgorithm { .. }));
assert!(store.is_empty());
}
#[test]
fn add_rejects_duplicate_name() {
let a = fresh_key();
let b = fresh_key();
let mut store = TrustStore::default();
store
.add("ci", SIG_ALGO_ED25519, &pubkey_b64url(&a))
.expect("add a");
let err = store
.add("ci", SIG_ALGO_ED25519, &pubkey_b64url(&b))
.unwrap_err();
assert!(matches!(err, TrustStoreError::DuplicateName(_)));
}
#[test]
fn remove_existing_and_missing() {
let key = fresh_key();
let mut store = TrustStore::default();
store
.add("ci", SIG_ALGO_ED25519, &pubkey_b64url(&key))
.expect("add");
let removed = store.remove("ci").expect("remove");
assert_eq!(removed.name, "ci");
assert!(store.is_empty());
let err = store.remove("ci").unwrap_err();
assert!(matches!(err, TrustStoreError::NoSuchPin(_)));
}
#[test]
fn save_then_load_round_trips() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("trust-store.json");
let key = fresh_key();
let mut store = TrustStore::default();
store
.add("ci-prod", SIG_ALGO_ED25519, &pubkey_b64url(&key))
.expect("add");
store.save(&path).expect("save");
let reloaded = TrustStore::load(&path).expect("load");
assert_eq!(reloaded, store);
assert_eq!(reloaded.schema, TRUST_STORE_SCHEMA);
assert_eq!(reloaded.len(), 1);
}
#[test]
fn load_missing_file_is_empty_not_error() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("does-not-exist.json");
let store = TrustStore::load(&path).expect("missing file => empty store");
assert!(store.is_empty());
}
#[test]
fn identity_for_signature_matches_signed_artifact() {
use crate::run_evidence::{
blake3_hash, ContractRef, EnforcementResult, EnvironmentEvidence, MachineEvidence,
ProcessEvidence, RiskDelta, RunEvidence, RUN_EVIDENCE_VERSION, RUN_SCHEMA,
};
use chrono::Utc;
let now = Utc::now();
let evidence = RunEvidence {
schema: RUN_SCHEMA.to_string(),
tsafe_attest_version: RUN_EVIDENCE_VERSION.to_string(),
started_at: now,
finished_at: now,
repo_path: "/tmp/x".to_string(),
repo_commit: None,
command: vec!["true".to_string()],
contract: ContractRef {
path: "c.json".to_string(),
hash: blake3_hash("c"),
},
environment: EnvironmentEvidence {
parent_env_count: 0,
child_env_count: 0,
removed_env_count: 0,
safe_baseline_injected: vec![],
secrets_injected: vec![],
sensitive_env_denied: vec![],
},
process: ProcessEvidence {
pid: 1,
exit_code: 0,
duration_ms: 1,
cwd: "/tmp".to_string(),
},
machine: MachineEvidence {
hostname_hash: blake3_hash("h"),
username_hash: blake3_hash("u"),
os: "linux".to_string(),
arch: "x86_64".to_string(),
},
result: EnforcementResult {
contract_enforced: true,
violations: vec![],
risk_delta: RiskDelta {
before_score: 0,
after_score: 0,
},
},
signature: None,
};
let key = fresh_key();
let signed = sign_evidence(&evidence, &key).expect("sign");
let sig: SignaturePayload = signed.signature;
let mut store = TrustStore::default();
assert!(store.identity_for_signature(&sig).is_none());
store
.add("ci-prod", SIG_ALGO_ED25519, &pubkey_b64url(&key))
.expect("pin signer");
let id = store
.identity_for_signature(&sig)
.expect("pinned signer resolves");
assert_eq!(id.name, "ci-prod");
}
}