tsafe-core 1.1.0

Core runtime engine for tsafe — encrypted credential storage, process injection contracts, audit log, RBAC
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
//! Ed25519 signing of `RunEvidence` artifacts — Phase 5 of the
//! algol→tsafe migration.
//!
//! # Why this module exists
//!
//! Phase 4 lifted the env-injection enforcement pipeline that emits
//! `RunEvidence` (see [`crate::run_evidence`]). The artifact carries
//! BLAKE3 fingerprints of every observed input (contract bytes, injected
//! secrets, denied env names, host identity) but Phase 4 explicitly
//! deferred cryptographic authorship attestation — i.e. *who* produced
//! the artifact — to Phase 5.
//!
//! Phase 5 closes that gap by signing the artifact with an Ed25519
//! keypair held in the tsafe keyring entry under the `tsafe-attest`
//! purpose. The signature lives on the artifact itself
//! ([`crate::run_evidence::RunEvidence::signature`]) so the wire shape
//! stays a single object and old readers parse the unsigned-equivalent
//! payload via `serde(default)`.
//!
//! # Scope (Phase 5, intentional)
//!
//! - Pure-Rust Ed25519 via [`ed25519_dalek`]; no substrate dep yet.
//!   Future phases may refactor the canonical encoder + sign path into a
//!   reusable cohort substrate (see substrate-design.md §1.2 theme 3),
//!   but for now this is the native implementation.
//! - JCS-style canonical encoding (sorted object keys, no whitespace,
//!   no insignificant fractional zeros on integers) — sufficient for
//!   `RunEvidence`'s flat JSON shape today; a strict RFC 8785 encoder is
//!   over-engineered for a struct with no floating-point or duplicate-
//!   keyed fields.
//! - Domain-tag prefix `tsafe.run_evidence.v1\0` prepended before
//!   signing. Prevents cross-protocol forgery if the same key is ever
//!   used for another tsafe artifact in a later phase.
//!
//! # Out of scope (deferred)
//!
//! - PKI / pubkey-trust management. Verification uses the pubkey
//!   embedded in [`SignaturePayload`] (TOFU). Operators are expected to
//!   pin/verify the pubkey out of band post-launch.
//! - Post-quantum signatures. The domain tag includes the
//!   `run_evidence.v1` version so a future PQ family can ship under
//!   `run_evidence.v2`.
//! - Detached signatures. Phase 5 ships attached signatures only
//!   (`RunEvidence.signature = Some(..)`); detached signing is a future
//!   substrate concern.

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;

/// Domain-tag prefix prepended to canonical bytes before [`SigningKey::sign`].
///
/// Including the schema name + version + a trailing NUL byte makes it
/// impossible to take an Ed25519 signature produced for some other tsafe
/// artifact (or a different RunEvidence schema version) and replay it as
/// a valid `tsafe.run_evidence.v1` signature.
pub const DOMAIN_TAG: &[u8] = b"tsafe.run_evidence.v1\0";

/// Algorithm identifier embedded in [`SignaturePayload::algo`].
///
/// Pinned to the only currently-supported value. A future cohort
/// upgrade to e.g. post-quantum signatures will mint a new domain tag
/// + algo value in tandem.
pub const SIG_ALGO_ED25519: &str = "ed25519";

/// Public Ed25519 signature payload carried alongside the signed
/// [`RunEvidence`] artifact.
///
/// All three fields are present on a successfully signed artifact;
/// absence of the parent `signature` slot on the [`RunEvidence`] itself
/// is how unsigned (or opted-out) emissions are represented.
///
/// `pubkey` and `sig` are base64url (no padding) per ec ADR-0003's
/// convention for binary fingerprints on the wire — same encoding the
/// rest of tsafe uses for its hash family.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct SignaturePayload {
    /// Signature algorithm identifier. Always [`SIG_ALGO_ED25519`] in
    /// Phase 5.
    pub algo: String,
    /// Verifying-key bytes, base64url-encoded, no padding (32 bytes
    /// decoded).
    pub pubkey: String,
    /// Ed25519 signature bytes, base64url-encoded, no padding (64 bytes
    /// decoded).
    pub sig: String,
}

