Skip to main content

chio_kernel/receipt_support/
signing.rs

1use crate::*;
2
3// ---------------------------------------------------------------------------
4// Hybrid receipt signing path
5// ---------------------------------------------------------------------------
6//
7// Mirrors `chio_policy::CryptoFloor` so the kernel boot path can branch on
8// the configured floor without taking a circular dependency on the policy
9// crate (chio-policy depends on chio-kernel). Operators that load a
10// HushSpec policy with `crypto_floor` set translate it into this enum
11// before constructing `KernelConfig`. Threat model row
12// `pq_signature_downgrade` is enforced by validation at this boundary.
13
14/// Minimum cryptographic posture enforced by the kernel-side signing path.
15///
16/// The textual encoding (`allow_classical`, `allow_hybrid`, `pq_required`)
17/// matches the `chio_policy::CryptoFloor` wire form so an operator can lift
18/// a parsed policy floor directly without re-encoding it.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
20pub enum KernelCryptoFloor {
21    /// Accept classical-only Ed25519/P-256/P-384 envelopes. Default.
22    #[default]
23    AllowClassical,
24    /// Accept hybrid classical-plus-ML-DSA-65 envelopes. Requires a PQ key.
25    AllowHybrid,
26    /// Reject classical-only envelopes; require hybrid signing on every
27    /// signed artifact. Requires a PQ key.
28    PqRequired,
29}
30
31impl KernelCryptoFloor {
32    /// Whether the floor permits hybrid envelopes on the wire.
33    #[must_use]
34    pub fn allows_hybrid(&self) -> bool {
35        matches!(self, Self::AllowHybrid | Self::PqRequired)
36    }
37
38    /// Whether the floor mandates hybrid envelopes (rejects classical-only).
39    #[must_use]
40    pub fn requires_pq(&self) -> bool {
41        matches!(self, Self::PqRequired)
42    }
43
44    /// Whether the floor permits classical-only envelopes on the wire.
45    #[must_use]
46    pub fn allows_classical_only(&self) -> bool {
47        matches!(self, Self::AllowClassical | Self::AllowHybrid)
48    }
49
50    /// Stable wire-format identifier for diagnostics.
51    #[must_use]
52    pub fn as_str(&self) -> &'static str {
53        match self {
54            Self::AllowClassical => "allow_classical",
55            Self::AllowHybrid => "allow_hybrid",
56            Self::PqRequired => "pq_required",
57        }
58    }
59}
60
61/// Sign an already-assembled [`ChioReceiptBody`] with an arbitrary
62/// [`SigningBackend`] (classical, hybrid, or PQ), recomputing `content_hash`
63/// inside the trust boundary (WYSIWYS).
64///
65/// Delegates to `chio_kernel_core::sign_receipt`, the production
66/// recompute-and-refuse primitive, so the hybrid signing path is held to the
67/// same WYSIWYS contract as the inline classical path and the mpsc signing
68/// task. `canonical_content` is the exact byte preimage the body's
69/// `content_hash` was derived from (for a value output the RFC 8785 canonical
70/// JSON; for a stream receipt the concatenated per-chunk digest preimage; for an
71/// empty output the literal `null` canonicalization). The signer recomputes
72/// `sha256_hex(canonical_content)` and refuses to sign when it disagrees with
73/// `body.content_hash`, closing the render-A / sign-B forgery on the hybrid path
74/// too rather than leaving it as a caller-hash-trust seam. Receipts produced via
75/// this wrapper are byte-identical to receipts produced via the inline classical
76/// path when the same backend and content are used.
77///
78/// Callers that genuinely cannot carry the content preimage (the thin transport
79/// adapters relaying an already-minted body across an FFI/WASM boundary) must
80/// use `chio_kernel_core::sign_receipt_relaying_trusted_body` directly, which is
81/// the explicit, auditable trusted-relay seam tracked; this
82/// content-bearing kernel wrapper is not that seam.
83///
84/// # Errors
85///
86/// Returns [`KernelError::ReceiptSigningFailed`] when:
87/// - the body's claimed `content_hash` does not match
88///   `sha256_hex(canonical_content)` (fail-closed: render-A / sign-B refused), OR
89/// - the body's `kernel_key` does not match the backend's public key
90///   (fail-closed: the signing path refuses to issue a signature that would not
91///   verify), OR
92/// - canonical signing fails.
93pub fn sign_receipt_body_with_backend(
94    body: ChioReceiptBody,
95    backend: &dyn chio_core::crypto::SigningBackend,
96    canonical_content: &[u8],
97) -> Result<ChioReceipt, KernelError> {
98    chio_kernel_core::sign_receipt(body, backend, canonical_content).map_err(|error| {
99        use chio_kernel_core::ReceiptSigningError;
100        let message = match error {
101            ReceiptSigningError::KernelKeyMismatch => {
102                "kernel signing key does not match receipt body kernel_key".to_string()
103            }
104            // WYSIWYS mismatch: the body claimed a `content_hash` the
105            // signer could not reproduce from `canonical_content`, so the
106            // signature is refused fail-closed.
107            ReceiptSigningError::ContentHashMismatch {
108                recomputed,
109                claimed,
110            } => format!(
111                "receipt content_hash mismatch: body claimed {claimed} but signer \
112                 recomputed {recomputed} over the canonical content (WYSIWYS refused)"
113            ),
114            ReceiptSigningError::SigningFailed(reason) => reason,
115        };
116        KernelError::ReceiptSigningFailed(message)
117    })
118}
119
120// ---------------------------------------------------------------------------
121// CanonicalBytes consumer wiring for the hybrid signing path
122// ---------------------------------------------------------------------------
123//
124// The `Arc<CanonicalBytes>` newtype lives at
125// `chio_core_types::crypto::SharedCanonicalBytes` (already exported from
126// `chio-core-types/src/canonical.rs`). The receipt-signing path under the
127// hybrid backend consumes that newtype directly so the canonical JSON byte
128// buffer is built once, hashed once for the classical half, and signed once
129// for the ML-DSA-65 half. No byte-equivalence shim is required because the
130// newtype is consumed directly rather than reserialized.
131//
132// The helper below returns the signed `ChioReceipt` paired with the
133// `SharedCanonicalBytes` it signed, so downstream consumers (receipt store,
134// federation cosign, lineage anchor) can persist or retransmit the EXACT
135// bytes the signature was computed over without reserializing the body.
136
137/// Receipt produced by the hybrid signing path together with the canonical
138/// byte buffer that was signed.
139///
140/// Returned by [`sign_receipt_body_hybrid_canonical`] so callers can persist
141/// or retransmit the exact bytes the signature was computed over without
142/// reserializing the body. The buffer is wrapped in
143/// [`SharedCanonicalBytes`] (which is `Arc<CanonicalBytes>`) so multiple
144/// consumers downstream can share a single allocation.
145#[derive(Clone, Debug)]
146pub struct SignedHybridReceipt {
147    /// The signed receipt envelope.
148    pub receipt: ChioReceipt,
149    /// The canonical JSON byte buffer that was signed. Wrapped in
150    /// [`chio_core::crypto::SharedCanonicalBytes`] so multiple downstream
151    /// consumers can share the allocation without copying or
152    /// reserializing.
153    pub canonical: chio_core::crypto::SharedCanonicalBytes,
154}
155
156/// Sign a [`ChioReceiptBody`] through a hybrid signing backend and return
157/// both the signed [`ChioReceipt`] and the [`SharedCanonicalBytes`] that
158/// were signed.
159///
160/// This is the shared-canonical-bytes entrypoint: the hybrid backend
161/// (classical Ed25519 plus ML-DSA-65) is fed the `CanonicalBytes` newtype
162/// directly so the canonical JSON byte buffer is built once, signed once, and
163/// shared by every downstream consumer (storage, lineage anchor, federation
164/// cosign).
165///
166/// # WYSIWYS recompute
167///
168/// `canonical_content` is the exact byte preimage the body's `content_hash` was
169/// derived from (the same preimage the classical sibling
170/// [`sign_receipt_body_with_backend`] takes). Before any cryptographic work this
171/// helper recomputes `sha256_hex(canonical_content)` inside the trust boundary
172/// and refuses to sign when it disagrees with `body.content_hash`
173/// ([`KernelError::ReceiptSigningFailed`], fail-closed). The check reuses the
174/// canonical WYSIWYS gate ([`ReceiptSigningHandle`]) so this shared-canonical
175/// hybrid/PQ path is held to the same recompute-and-refuse contract as the
176/// inline classical path, the mpsc signing task, and `sign_receipt_body_with_backend`.
177/// This closes the render-A / sign-B forgery on the shared-canonical entrypoint
178/// rather than leaving it as a caller-hash-trust seam. Callers that genuinely
179/// cannot carry the content preimage must use
180/// `chio_kernel_core::sign_receipt_relaying_trusted_body` (the explicit
181/// trusted-relay seam); this content-bearing kernel wrapper is not that
182/// seam.
183///
184/// # Authoritative signing input
185///
186/// The bytes signed are the canonical JSON encoding of the
187/// [`chio_core::receipt::signing::ChioReceiptSigningBody`] wrapper, which binds
188/// the content-addressed receipt id to the
189/// [`chio_core::receipt::body::ChioReceiptIdInput`] that derived it. This is
190/// the same byte sequence the classical sibling
191/// [`sign_receipt_body_with_backend`] signs (it delegates to
192/// [`chio_kernel_core::sign_receipt`] and then
193/// [`chio_core::receipt::body::ChioReceipt::sign_with_backend`]). The two
194/// paths produce byte-identical signed bytes for the same body and
195/// backend.
196///
197/// # Trust-boundary discipline
198///
199/// - Fail-closed: if `body.kernel_key` does not match `backend.public_key()`
200///   the helper returns [`KernelError::ReceiptSigningFailed`] without
201///   touching the canonical buffer or producing a signature.
202/// - Byte-identity: the `canonical` field of the returned
203///   [`SignedHybridReceipt`] is the exact buffer the backend signed. A
204///   downstream verifier MUST consume the same buffer via
205///   [`chio_core::crypto::PublicKey::verify`] to keep the byte chain intact.
206/// - Algorithm agnostic at the trait boundary: the helper accepts any
207///   [`SigningBackend`] (Ed25519, P-256, P-384, or hybrid). When the
208///   backend is the classical-only `Ed25519Backend` the canonical buffer
209///   is still the exact bytes the `sign_with_backend` path signs,
210///   so callers may treat this entrypoint as the canonical-bytes-aware
211///   superset of the `sign_with_backend` path.
212///
213/// # Errors
214///
215/// Returns [`KernelError::ReceiptSigningFailed`] when:
216/// - the body's claimed `content_hash` does not match
217///   `sha256_hex(canonical_content)` (fail-closed: render-A / sign-B refused), OR
218/// - `body.kernel_key` does not match the backend's public key, OR
219/// - `body` fails semantic validation (see
220///   [`chio_core::receipt::body::ChioReceiptBody::validate_signable_semantics`]),
221///   OR
222/// - canonical JSON encoding of the receipt id input or signing wrapper
223///   fails, OR
224/// - the signing backend itself rejects the message (for example, FIPS
225///   ECDSA backends that fail to acquire OS randomness).
226pub fn sign_receipt_body_hybrid_canonical(
227    body: ChioReceiptBody,
228    backend: &dyn chio_core::crypto::SigningBackend,
229    canonical_content: &[u8],
230) -> Result<SignedHybridReceipt, KernelError> {
231    use chio_core::crypto::{
232        canonical_json_shared_bytes, sign_shared_canonical_with_backend, PublicKey,
233    };
234    use chio_core::receipt::{
235        body::chio_receipt_id, signing::bind_receipt_signing_nonce, signing::ChioReceiptSigningBody,
236    };
237
238    // WYSIWYS recompute-and-refuse FIRST, inside the trust boundary, before any
239    // kernel-key check or cryptographic work. We recompute
240    // `sha256_hex(canonical_content)` over the BORROWED preimage and refuse to
241    // sign when it disagrees with the caller-supplied `body.content_hash`. This
242    // is the same recompute-and-refuse gate `chio_kernel_core::sign_receipt`
243    // applies (also from the borrowed slice), so the shared-canonical hybrid/PQ
244    // path can no longer render content A while signing a body claiming the hash
245    // of content B.
246    //
247    // hash the borrowed slice directly instead of
248    // cloning it into a `ReceiptSigningHandle` via `to_vec()`. The preimage can
249    // be up to the configured stream/output max (256 MiB); a transient clone
250    // here doubled peak memory for no benefit, since the handle's only role on
251    // this path is the recompute-and-compare the borrowed-slice form does
252    // identically.
253    let recomputed = chio_core::crypto::sha256_hex(canonical_content);
254    if recomputed != body.content_hash {
255        return Err(KernelError::ReceiptSigningFailed(format!(
256            "receipt content_hash mismatch: body claimed {} but signer recomputed {} \
257             over the canonical content (WYSIWYS refused)",
258            body.content_hash, recomputed
259        )));
260    }
261
262    // Fail-closed kernel-key match BEFORE any cryptographic work. Mirrors
263    // `chio_kernel_core::sign_receipt` so the byte-identity contract holds
264    // when callers route through either entrypoint.
265    let backend_pk: PublicKey = backend.public_key();
266    if body.kernel_key.algorithm() != backend_pk.algorithm() || body.kernel_key != backend_pk {
267        return Err(KernelError::ReceiptSigningFailed(
268            "kernel signing key does not match receipt body kernel_key".to_string(),
269        ));
270    }
271
272    // Mirror the classical sibling path so the two entrypoints sign the
273    // same authoritative `ChioReceiptSigningBody` wrapper (id plus
274    // `ChioReceiptIdInput`). `ChioReceipt::sign_with_backend` performs
275    // four steps: validate semantics, bind the canonical signing nonce
276    // into metadata, compute the content-addressed id, and build the
277    // wrapper. We replicate them here so the bytes the hybrid backend
278    // signs are byte-identical to what the classical sibling signs for
279    // the same body.
280    let mut body = body;
281    body.validate_signable_semantics().map_err(|error| {
282        KernelError::ReceiptSigningFailed(format!(
283            "receipt body failed semantic validation: {error}"
284        ))
285    })?;
286    // Bind the signing nonce BEFORE computing the id, exactly as the
287    // classical path does, so the content-addressed id (and therefore the
288    // signed bytes) cover the nonce.
289    bind_receipt_signing_nonce(&mut body);
290    body.id = chio_receipt_id(&body).map_err(|error| {
291        KernelError::ReceiptSigningFailed(format!(
292            "canonical JSON encoding of receipt id input failed: {error}"
293        ))
294    })?;
295    let signing_body = ChioReceiptSigningBody::from(&body);
296
297    // Build the SharedCanonicalBytes once over the authoritative
298    // signing wrapper. This is the byte buffer the classical half
299    // hashes and the ML-DSA-65 half signs.
300    let canonical = canonical_json_shared_bytes(&signing_body).map_err(|error| {
301        KernelError::ReceiptSigningFailed(format!(
302            "canonical JSON encoding of receipt signing body failed: {error}"
303        ))
304    })?;
305
306    // Sign through the shared-bytes path so the backend is fed the EXACT
307    // buffer downstream consumers will consume. `Arc::clone` keeps the
308    // allocation; no second canonicalization happens.
309    let signed =
310        sign_shared_canonical_with_backend(backend, canonical.clone()).map_err(|error| {
311            KernelError::ReceiptSigningFailed(format!("hybrid signing failed: {error}"))
312        })?;
313    let (signature, signed_canonical) = signed.into_parts();
314
315    // Defence-in-depth: the buffer the backend signed MUST be the same
316    // allocation we built above. `sign_shared_canonical_with_backend`
317    // does not reserialize, but assert byte-equality so a future change
318    // that reserializes silently fails this contract first.
319    debug_assert_eq!(
320        canonical.as_bytes(),
321        signed_canonical.as_bytes(),
322        "byte-identity drift: shared canonical bytes were re-encoded"
323    );
324
325    let receipt = ChioReceipt {
326        id: body.id,
327        timestamp: body.timestamp,
328        capability_id: body.capability_id,
329        tool_server: body.tool_server,
330        tool_name: body.tool_name,
331        action: body.action,
332        decision: body.decision,
333        receipt_kind: body.receipt_kind,
334        boundary_class: body.boundary_class,
335        observation_outcome: body.observation_outcome,
336        tool_origin: body.tool_origin,
337        redaction_mode: body.redaction_mode,
338        actor_chain: body.actor_chain,
339        content_hash: body.content_hash,
340        policy_hash: body.policy_hash,
341        evidence: body.evidence,
342        metadata: body.metadata,
343        trust_level: body.trust_level,
344        tenant_id: body.tenant_id,
345        bbs_projection_version: None,
346        kernel_key: body.kernel_key,
347        bbs_signature: None,
348        algorithm: Some(backend.algorithm()),
349        signature,
350    };
351
352    Ok(SignedHybridReceipt {
353        receipt,
354        canonical: signed_canonical,
355    })
356}
357
358/// Errors raised when the kernel boot path constructs a receipt-signing
359/// backend from a configured `crypto_floor` and provisioned key material.
360///
361/// These errors fire at signer construction time, before any receipt is
362/// signed. The kernel boot path MUST surface the error and refuse to
363/// start. Threat model row `pq_signature_downgrade` is the surface this
364/// guards.
365#[derive(Debug, thiserror::Error, PartialEq, Eq)]
366pub enum KernelSigningBackendError {
367    /// `crypto_floor=allow_hybrid` or `crypto_floor=pq_required` was
368    /// configured but no ML-DSA-65 key was provisioned. Fail-closed.
369    #[error(
370        "policy.crypto_floor={floor} requires a post-quantum (ML-DSA-65) signing key but \
371         none was provisioned at kernel boot"
372    )]
373    HybridFloorRequiresPqKey {
374        /// The floor that triggered the rejection.
375        floor: &'static str,
376    },
377
378    /// The provisioned PQ key material could not be loaded into a
379    /// signing backend.
380    #[error("post-quantum signing key import failed: {reason}")]
381    PqKeyImportFailed {
382        /// The reason the key import failed.
383        reason: String,
384    },
385}
386
387/// Construct the kernel-side receipt signing backend from a configured
388/// `crypto_floor` and the operator-provisioned key material.
389///
390/// Returns a boxed [`SigningBackend`] that the kernel calls through for
391/// every receipt. Under [`KernelCryptoFloor::AllowClassical`] this is an
392/// [`Ed25519Backend`] wrapping the classical [`Keypair`]; under
393/// [`KernelCryptoFloor::AllowHybrid`] or [`KernelCryptoFloor::PqRequired`]
394/// it is a [`HybridBackend`] composed of the same Ed25519 backend and an
395/// [`MlDsa65Backend`] derived from `pq_seed`.
396///
397/// `pq_seed` is the 32-byte FIPS 204 keygen seed for the rolled
398/// ML-DSA-65 key. Operators load it from the kernel boot environment
399/// (HSM, sealed file, KMS); the seed never leaves the kernel process.
400///
401/// # Errors
402///
403/// Fails fast with [`KernelSigningBackendError::HybridFloorRequiresPqKey`]
404/// if `crypto_floor` permits hybrid but `pq_seed` is `None`. This is the
405/// same fail-closed behaviour that `chio_policy::CryptoFloor::validate_with_pq_key`
406/// applies at policy load; checking it again at backend construction
407/// catches any drift between the two enums.
408#[cfg(feature = "pq")]
409pub fn kernel_signing_backend(
410    crypto_floor: KernelCryptoFloor,
411    classical_keypair: Keypair,
412    pq_seed: Option<&[u8; 32]>,
413) -> Result<Box<dyn chio_core::crypto::SigningBackend>, KernelSigningBackendError> {
414    use chio_core::crypto::{Ed25519Backend, HybridBackend, MlDsa65Backend};
415
416    let classical = Ed25519Backend::new(classical_keypair);
417
418    if !crypto_floor.allows_hybrid() {
419        return Ok(Box::new(classical));
420    }
421
422    let seed = pq_seed.ok_or(KernelSigningBackendError::HybridFloorRequiresPqKey {
423        floor: crypto_floor.as_str(),
424    })?;
425    let pq = MlDsa65Backend::from_seed(seed);
426    let hybrid = HybridBackend::new(Box::new(classical), pq).map_err(|error| {
427        KernelSigningBackendError::PqKeyImportFailed {
428            reason: error.to_string(),
429        }
430    })?;
431    Ok(Box::new(hybrid))
432}
433
434/// Construct the kernel-side receipt signing backend without the `pq`
435/// feature.
436///
437/// Without the `pq` feature, only [`KernelCryptoFloor::AllowClassical`] is
438/// constructible; any other floor returns
439/// [`KernelSigningBackendError::HybridFloorRequiresPqKey`]. This preserves
440/// the fail-closed contract for default builds.
441#[cfg(not(feature = "pq"))]
442pub fn kernel_signing_backend(
443    crypto_floor: KernelCryptoFloor,
444    classical_keypair: Keypair,
445    _pq_seed: Option<&[u8; 32]>,
446) -> Result<Box<dyn chio_core::crypto::SigningBackend>, KernelSigningBackendError> {
447    use chio_core::crypto::Ed25519Backend;
448
449    if crypto_floor.allows_hybrid() {
450        return Err(KernelSigningBackendError::HybridFloorRequiresPqKey {
451            floor: crypto_floor.as_str(),
452        });
453    }
454    Ok(Box::new(Ed25519Backend::new(classical_keypair)))
455}
456
457#[cfg(test)]
458mod borrowed_preimage_tests {
459    //! the hybrid/PQ shared-canonical path recomputes
460    //! `sha256_hex(canonical_content)` from the BORROWED preimage slice (no
461    //! `to_vec()` clone) before any signing work, so render-A / sign-B is still
462    //! refused fail-closed. These run under a classical Ed25519 backend so they
463    //! exercise the borrowed-slice recompute without the `pq` feature (the
464    //! helper accepts any `SigningBackend`).
465    use chio_core::crypto::{Ed25519Backend, Keypair};
466    use chio_core::receipt::body::ChioReceiptBody;
467    use chio_core::receipt::decision::{Decision, ToolCallAction};
468    use chio_core::receipt::kinds::TrustLevel;
469
470    use super::sign_receipt_body_hybrid_canonical;
471    use crate::KernelError;
472
473    const PREIMAGE: &[u8] = br#"{"q":"borrowed-preimage"}"#;
474
475    fn seed() -> [u8; 32] {
476        let raw = b"chio-borrowed-preimage-test-seed";
477        let mut out = [0u8; 32];
478        out.copy_from_slice(raw);
479        out
480    }
481
482    fn body_for(kernel_key: chio_core::crypto::PublicKey, content_hash: String) -> ChioReceiptBody {
483        ChioReceiptBody {
484            id: "rcpt-borrowed-preimage".to_string(),
485            timestamp: 1_700_000_010,
486            capability_id: "cap-borrowed".to_string(),
487            tool_server: "audit-server".to_string(),
488            tool_name: "report".to_string(),
489            action: ToolCallAction::from_parameters(serde_json::json!({"q": "borrowed-preimage"}))
490                .expect("action canonicalises"),
491            decision: Some(Decision::Allow),
492            receipt_kind: Default::default(),
493            boundary_class: Default::default(),
494            observation_outcome: None,
495            tool_origin: Default::default(),
496            redaction_mode: Default::default(),
497            actor_chain: Vec::new(),
498            content_hash,
499            policy_hash: "policy-borrowed".to_string(),
500            evidence: Vec::new(),
501            metadata: None,
502            trust_level: TrustLevel::Mediated,
503            tenant_id: None,
504            kernel_key,
505            bbs_projection_version: None,
506        }
507    }
508
509    #[test]
510    fn borrowed_hash_path_signs_matching_body() {
511        // A body whose claimed `content_hash` matches the borrowed preimage must
512        // sign: the borrowed-slice recompute agrees, so the (previously cloned)
513        // hash check still admits the legitimate receipt.
514        let kp = Keypair::from_seed(&seed());
515        let backend = Ed25519Backend::new(kp.clone());
516        let body = body_for(kp.public_key(), chio_core::crypto::sha256_hex(PREIMAGE));
517
518        let signed = sign_receipt_body_hybrid_canonical(body, &backend, PREIMAGE)
519            .expect("matching body must sign through the borrowed-slice recompute path");
520        assert!(
521            signed
522                .receipt
523                .kernel_key
524                .verify(signed.canonical.as_bytes(), &signed.receipt.signature),
525            "produced signature must verify over the exact signed bytes"
526        );
527    }
528
529    #[test]
530    fn borrowed_hash_path_refuses_render_a_sign_b() {
531        // WYSIWYS: the body binds `content_hash` to PREIMAGE but the
532        // signer is handed a DIFFERENT preimage. The borrowed-slice recompute
533        // must disagree and refuse, proving the clone-free hash check still
534        // recomputes-and-refuses (case 4 must not weaken the gate).
535        let kp = Keypair::from_seed(&seed());
536        let backend = Ed25519Backend::new(kp.clone());
537        let body = body_for(kp.public_key(), chio_core::crypto::sha256_hex(PREIMAGE));
538        let attacker_preimage = br#"{"q":"a-different-thing"}"#;
539
540        let err = sign_receipt_body_hybrid_canonical(body, &backend, attacker_preimage)
541            .expect_err("render-A / sign-B must fail closed on the borrowed-slice path");
542        match err {
543            KernelError::ReceiptSigningFailed(message) => {
544                assert!(
545                    message.contains("content_hash mismatch")
546                        && message.contains("WYSIWYS refused"),
547                    "expected WYSIWYS content-hash mismatch diagnostic, got {message}"
548                );
549            }
550            other => panic!("expected ReceiptSigningFailed, got {other:?}"),
551        }
552    }
553}