tsafe_core/sign.rs
1//! Ed25519 signing of `RunEvidence` artifacts — Phase 5 of the
2//! algol→tsafe migration.
3//!
4//! # Why this module exists
5//!
6//! Phase 4 lifted the env-injection enforcement pipeline that emits
7//! `RunEvidence` (see [`crate::run_evidence`]). The artifact carries
8//! BLAKE3 fingerprints of every observed input (contract bytes, injected
9//! secrets, denied env names, host identity) but Phase 4 explicitly
10//! deferred cryptographic authorship attestation — i.e. *who* produced
11//! the artifact — to Phase 5.
12//!
13//! Phase 5 closes that gap by signing the artifact with an Ed25519
14//! keypair held in the tsafe keyring entry under the `tsafe-attest`
15//! purpose. The signature lives on the artifact itself
16//! ([`crate::run_evidence::RunEvidence::signature`]) so the wire shape
17//! stays a single object and old readers parse the unsigned-equivalent
18//! payload via `serde(default)`.
19//!
20//! # Scope (Phase 5, intentional)
21//!
22//! - Pure-Rust Ed25519 via [`ed25519_dalek`]; no substrate dep yet.
23//! Future phases may refactor the canonical encoder + sign path into a
24//! reusable cohort substrate (see substrate-design.md §1.2 theme 3),
25//! but for now this is the native implementation.
26//! - JCS-style canonical encoding (sorted object keys, no whitespace,
27//! no insignificant fractional zeros on integers) — sufficient for
28//! `RunEvidence`'s flat JSON shape today; a strict RFC 8785 encoder is
29//! over-engineered for a struct with no floating-point or duplicate-
30//! keyed fields.
31//! - A domain-tag prefix is prepended before signing to prevent
32//! cross-protocol forgery if the same key is ever used for another
33//! tsafe artifact. The tag is selected from the artifact's schema via
34//! [`domain_tag_for`]: v2 `axiom.receipt.v1` receipts sign under
35//! [`DOMAIN_TAG_V2`]; legacy `tsafe.run.v1` / `algol.run.v1` receipts
36//! verify under the v1 [`DOMAIN_TAG`] during the read-compat window. A
37//! v1-tag signature does not verify under the v2 tag and vice versa.
38//!
39//! # Out of scope (deferred)
40//!
41//! - PKI / pubkey-trust management. Verification uses the pubkey
42//! embedded in [`SignaturePayload`] (TOFU). Operators are expected to
43//! pin/verify the pubkey out of band post-launch.
44//! - Post-quantum signatures. The domain tag includes the
45//! `run_evidence.v1` version so a future PQ family can ship under
46//! `run_evidence.v2`.
47//! - Detached signatures. Phase 5 ships attached signatures only
48//! (`RunEvidence.signature = Some(..)`); detached signing is a future
49//! substrate concern.
50
51use crate::run_evidence::RunEvidence;
52use base64::engine::general_purpose::URL_SAFE_NO_PAD;
53use base64::Engine as _;
54use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey, SIGNATURE_LENGTH};
55use serde::{Deserialize, Serialize};
56use serde_json::{Map, Value};
57use thiserror::Error;
58
59/// v1 domain-tag prefix prepended to canonical bytes before
60/// [`SigningKey::sign`] for legacy (`tsafe.run.v1` / `algol.run.v1`)
61/// receipts.
62///
63/// Including the schema name + version + a trailing NUL byte makes it
64/// impossible to take an Ed25519 signature produced for some other tsafe
65/// artifact (or a different RunEvidence schema version) and replay it as
66/// a valid v1 signature. Retained for the v2.x read-compat window so a
67/// v2 binary can still verify a receipt signed under v1.
68pub const DOMAIN_TAG: &[u8] = b"tsafe.run_evidence.v1\0";
69
70/// v2 domain-tag prefix for `axiom.receipt.v1` receipts (current).
71///
72/// v2.0.0 mints a fresh domain tag in tandem with the
73/// [`crate::run_evidence::RECEIPT_SCHEMA`] relabel. Because the tag
74/// bytes differ from [`DOMAIN_TAG`], a signature produced under the v1
75/// tag cannot verify under the v2 tag and vice versa — the
76/// cross-protocol forgery guard. The signer/verifier select the tag from
77/// the artifact's parsed schema via [`domain_tag_for`].
78pub const DOMAIN_TAG_V2: &[u8] = b"axiom.receipt.v1\0";
79
80/// Select the signing/verification domain tag for an artifact's schema.
81///
82/// Legacy (`tsafe.run.v1` / `algol.run.v1`) artifacts dispatch to the v1
83/// [`DOMAIN_TAG`]; everything else (canonically `axiom.receipt.v1`)
84/// dispatches to [`DOMAIN_TAG_V2`]. Keying off the parsed schema string
85/// is what binds the signature to its protocol version: re-tagging an
86/// artifact's `schema` field without re-signing flips the expected
87/// domain tag and the signature stops verifying.
88pub fn domain_tag_for(schema: &str) -> &'static [u8] {
89 if crate::run_evidence::is_legacy_run_schema(schema) {
90 DOMAIN_TAG
91 } else {
92 DOMAIN_TAG_V2
93 }
94}
95
96/// Algorithm identifier embedded in [`SignaturePayload::algo`].
97///
98/// Pinned to the only currently-supported value. A future cohort
99/// upgrade to e.g. post-quantum signatures will mint a new domain tag
100/// + algo value in tandem.
101pub const SIG_ALGO_ED25519: &str = "ed25519";
102
103/// Public Ed25519 signature payload carried alongside the signed
104/// [`RunEvidence`] artifact.
105///
106/// All three fields are present on a successfully signed artifact;
107/// absence of the parent `signature` slot on the [`RunEvidence`] itself
108/// is how unsigned (or opted-out) emissions are represented.
109///
110/// `pubkey` and `sig` are base64url (no padding) per ec ADR-0003's
111/// convention for binary fingerprints on the wire — same encoding the
112/// rest of tsafe uses for its hash family.
113#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
114pub struct SignaturePayload {
115 /// Signature algorithm identifier. Always [`SIG_ALGO_ED25519`] in
116 /// Phase 5.
117 pub algo: String,
118 /// Verifying-key bytes, base64url-encoded, no padding (32 bytes
119 /// decoded).
120 pub pubkey: String,
121 /// Ed25519 signature bytes, base64url-encoded, no padding (64 bytes
122 /// decoded).
123 pub sig: String,
124}
125
126/// Convenience wrapper bundling a `RunEvidence` artifact with its
127/// detached-shape [`SignaturePayload`].
128///
129/// This is just a type-safety convenience for callers that need to pass
130/// a guaranteed-signed artifact around in their type system. The
131/// underlying wire-format storage location is
132/// [`RunEvidence::signature`] — both shapes round-trip cleanly to and
133/// from canonical JSON.
134#[derive(Debug, Clone, PartialEq, Eq)]
135pub struct SignedEvidence {
136 /// The signed evidence. Its [`RunEvidence::signature`] field is
137 /// always `Some` on a value returned from [`sign_evidence`].
138 pub evidence: RunEvidence,
139 /// The signature payload, duplicated as a sibling field so callers
140 /// can borrow the signature without unpacking the option on
141 /// `evidence`.
142 pub signature: SignaturePayload,
143}
144
145/// Errors that can arise while producing a signed `RunEvidence`.
146#[derive(Debug, Error)]
147pub enum SignError {
148 /// The artifact failed JSON serialisation before signing. Should
149 /// not happen in practice because `RunEvidence` is `Serialize`-by-
150 /// derive and every field is a JSON-native type, but the error
151 /// path is preserved for completeness.
152 #[error("serialise RunEvidence for signing: {0}")]
153 Serialize(#[from] serde_json::Error),
154}
155
156/// Errors that can arise while verifying a signed `RunEvidence`.
157#[derive(Debug, Error)]
158pub enum VerifyError {
159 /// The artifact had no `signature` field. Distinguished from a
160 /// failed cryptographic verification so callers can choose to
161 /// treat absence as "needs operator action" rather than a hard
162 /// failure.
163 #[error("evidence has no signature field")]
164 SignatureAbsent,
165 /// The signature payload announced an algorithm tsafe does not
166 /// understand. Carries the offending value verbatim so error
167 /// surfaces can report it.
168 #[error("unsupported signature algorithm: {0}")]
169 UnsupportedAlgorithm(String),
170 /// The pubkey or signature bytes failed base64url decoding.
171 #[error("invalid base64url encoding on signature field: {0}")]
172 Base64(#[from] base64::DecodeError),
173 /// The decoded pubkey was not 32 bytes long.
174 #[error("invalid pubkey length: expected 32 bytes, got {0}")]
175 PubkeyLength(usize),
176 /// The decoded signature was not 64 bytes long.
177 #[error("invalid signature length: expected 64 bytes, got {0}")]
178 SignatureLength(usize),
179 /// The verifying key bytes were syntactically well-formed but
180 /// represent a malformed Ed25519 point.
181 #[error("malformed Ed25519 verifying key: {0}")]
182 MalformedKey(ed25519_dalek::ed25519::Error),
183 /// The signature did not verify against the supplied / embedded
184 /// pubkey. This is the canonical "tampered or wrong-key" outcome.
185 #[error("signature verification failed: {0}")]
186 SignatureMismatch(ed25519_dalek::ed25519::Error),
187 /// Serialisation failure while reconstructing the canonical bytes.
188 /// Same caveat as [`SignError::Serialize`] — exists only because
189 /// the underlying API returns a `Result`.
190 #[error("serialise RunEvidence for verification: {0}")]
191 Serialize(#[from] serde_json::Error),
192}
193
194/// Produce the canonical byte representation of a `RunEvidence` for
195/// signing or verification.
196///
197/// The encoding is a JCS-style canonical JSON:
198///
199/// - Top-level object keys are sorted lexicographically.
200/// - Nested object keys are sorted lexicographically (recursively).
201/// - The `signature` field is stripped before encoding so a fresh
202/// signature can be computed on the just-signed shape.
203/// - No whitespace appears anywhere in the output.
204/// - Integers, booleans, nulls, and strings serialise via `serde_json`
205/// defaults; `RunEvidence` does not contain floats or duplicate
206/// keys, so the JCS edge cases for those are not exercised here.
207///
208/// The domain-tag prefix [`DOMAIN_TAG`] is **not** included in the
209/// return value — callers prepend it inside [`sign_evidence`] /
210/// [`verify_evidence`]. Exposing the unprefixed canonical bytes makes
211/// the function usable as a regression-test surface and a future
212/// substrate hand-off point.
213pub fn canonical_bytes(evidence: &RunEvidence) -> Vec<u8> {
214 // Re-route through `serde_json::Value` so we can walk the tree and
215 // sort object keys. Using `to_value` rather than `to_string` keeps
216 // numbers as numbers (no precision conversion) and lets us strip
217 // the `signature` field before serialising the canonical form.
218 let mut value = serde_json::to_value(evidence)
219 .expect("RunEvidence::serialize is infallible (derive-Serialize on JSON-native fields)");
220 if let Value::Object(map) = &mut value {
221 map.remove("signature");
222 }
223 let canonical = canonicalise(value);
224 // `to_string` on a sorted Value already emits compact JSON
225 // (no whitespace between tokens). No second pass needed.
226 serde_json::to_string(&canonical)
227 .expect("canonical Value is JSON-native by construction")
228 .into_bytes()
229}
230
231/// Recursively canonicalise object keys.
232///
233/// `serde_json::Value::Object` is backed by a `Map` which preserves
234/// insertion order (when the `preserve_order` feature is off, the
235/// default in tsafe, it is backed by `BTreeMap` which already sorts
236/// keys — but we re-sort explicitly so the behaviour stays correct
237/// regardless of which `serde_json` feature flags downstream consumers
238/// enable).
239fn canonicalise(value: Value) -> Value {
240 match value {
241 Value::Object(map) => {
242 let mut entries: Vec<(String, Value)> = map.into_iter().collect();
243 entries.sort_by(|a, b| a.0.cmp(&b.0));
244 let mut sorted = Map::new();
245 for (key, child) in entries {
246 sorted.insert(key, canonicalise(child));
247 }
248 Value::Object(sorted)
249 }
250 Value::Array(items) => Value::Array(items.into_iter().map(canonicalise).collect()),
251 other => other,
252 }
253}
254
255/// Sign a `RunEvidence` with the supplied [`SigningKey`].
256///
257/// Returns a [`SignedEvidence`] whose embedded `evidence.signature` is
258/// `Some(..)` so callers can serialise it directly to a single JSON
259/// object.
260///
261/// The signing pipeline:
262///
263/// 1. Strip any pre-existing `signature` field via [`canonical_bytes`].
264/// 2. Prepend the domain tag [`DOMAIN_TAG`].
265/// 3. Sign the resulting byte sequence with Ed25519.
266/// 4. Embed the verifying-key bytes + signature in a fresh
267/// [`SignaturePayload`], install it on the returned `RunEvidence`.
268///
269/// The function is total over well-formed input. The `Result` return
270/// type is preserved so a future change of canonical encoder (e.g. to a
271/// substrate library that can fail on duplicate keys) does not require
272/// a breaking API change.
273pub fn sign_evidence(
274 evidence: &RunEvidence,
275 signing_key: &SigningKey,
276) -> Result<SignedEvidence, SignError> {
277 let canonical = canonical_bytes(evidence);
278 let tag = domain_tag_for(&evidence.schema);
279 let mut to_sign = Vec::with_capacity(tag.len() + canonical.len());
280 to_sign.extend_from_slice(tag);
281 to_sign.extend_from_slice(&canonical);
282 let signature = signing_key.sign(&to_sign);
283 let verifying_key = signing_key.verifying_key();
284
285 let payload = SignaturePayload {
286 algo: SIG_ALGO_ED25519.to_string(),
287 pubkey: URL_SAFE_NO_PAD.encode(verifying_key.as_bytes()),
288 sig: URL_SAFE_NO_PAD.encode(signature.to_bytes()),
289 };
290 let mut signed_evidence = evidence.clone();
291 signed_evidence.signature = Some(payload.clone());
292 Ok(SignedEvidence {
293 evidence: signed_evidence,
294 signature: payload,
295 })
296}
297
298/// Verify a signed `RunEvidence` against the supplied [`VerifyingKey`].
299///
300/// Returns `Ok(())` if the signature is valid for the canonical bytes
301/// of the evidence (with the `signature` field stripped) under the
302/// domain tag [`DOMAIN_TAG`]. Returns a typed [`VerifyError`]
303/// otherwise.
304///
305/// Most callers will use [`verify_signed_evidence`] which derives the
306/// verifying key from the artifact itself (TOFU) — this lower-level
307/// function exists so an operator-supplied pubkey can be used to
308/// short-circuit the embedded pubkey, useful for out-of-band trust
309/// pinning.
310pub fn verify_evidence(
311 signed: &SignedEvidence,
312 verifying_key: &VerifyingKey,
313) -> Result<(), VerifyError> {
314 if signed.signature.algo != SIG_ALGO_ED25519 {
315 return Err(VerifyError::UnsupportedAlgorithm(
316 signed.signature.algo.clone(),
317 ));
318 }
319 let sig_bytes = URL_SAFE_NO_PAD.decode(&signed.signature.sig)?;
320 if sig_bytes.len() != SIGNATURE_LENGTH {
321 return Err(VerifyError::SignatureLength(sig_bytes.len()));
322 }
323 let sig_array: [u8; SIGNATURE_LENGTH] = sig_bytes
324 .as_slice()
325 .try_into()
326 .expect("length-checked above");
327 let signature = Signature::from_bytes(&sig_array);
328
329 let canonical = canonical_bytes(&signed.evidence);
330 let tag = domain_tag_for(&signed.evidence.schema);
331 let mut to_verify = Vec::with_capacity(tag.len() + canonical.len());
332 to_verify.extend_from_slice(tag);
333 to_verify.extend_from_slice(&canonical);
334
335 verifying_key
336 .verify(&to_verify, &signature)
337 .map_err(VerifyError::SignatureMismatch)
338}
339
340/// Verify a signed `RunEvidence` using the pubkey embedded in the
341/// artifact itself (TOFU — Trust-On-First-Use).
342///
343/// This is the "no operator-supplied pubkey" path: tsafe trusts the
344/// artifact's own claim of authorship. Operators MUST pin the pubkey
345/// out of band before relying on the signature for any security
346/// purpose; this routine guarantees only that the artifact was signed
347/// by whoever owns the embedded key, not that the embedded key is
348/// trustworthy.
349pub fn verify_signed_evidence(signed: &SignedEvidence) -> Result<(), VerifyError> {
350 let key = decode_verifying_key(&signed.signature.pubkey)?;
351 verify_evidence(signed, &key)
352}
353
354/// Decode a base64url verifying-key string into an Ed25519
355/// [`VerifyingKey`].
356///
357/// Exposed for the CLI surface (`tsafe attest verify --pubkey <key>`),
358/// which receives the pubkey from the operator as a base64url string.
359pub fn decode_verifying_key(pubkey_b64url: &str) -> Result<VerifyingKey, VerifyError> {
360 let bytes = URL_SAFE_NO_PAD.decode(pubkey_b64url)?;
361 if bytes.len() != ed25519_dalek::PUBLIC_KEY_LENGTH {
362 return Err(VerifyError::PubkeyLength(bytes.len()));
363 }
364 let array: [u8; ed25519_dalek::PUBLIC_KEY_LENGTH] =
365 bytes.as_slice().try_into().expect("length-checked above");
366 VerifyingKey::from_bytes(&array).map_err(VerifyError::MalformedKey)
367}
368
369/// Reconstruct a [`SignedEvidence`] from a `RunEvidence` whose
370/// `signature` field is `Some(..)`.
371///
372/// Convenience for callers that have already deserialised a JSON
373/// artifact and want the type-safe split.
374pub fn signed_from_run_evidence(evidence: RunEvidence) -> Option<SignedEvidence> {
375 let signature = evidence.signature.clone()?;
376 Some(SignedEvidence {
377 evidence,
378 signature,
379 })
380}
381
382#[cfg(test)]
383mod tests {
384 use super::*;
385 use crate::run_evidence::{
386 blake3_hash, ContractRef, DeniedSensitiveEnvEvidence, EnforcementResult,
387 EnvironmentEvidence, InjectedSecretEvidence, MachineEvidence, ProcessEvidence, RiskDelta,
388 LEGACY_RUN_SCHEMA, RECEIPT_SCHEMA, RUN_EVIDENCE_VERSION, RUN_SCHEMA,
389 };
390 use chrono::Utc;
391 use ed25519_dalek::SigningKey;
392 use rand::rngs::OsRng;
393
394 fn signing_key() -> SigningKey {
395 SigningKey::generate(&mut OsRng)
396 }
397
398 fn well_formed_evidence() -> RunEvidence {
399 let now = Utc::now();
400 RunEvidence {
401 schema: RECEIPT_SCHEMA.to_string(),
402 tsafe_attest_version: RUN_EVIDENCE_VERSION.to_string(),
403 started_at: now,
404 finished_at: now,
405 repo_path: "/tmp/test".to_string(),
406 repo_commit: None,
407 command: vec!["true".to_string()],
408 contract: ContractRef {
409 path: "tsafe.contract.json".to_string(),
410 hash: blake3_hash("contract"),
411 },
412 environment: EnvironmentEvidence {
413 parent_env_count: 3,
414 child_env_count: 1,
415 removed_env_count: 2,
416 safe_baseline_injected: vec!["PATH".to_string()],
417 secrets_injected: vec![InjectedSecretEvidence {
418 name: "DATABASE_URL".to_string(),
419 source: "literal://demo/DATABASE_URL".to_string(),
420 hash: blake3_hash("db"),
421 redacted_value: "p***".to_string(),
422 required: true,
423 }],
424 sensitive_env_denied: vec![DeniedSensitiveEnvEvidence {
425 name: "AWS_SECRET_ACCESS_KEY".to_string(),
426 hash: blake3_hash("aws"),
427 reason: "test".to_string(),
428 }],
429 },
430 process: ProcessEvidence {
431 pid: 1,
432 exit_code: 0,
433 duration_ms: 1,
434 cwd: "/tmp".to_string(),
435 },
436 machine: MachineEvidence {
437 hostname_hash: blake3_hash("host"),
438 username_hash: blake3_hash("user"),
439 os: "linux".to_string(),
440 arch: "x86_64".to_string(),
441 },
442 result: EnforcementResult {
443 contract_enforced: true,
444 violations: Vec::new(),
445 risk_delta: RiskDelta {
446 before_score: 10,
447 after_score: 0,
448 },
449 },
450 signature: None,
451 }
452 }
453
454 #[test]
455 fn canonical_bytes_strips_signature_field() {
456 let mut signed = well_formed_evidence();
457 signed.signature = Some(SignaturePayload {
458 algo: "ed25519".into(),
459 pubkey: "AAAA".into(),
460 sig: "BBBB".into(),
461 });
462 let unsigned = {
463 let mut e = signed.clone();
464 e.signature = None;
465 e
466 };
467 assert_eq!(
468 canonical_bytes(&signed),
469 canonical_bytes(&unsigned),
470 "canonical_bytes must be identical regardless of signature presence"
471 );
472 }
473
474 #[test]
475 fn canonical_bytes_object_keys_are_sorted() {
476 let bytes = canonical_bytes(&well_formed_evidence());
477 let text = String::from_utf8(bytes).unwrap();
478 // The top-level object must start with a sorted key.
479 // "command" < "contract" < "environment" < ... so the first
480 // field after the opening brace is `command`.
481 assert!(
482 text.starts_with(r#"{"command":"#),
483 "canonical encoding should start with sorted keys; got prefix {}",
484 &text[..text.len().min(40)]
485 );
486 }
487
488 #[test]
489 fn canonical_bytes_contains_no_whitespace() {
490 let bytes = canonical_bytes(&well_formed_evidence());
491 for &b in &bytes {
492 assert!(
493 !matches!(b, b' ' | b'\n' | b'\r' | b'\t'),
494 "canonical encoding contained whitespace byte 0x{b:02x}"
495 );
496 }
497 }
498
499 #[test]
500 fn sign_then_verify_roundtrips() {
501 let evidence = well_formed_evidence();
502 let key = signing_key();
503 let signed = sign_evidence(&evidence, &key).expect("sign");
504 assert!(
505 signed.evidence.signature.is_some(),
506 "signed evidence must carry a signature payload"
507 );
508 verify_evidence(&signed, &key.verifying_key()).expect("verify");
509 }
510
511 #[test]
512 fn verify_signed_evidence_uses_embedded_pubkey() {
513 let evidence = well_formed_evidence();
514 let key = signing_key();
515 let signed = sign_evidence(&evidence, &key).expect("sign");
516 verify_signed_evidence(&signed).expect("verify TOFU");
517 }
518
519 #[test]
520 fn tampered_evidence_fails_verification() {
521 let evidence = well_formed_evidence();
522 let key = signing_key();
523 let mut signed = sign_evidence(&evidence, &key).expect("sign");
524 // Mutate a field the canonical encoder includes.
525 signed.evidence.process.exit_code = 1;
526 let result = verify_evidence(&signed, &key.verifying_key());
527 assert!(matches!(result, Err(VerifyError::SignatureMismatch(_))));
528 }
529
530 #[test]
531 fn wrong_pubkey_fails_verification() {
532 let evidence = well_formed_evidence();
533 let signed = sign_evidence(&evidence, &signing_key()).expect("sign");
534 let wrong = signing_key().verifying_key();
535 let result = verify_evidence(&signed, &wrong);
536 assert!(matches!(result, Err(VerifyError::SignatureMismatch(_))));
537 }
538
539 #[test]
540 fn unsupported_algorithm_is_rejected() {
541 let evidence = well_formed_evidence();
542 let mut signed = sign_evidence(&evidence, &signing_key()).expect("sign");
543 signed.signature.algo = "ecdsa-p256".into();
544 signed.evidence.signature.as_mut().unwrap().algo = "ecdsa-p256".into();
545 let key = signing_key();
546 let result = verify_evidence(&signed, &key.verifying_key());
547 assert!(matches!(result, Err(VerifyError::UnsupportedAlgorithm(_))));
548 }
549
550 #[test]
551 fn signed_from_run_evidence_round_trips_through_json() {
552 let evidence = well_formed_evidence();
553 let key = signing_key();
554 let signed = sign_evidence(&evidence, &key).expect("sign");
555 let json =
556 serde_json::to_string(&signed.evidence).expect("serialise signed RunEvidence to JSON");
557 let parsed: RunEvidence = serde_json::from_str(&json).expect("deserialise");
558 let reconstituted = signed_from_run_evidence(parsed).expect("signature field present");
559 verify_evidence(&reconstituted, &key.verifying_key())
560 .expect("signature survives JSON round-trip");
561 }
562
563 #[test]
564 fn unsigned_evidence_round_trips_through_json_without_signature() {
565 // Backward-compat: legacy artifacts that have no `signature` field
566 // must continue to parse via `serde(default)`.
567 let evidence = well_formed_evidence();
568 let json = serde_json::to_string(&evidence).expect("serialise unsigned RunEvidence");
569 let parsed: RunEvidence = serde_json::from_str(&json).expect("deserialise unsigned");
570 assert!(parsed.signature.is_none());
571 assert!(signed_from_run_evidence(parsed).is_none());
572 }
573
574 #[test]
575 fn signed_payload_pubkey_decodes_to_thirty_two_bytes() {
576 let evidence = well_formed_evidence();
577 let key = signing_key();
578 let signed = sign_evidence(&evidence, &key).expect("sign");
579 let bytes = URL_SAFE_NO_PAD.decode(&signed.signature.pubkey).unwrap();
580 assert_eq!(bytes.len(), ed25519_dalek::PUBLIC_KEY_LENGTH);
581 }
582
583 #[test]
584 fn signed_payload_sig_decodes_to_sixty_four_bytes() {
585 let evidence = well_formed_evidence();
586 let signed = sign_evidence(&evidence, &signing_key()).expect("sign");
587 let bytes = URL_SAFE_NO_PAD.decode(&signed.signature.sig).unwrap();
588 assert_eq!(bytes.len(), SIGNATURE_LENGTH);
589 }
590
591 #[test]
592 fn decode_verifying_key_round_trips_with_sign_evidence() {
593 let evidence = well_formed_evidence();
594 let key = signing_key();
595 let signed = sign_evidence(&evidence, &key).expect("sign");
596 let decoded = decode_verifying_key(&signed.signature.pubkey).expect("decode pubkey");
597 assert_eq!(decoded.as_bytes(), key.verifying_key().as_bytes());
598 }
599
600 /// THE highest-value guard: a signature minted under the v1 domain
601 /// tag must NOT verify under the v2 tag.
602 ///
603 /// An attacker who holds a legacy `tsafe.run.v1` receipt + signature
604 /// must not be able to re-label the `schema` field to
605 /// `axiom.receipt.v1` and have the (unchanged) signature accepted.
606 /// Because the verifier selects its domain tag from the parsed
607 /// schema string, flipping the label flips the expected tag and the
608 /// v1 signature ceases to verify. The same `SignatureMismatch`
609 /// outcome occurs in reverse (a v2 signature re-labelled to v1).
610 #[test]
611 fn v1_tag_signature_does_not_verify_under_v2_tag() {
612 let key = signing_key();
613
614 // Sign a genuine v1 receipt (legacy schema -> v1 domain tag).
615 let mut v1_evidence = well_formed_evidence();
616 v1_evidence.schema = RUN_SCHEMA.to_string();
617 let mut signed_v1 = sign_evidence(&v1_evidence, &key).expect("sign v1");
618 // The v1 signature verifies under the v1 tag (sanity).
619 verify_evidence(&signed_v1, &key.verifying_key()).expect("v1 verifies under v1 tag");
620
621 // Cross-protocol forgery attempt: re-label the schema to v2 so the
622 // verifier reaches for DOMAIN_TAG_V2 against an unchanged v1
623 // signature. Must fail closed.
624 signed_v1.evidence.schema = RECEIPT_SCHEMA.to_string();
625 let result = verify_evidence(&signed_v1, &key.verifying_key());
626 assert!(
627 matches!(result, Err(VerifyError::SignatureMismatch(_))),
628 "v1-tag signature must NOT verify once schema is re-labelled to v2; got {result:?}"
629 );
630
631 // And the symmetric direction: a v2 signature re-labelled to v1.
632 let v2_evidence = well_formed_evidence(); // RECEIPT_SCHEMA
633 let mut signed_v2 = sign_evidence(&v2_evidence, &key).expect("sign v2");
634 verify_evidence(&signed_v2, &key.verifying_key()).expect("v2 verifies under v2 tag");
635 signed_v2.evidence.schema = RUN_SCHEMA.to_string();
636 let result = verify_evidence(&signed_v2, &key.verifying_key());
637 assert!(
638 matches!(result, Err(VerifyError::SignatureMismatch(_))),
639 "v2-tag signature must NOT verify once schema is re-labelled to v1; got {result:?}"
640 );
641 }
642
643 /// v1-compat READ regression: a v2 binary must still verify an OLD
644 /// receipt that was signed under the v1 domain tag with a legacy
645 /// schema label. Proves the read-compat window is intact and the
646 /// relabel did not break existing audit trails.
647 #[test]
648 fn v2_binary_verifies_legacy_v1_receipt() {
649 let key = signing_key();
650 for schema in [RUN_SCHEMA, LEGACY_RUN_SCHEMA] {
651 let mut legacy = well_formed_evidence();
652 legacy.schema = schema.to_string();
653 let signed = sign_evidence(&legacy, &key).expect("sign legacy");
654 // TOFU path (embedded pubkey) and explicit-key path both verify.
655 verify_signed_evidence(&signed)
656 .unwrap_or_else(|err| panic!("{schema} TOFU verify failed: {err:?}"));
657 verify_evidence(&signed, &key.verifying_key())
658 .unwrap_or_else(|err| panic!("{schema} explicit-key verify failed: {err:?}"));
659 }
660 }
661
662 #[test]
663 fn domain_tag_dispatch_matches_schema() {
664 assert_eq!(domain_tag_for(RECEIPT_SCHEMA), DOMAIN_TAG_V2);
665 assert_eq!(domain_tag_for(RUN_SCHEMA), DOMAIN_TAG);
666 assert_eq!(domain_tag_for(LEGACY_RUN_SCHEMA), DOMAIN_TAG);
667 assert_ne!(DOMAIN_TAG, DOMAIN_TAG_V2);
668 }
669}