/// Convenience wrapper bundling a `RunEvidence` artifact with its
/// detached-shape [`SignaturePayload`].
///
/// This is just a type-safety convenience for callers that need to pass
/// a guaranteed-signed artifact around in their type system. The
/// underlying wire-format storage location is
/// [`RunEvidence::signature`] — both shapes round-trip cleanly to and
/// from canonical JSON.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SignedEvidence {
    /// The signed evidence. Its [`RunEvidence::signature`] field is
    /// always `Some` on a value returned from [`sign_evidence`].
    pub evidence: RunEvidence,
    /// The signature payload, duplicated as a sibling field so callers
    /// can borrow the signature without unpacking the option on
    /// `evidence`.
    pub signature: SignaturePayload,
}

/// Errors that can arise while producing a signed `RunEvidence`.
#[derive(Debug, Error)]
pub enum SignError {
    /// The artifact failed JSON serialisation before signing. Should
    /// not happen in practice because `RunEvidence` is `Serialize`-by-
    /// derive and every field is a JSON-native type, but the error
    /// path is preserved for completeness.
    #[error("serialise RunEvidence for signing: {0}")]
    Serialize(#[from] serde_json::Error),
}

/// Errors that can arise while verifying a signed `RunEvidence`.
#[derive(Debug, Error)]
pub enum VerifyError {
    /// The artifact had no `signature` field. Distinguished from a
    /// failed cryptographic verification so callers can choose to
    /// treat absence as "needs operator action" rather than a hard
    /// failure.
    #[error("evidence has no signature field")]
    SignatureAbsent,
    /// The signature payload announced an algorithm tsafe does not
    /// understand. Carries the offending value verbatim so error
    /// surfaces can report it.
    #[error("unsupported signature algorithm: {0}")]
    UnsupportedAlgorithm(String),
    /// The pubkey or signature bytes failed base64url decoding.
    #[error("invalid base64url encoding on signature field: {0}")]
    Base64(#[from] base64::DecodeError),
    /// The decoded pubkey was not 32 bytes long.
    #[error("invalid pubkey length: expected 32 bytes, got {0}")]
    PubkeyLength(usize),
    /// The decoded signature was not 64 bytes long.
    #[error("invalid signature length: expected 64 bytes, got {0}")]
    SignatureLength(usize),
    /// The verifying key bytes were syntactically well-formed but
    /// represent a malformed Ed25519 point.
    #[error("malformed Ed25519 verifying key: {0}")]
    MalformedKey(ed25519_dalek::ed25519::Error),
    /// The signature did not verify against the supplied / embedded
    /// pubkey. This is the canonical "tampered or wrong-key" outcome.
    #[error("signature verification failed: {0}")]
    SignatureMismatch(ed25519_dalek::ed25519::Error),
    /// Serialisation failure while reconstructing the canonical bytes.
    /// Same caveat as [`SignError::Serialize`] — exists only because
    /// the underlying API returns a `Result`.
    #[error("serialise RunEvidence for verification: {0}")]
    Serialize(#[from] serde_json::Error),
}

/// Produce the canonical byte representation of a `RunEvidence` for
/// signing or verification.
///
/// The encoding is a JCS-style canonical JSON:
///
/// - Top-level object keys are sorted lexicographically.
/// - Nested object keys are sorted lexicographically (recursively).
/// - The `signature` field is stripped before encoding so a fresh
///   signature can be computed on the just-signed shape.
/// - No whitespace appears anywhere in the output.
/// - Integers, booleans, nulls, and strings serialise via `serde_json`
///   defaults; `RunEvidence` does not contain floats or duplicate
///   keys, so the JCS edge cases for those are not exercised here.
///
/// The domain-tag prefix [`DOMAIN_TAG`] is **not** included in the
/// return value — callers prepend it inside [`sign_evidence`] /
/// [`verify_evidence`]. Exposing the unprefixed canonical bytes makes
/// the function usable as a regression-test surface and a future
/// substrate hand-off point.
pub fn canonical_bytes(evidence: &RunEvidence) -> Vec<u8> {
    // Re-route through `serde_json::Value` so we can walk the tree and
    // sort object keys. Using `to_value` rather than `to_string` keeps
    // numbers as numbers (no precision conversion) and lets us strip
    // the `signature` field before serialising the canonical form.
    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);
    // `to_string` on a sorted Value already emits compact JSON
    // (no whitespace between tokens). No second pass needed.
    serde_json::to_string(&canonical)
        .expect("canonical Value is JSON-native by construction")
        .into_bytes()
}

/// Recursively canonicalise object keys.
///
/// `serde_json::Value::Object` is backed by a `Map` which preserves
/// insertion order (when the `preserve_order` feature is off, the
/// default in tsafe, it is backed by `BTreeMap` which already sorts
/// keys — but we re-sort explicitly so the behaviour stays correct
/// regardless of which `serde_json` feature flags downstream consumers
/// enable).
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,
    }
}

