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}