Skip to main content

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}