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