/// Sign a `RunEvidence` with the supplied [`SigningKey`].
///
/// Returns a [`SignedEvidence`] whose embedded `evidence.signature` is
/// `Some(..)` so callers can serialise it directly to a single JSON
/// object.
///
/// The signing pipeline:
///
/// 1. Strip any pre-existing `signature` field via [`canonical_bytes`].
/// 2. Prepend the domain tag [`DOMAIN_TAG`].
/// 3. Sign the resulting byte sequence with Ed25519.
/// 4. Embed the verifying-key bytes + signature in a fresh
///    [`SignaturePayload`], install it on the returned `RunEvidence`.
///
/// The function is total over well-formed input. The `Result` return
/// type is preserved so a future change of canonical encoder (e.g. to a
/// substrate library that can fail on duplicate keys) does not require
/// a breaking API change.
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,
    })
}

/// Verify a signed `RunEvidence` against the supplied [`VerifyingKey`].
///
/// Returns `Ok(())` if the signature is valid for the canonical bytes
/// of the evidence (with the `signature` field stripped) under the
/// domain tag [`DOMAIN_TAG`]. Returns a typed [`VerifyError`]
/// otherwise.
///
/// Most callers will use [`verify_signed_evidence`] which derives the
/// verifying key from the artifact itself (TOFU) — this lower-level
/// function exists so an operator-supplied pubkey can be used to
/// short-circuit the embedded pubkey, useful for out-of-band trust
/// pinning.
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)
}

/// Verify a signed `RunEvidence` using the pubkey embedded in the
/// artifact itself (TOFU — Trust-On-First-Use).
///
/// This is the "no operator-supplied pubkey" path: tsafe trusts the
/// artifact's own claim of authorship. Operators MUST pin the pubkey
/// out of band before relying on the signature for any security
/// purpose; this routine guarantees only that the artifact was signed
/// by whoever owns the embedded key, not that the embedded key is
/// trustworthy.
pub fn verify_signed_evidence(signed: &SignedEvidence) -> Result<(), VerifyError> {
    let key = decode_verifying_key(&signed.signature.pubkey)?;
    verify_evidence(signed, &key)
}

/// Decode a base64url verifying-key string into an Ed25519
/// [`VerifyingKey`].
///
/// Exposed for the CLI surface (`tsafe attest verify --pubkey <key>`),
/// which receives the pubkey from the operator as a base64url string.
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)
}

/// Reconstruct a [`SignedEvidence`] from a `RunEvidence` whose
/// `signature` field is `Some(..)`.
///
/// Convenience for callers that have already deserialised a JSON
/// artifact and want the type-safe split.
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();
        // The top-level object must start with a sorted key.
        // "command" < "contract" < "environment" < ... so the first
        // field after the opening brace is `command`.
        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");
        // Mutate a field the canonical encoder includes.
        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() {
        // Backward-compat: legacy artifacts that have no `signature` field
        // must continue to parse via `serde(default)`.
        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());
    }
}