use crate::run_evidence::RunEvidence;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine as _;
use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey, SIGNATURE_LENGTH};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
use thiserror::Error;
pub const DOMAIN_TAG: &[u8] = b"tsafe.run_evidence.v1\0";
pub const SIG_ALGO_ED25519: &str = "ed25519";
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SignaturePayload {
pub algo: String,
pub pubkey: String,
pub sig: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignedEvidence {
pub evidence: RunEvidence,
pub signature: SignaturePayload,
}
#[derive(Debug, Error)]
pub enum SignError {
#[error("serialise RunEvidence for signing: {0}")]
Serialize(#[from] serde_json::Error),
}
#[derive(Debug, Error)]
pub enum VerifyError {
#[error("evidence has no signature field")]
SignatureAbsent,
#[error("unsupported signature algorithm: {0}")]
UnsupportedAlgorithm(String),
#[error("invalid base64url encoding on signature field: {0}")]
Base64(#[from] base64::DecodeError),
#[error("invalid pubkey length: expected 32 bytes, got {0}")]
PubkeyLength(usize),
#[error("invalid signature length: expected 64 bytes, got {0}")]
SignatureLength(usize),
#[error("malformed Ed25519 verifying key: {0}")]
MalformedKey(ed25519_dalek::ed25519::Error),
#[error("signature verification failed: {0}")]
SignatureMismatch(ed25519_dalek::ed25519::Error),
#[error("serialise RunEvidence for verification: {0}")]
Serialize(#[from] serde_json::Error),
}
pub fn canonical_bytes(evidence: &RunEvidence) -> Vec<u8> {
let mut value = serde_json::to_value(evidence)
.expect("RunEvidence::serialize is infallible (derive-Serialize on JSON-native fields)");
if let Value::Object(map) = &mut value {
map.remove("signature");
}
let canonical = canonicalise(value);
serde_json::to_string(&canonical)
.expect("canonical Value is JSON-native by construction")
.into_bytes()
}
fn canonicalise(value: Value) -> Value {
match value {
Value::Object(map) => {
let mut entries: Vec<(String, Value)> = map.into_iter().collect();
entries.sort_by(|a, b| a.0.cmp(&b.0));
let mut sorted = Map::new();
for (key, child) in entries {
sorted.insert(key, canonicalise(child));
}
Value::Object(sorted)
}
Value::Array(items) => Value::Array(items.into_iter().map(canonicalise).collect()),
other => other,
}
}
pub fn sign_evidence(
evidence: &RunEvidence,
signing_key: &SigningKey,
) -> Result<SignedEvidence, SignError> {
let canonical = canonical_bytes(evidence);
let mut to_sign = Vec::with_capacity(DOMAIN_TAG.len() + canonical.len());
to_sign.extend_from_slice(DOMAIN_TAG);
to_sign.extend_from_slice(&canonical);
let signature = signing_key.sign(&to_sign);
let verifying_key = signing_key.verifying_key();
let payload = SignaturePayload {
algo: SIG_ALGO_ED25519.to_string(),
pubkey: URL_SAFE_NO_PAD.encode(verifying_key.as_bytes()),
sig: URL_SAFE_NO_PAD.encode(signature.to_bytes()),
};
let mut signed_evidence = evidence.clone();
signed_evidence.signature = Some(payload.clone());
Ok(SignedEvidence {
evidence: signed_evidence,
signature: payload,
})
}
pub fn verify_evidence(
signed: &SignedEvidence,
verifying_key: &VerifyingKey,
) -> Result<(), VerifyError> {
if signed.signature.algo != SIG_ALGO_ED25519 {
return Err(VerifyError::UnsupportedAlgorithm(
signed.signature.algo.clone(),
));
}
let sig_bytes = URL_SAFE_NO_PAD.decode(&signed.signature.sig)?;
if sig_bytes.len() != SIGNATURE_LENGTH {
return Err(VerifyError::SignatureLength(sig_bytes.len()));
}
let sig_array: [u8; SIGNATURE_LENGTH] = sig_bytes
.as_slice()
.try_into()
.expect("length-checked above");
let signature = Signature::from_bytes(&sig_array);
let canonical = canonical_bytes(&signed.evidence);
let mut to_verify = Vec::with_capacity(DOMAIN_TAG.len() + canonical.len());
to_verify.extend_from_slice(DOMAIN_TAG);
to_verify.extend_from_slice(&canonical);
verifying_key
.verify(&to_verify, &signature)
.map_err(VerifyError::SignatureMismatch)
}
pub fn verify_signed_evidence(signed: &SignedEvidence) -> Result<(), VerifyError> {
let key = decode_verifying_key(&signed.signature.pubkey)?;
verify_evidence(signed, &key)
}
pub fn decode_verifying_key(pubkey_b64url: &str) -> Result<VerifyingKey, VerifyError> {
let bytes = URL_SAFE_NO_PAD.decode(pubkey_b64url)?;
if bytes.len() != ed25519_dalek::PUBLIC_KEY_LENGTH {
return Err(VerifyError::PubkeyLength(bytes.len()));
}
let array: [u8; ed25519_dalek::PUBLIC_KEY_LENGTH] =
bytes.as_slice().try_into().expect("length-checked above");
VerifyingKey::from_bytes(&array).map_err(VerifyError::MalformedKey)
}
pub fn signed_from_run_evidence(evidence: RunEvidence) -> Option<SignedEvidence> {
let signature = evidence.signature.clone()?;
Some(SignedEvidence {
evidence,
signature,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::run_evidence::{
blake3_hash, ContractRef, DeniedSensitiveEnvEvidence, EnforcementResult,
EnvironmentEvidence, InjectedSecretEvidence, MachineEvidence, ProcessEvidence, RiskDelta,
RUN_EVIDENCE_VERSION, RUN_SCHEMA,
};
use chrono::Utc;
use ed25519_dalek::SigningKey;
use rand::rngs::OsRng;
fn signing_key() -> SigningKey {
SigningKey::generate(&mut OsRng)
}
fn well_formed_evidence() -> RunEvidence {
let now = Utc::now();
RunEvidence {
schema: RUN_SCHEMA.to_string(),
tsafe_attest_version: RUN_EVIDENCE_VERSION.to_string(),
started_at: now,
finished_at: now,
repo_path: "/tmp/test".to_string(),
repo_commit: None,
command: vec!["true".to_string()],
contract: ContractRef {
path: "tsafe.contract.json".to_string(),
hash: blake3_hash("contract"),
},
environment: EnvironmentEvidence {
parent_env_count: 3,
child_env_count: 1,
removed_env_count: 2,
safe_baseline_injected: vec!["PATH".to_string()],
secrets_injected: vec![InjectedSecretEvidence {
name: "DATABASE_URL".to_string(),
source: "literal://demo/DATABASE_URL".to_string(),
hash: blake3_hash("db"),
redacted_value: "p***".to_string(),
required: true,
}],
sensitive_env_denied: vec![DeniedSensitiveEnvEvidence {
name: "AWS_SECRET_ACCESS_KEY".to_string(),
hash: blake3_hash("aws"),
reason: "test".to_string(),
}],
},
process: ProcessEvidence {
pid: 1,
exit_code: 0,
duration_ms: 1,
cwd: "/tmp".to_string(),
},
machine: MachineEvidence {
hostname_hash: blake3_hash("host"),
username_hash: blake3_hash("user"),
os: "linux".to_string(),
arch: "x86_64".to_string(),
},
result: EnforcementResult {
contract_enforced: true,
violations: Vec::new(),
risk_delta: RiskDelta {
before_score: 10,
after_score: 0,
},
},
signature: None,
}
}
#[test]
fn canonical_bytes_strips_signature_field() {
let mut signed = well_formed_evidence();
signed.signature = Some(SignaturePayload {
algo: "ed25519".into(),
pubkey: "AAAA".into(),
sig: "BBBB".into(),
});
let unsigned = {
let mut e = signed.clone();
e.signature = None;
e
};
assert_eq!(
canonical_bytes(&signed),
canonical_bytes(&unsigned),
"canonical_bytes must be identical regardless of signature presence"
);
}
#[test]
fn canonical_bytes_object_keys_are_sorted() {
let bytes = canonical_bytes(&well_formed_evidence());
let text = String::from_utf8(bytes).unwrap();
assert!(
text.starts_with(r#"{"command":"#),
"canonical encoding should start with sorted keys; got prefix {}",
&text[..text.len().min(40)]
);
}
#[test]
fn canonical_bytes_contains_no_whitespace() {
let bytes = canonical_bytes(&well_formed_evidence());
for &b in &bytes {
assert!(
!matches!(b, b' ' | b'\n' | b'\r' | b'\t'),
"canonical encoding contained whitespace byte 0x{b:02x}"
);
}
}
#[test]
fn sign_then_verify_roundtrips() {
let evidence = well_formed_evidence();
let key = signing_key();
let signed = sign_evidence(&evidence, &key).expect("sign");
assert!(
signed.evidence.signature.is_some(),
"signed evidence must carry a signature payload"
);
verify_evidence(&signed, &key.verifying_key()).expect("verify");
}
#[test]
fn verify_signed_evidence_uses_embedded_pubkey() {
let evidence = well_formed_evidence();
let key = signing_key();
let signed = sign_evidence(&evidence, &key).expect("sign");
verify_signed_evidence(&signed).expect("verify TOFU");
}
#[test]
fn tampered_evidence_fails_verification() {
let evidence = well_formed_evidence();
let key = signing_key();
let mut signed = sign_evidence(&evidence, &key).expect("sign");
signed.evidence.process.exit_code = 1;
let result = verify_evidence(&signed, &key.verifying_key());
assert!(matches!(result, Err(VerifyError::SignatureMismatch(_))));
}
#[test]
fn wrong_pubkey_fails_verification() {
let evidence = well_formed_evidence();
let signed = sign_evidence(&evidence, &signing_key()).expect("sign");
let wrong = signing_key().verifying_key();
let result = verify_evidence(&signed, &wrong);
assert!(matches!(result, Err(VerifyError::SignatureMismatch(_))));
}
#[test]
fn unsupported_algorithm_is_rejected() {
let evidence = well_formed_evidence();
let mut signed = sign_evidence(&evidence, &signing_key()).expect("sign");
signed.signature.algo = "ecdsa-p256".into();
signed.evidence.signature.as_mut().unwrap().algo = "ecdsa-p256".into();
let key = signing_key();
let result = verify_evidence(&signed, &key.verifying_key());
assert!(matches!(result, Err(VerifyError::UnsupportedAlgorithm(_))));
}
#[test]
fn signed_from_run_evidence_round_trips_through_json() {
let evidence = well_formed_evidence();
let key = signing_key();
let signed = sign_evidence(&evidence, &key).expect("sign");
let json =
serde_json::to_string(&signed.evidence).expect("serialise signed RunEvidence to JSON");
let parsed: RunEvidence = serde_json::from_str(&json).expect("deserialise");
let reconstituted = signed_from_run_evidence(parsed).expect("signature field present");
verify_evidence(&reconstituted, &key.verifying_key())
.expect("signature survives JSON round-trip");
}
#[test]
fn unsigned_evidence_round_trips_through_json_without_signature() {
let evidence = well_formed_evidence();
let json = serde_json::to_string(&evidence).expect("serialise unsigned RunEvidence");
let parsed: RunEvidence = serde_json::from_str(&json).expect("deserialise unsigned");
assert!(parsed.signature.is_none());
assert!(signed_from_run_evidence(parsed).is_none());
}
#[test]
fn signed_payload_pubkey_decodes_to_thirty_two_bytes() {
let evidence = well_formed_evidence();
let key = signing_key();
let signed = sign_evidence(&evidence, &key).expect("sign");
let bytes = URL_SAFE_NO_PAD.decode(&signed.signature.pubkey).unwrap();
assert_eq!(bytes.len(), ed25519_dalek::PUBLIC_KEY_LENGTH);
}
#[test]
fn signed_payload_sig_decodes_to_sixty_four_bytes() {
let evidence = well_formed_evidence();
let signed = sign_evidence(&evidence, &signing_key()).expect("sign");
let bytes = URL_SAFE_NO_PAD.decode(&signed.signature.sig).unwrap();
assert_eq!(bytes.len(), SIGNATURE_LENGTH);
}
#[test]
fn decode_verifying_key_round_trips_with_sign_evidence() {
let evidence = well_formed_evidence();
let key = signing_key();
let signed = sign_evidence(&evidence, &key).expect("sign");
let decoded = decode_verifying_key(&signed.signature.pubkey).expect("decode pubkey");
assert_eq!(decoded.as_bytes(), key.verifying_key().as_bytes());
}
}