chio_kernel_core/receipts.rs
1//! Portable receipt signing.
2//!
3//! Wraps `chio_core_types::receipt::body::ChioReceipt::sign_with_backend` so the kernel core
4//! can produce signed receipts without depending on the `chio-kernel` full
5//! crate's keypair-based helper. Using the `SigningBackend` trait keeps
6//! the FIPS-capable signing path available on every adapter.
7
8use alloc::string::ToString;
9#[cfg(kani)]
10use alloc::vec::Vec;
11
12use chio_core_types::crypto::SigningBackend;
13use chio_core_types::receipt::signing::ReceiptSigningHandle;
14use chio_core_types::receipt::{body::ChioReceipt, body::ChioReceiptBody};
15
16/// Errors raised by [`sign_receipt`].
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum ReceiptSigningError {
19 /// The receipt body's `kernel_key` does not match the signing backend's
20 /// public key. Signing would succeed but verification against the
21 /// embedded `kernel_key` would then fail; we fail early to catch
22 /// config drift.
23 KernelKeyMismatch,
24 /// The body's claimed `content_hash` does not match the hash the signer
25 /// recomputed over the bound canonical content. WYSIWYS fail-closed gate:
26 /// closes render-A / sign-B forgeries. Carries the recomputed and claimed
27 /// hashes for audit.
28 ContentHashMismatch {
29 /// Hash recomputed by the signer over the handle's canonical content.
30 recomputed: alloc::string::String,
31 /// `content_hash` the caller embedded in the receipt body.
32 claimed: alloc::string::String,
33 },
34 /// The canonical-JSON signing pipeline raised an error (bubbled up
35 /// from `chio-core-types::crypto::sign_canonical_with_backend`).
36 SigningFailed(alloc::string::String),
37}
38
39/// WYSIWYS receipt signing at the kernel-core trust boundary. **This is the
40/// production signing primitive: every signature minted from evaluated content
41/// flows through here, and it never trusts the caller's `content_hash`.**
42///
43/// `canonical_content` is the exact byte preimage the receipt's `content_hash`
44/// was derived from (for a value output the RFC 8785 canonical JSON; for a
45/// stream receipt the concatenated per-chunk digest preimage; for an empty
46/// output the literal `null` canonicalization). The signer recomputes
47/// `sha256_hex(canonical_content)` *inside the trust boundary* and refuses to
48/// sign when it disagrees with `body.content_hash` ([`ReceiptSigningError::ContentHashMismatch`],
49/// fail-closed). This closes the render-A / sign-B forgery on the production
50/// path: a caller can no longer render content `A` to a human while submitting
51/// a body claiming the hash of content `B`.
52///
53/// The recompute runs **before** the kernel-key check and before any signing
54/// work, so a hash mismatch can never reach the signer.
55///
56/// For the move-only, one-time API that binds a signature to a single evaluated
57/// artifact (and cannot be replayed), see [`sign_receipt_with_handle`]; it
58/// delegates here after consuming its handle. For the thin transport adapters
59/// that relay an already-minted body across an FFI/WASM boundary and therefore
60/// do **not** hold the content preimage, see
61/// [`sign_receipt_relaying_trusted_body`].
62///
63/// The `body.kernel_key` must equal `backend.public_key()`; otherwise we fail
64/// fast with [`ReceiptSigningError::KernelKeyMismatch`] so the caller doesn't
65/// produce a receipt whose signature cannot be verified.
66///
67/// # Errors
68///
69/// - [`ReceiptSigningError::ContentHashMismatch`] when the body's claimed
70/// `content_hash` does not match `sha256_hex(canonical_content)`.
71/// - [`ReceiptSigningError::KernelKeyMismatch`] when `body.kernel_key` does not
72/// match `backend.public_key()`.
73/// - [`ReceiptSigningError::SigningFailed`] when canonical signing fails.
74pub fn sign_receipt(
75 body: ChioReceiptBody,
76 backend: &dyn SigningBackend,
77 canonical_content: &[u8],
78) -> Result<ChioReceipt, ReceiptSigningError> {
79 // Recompute-and-refuse FIRST, inside the trust boundary, before any
80 // kernel-key or signing work, so a hash mismatch can never reach the
81 // signer. The recomputed hash is the signer's own (sha256 over the bytes
82 // the producer evaluated), never the caller's asserted `content_hash`.
83 let recomputed = chio_core_types::crypto::sha256_hex(canonical_content);
84 if recomputed != body.content_hash {
85 // Capture the claimed hash for the error payload BEFORE forgetting the
86 // body. Under `--cfg kani` we `mem::forget(body)` to avoid symbolically
87 // executing the body's Drop, but that moves the whole `body`; reading
88 // `body.content_hash` afterwards would be a use-after-move that fails to
89 // type-check on the formal-verification build. Clone the claimed hash
90 // into a local first so the error is built from owned data on both the
91 // normal and kani cfg paths.
92 let claimed = body.content_hash.clone();
93 #[cfg(kani)]
94 core::mem::forget(body);
95
96 return Err(ReceiptSigningError::ContentHashMismatch {
97 recomputed,
98 claimed,
99 });
100 }
101
102 sign_receipt_relaying_trusted_body(body, backend)
103}
104
105/// Sign a receipt body using the given [`SigningBackend`] **without**
106/// recomputing `content_hash` from a content preimage.
107///
108/// This trusts the caller-supplied `body.content_hash`. It exists **only** for
109/// the thin transport adapters (mobile FFI / browser WASM / C++ FFI) that
110/// receive an already-assembled, serialized [`ChioReceiptBody`] over their
111/// boundary and therefore do **not** hold the content preimage needed to
112/// re-derive the hash. Those adapters relay a body minted by an upstream
113/// trusted producer (the kernel), where the WYSIWYS recompute already ran
114/// through [`sign_receipt`] / [`sign_receipt_with_handle`].
115///
116/// Every path that *does* hold the evaluated content -- i.e. the production
117/// kernel signing path (`chio_kernel::kernel::responses::build_and_sign_receipt`
118/// and the mpsc signing task) -- MUST instead call [`sign_receipt`] (or
119/// [`sign_receipt_with_handle`]) so `content_hash` is recomputed over the
120/// canonical content inside the trust boundary and signing is refused on
121/// mismatch (fail-closed). Threading the content preimage across the FFI/WASM
122/// boundary so these adapters can recompute too is the larger follow-up tracked
123/// as ; until then this entrypoint is the explicit, auditable seam where
124/// caller `content_hash` is trusted, rather than that trust being the silent
125/// default of `sign_receipt`.
126///
127/// The `body.kernel_key` must equal `backend.public_key()`; otherwise we
128/// fail fast with [`ReceiptSigningError::KernelKeyMismatch`] so the caller
129/// doesn't produce a receipt whose signature cannot be verified.
130///
131/// # Errors
132///
133/// - [`ReceiptSigningError::KernelKeyMismatch`] when `body.kernel_key` does not
134/// match `backend.public_key()`.
135/// - [`ReceiptSigningError::SigningFailed`] when canonical signing fails.
136pub fn sign_receipt_relaying_trusted_body(
137 body: ChioReceiptBody,
138 backend: &dyn SigningBackend,
139) -> Result<ChioReceipt, ReceiptSigningError> {
140 let backend_key = backend.public_key();
141 if body.kernel_key.algorithm() != backend_key.algorithm() || body.kernel_key != backend_key {
142 #[cfg(kani)]
143 core::mem::forget(body);
144
145 return Err(ReceiptSigningError::KernelKeyMismatch);
146 }
147
148 #[cfg(kani)]
149 {
150 // Kani cannot practically symbolically execute the serde/RFC 8785
151 // canonicalization stack. This model still exercises the successful
152 // public branch: matching kernel key, backend signing, and field
153 // preservation into the returned receipt.
154 let signature = backend
155 .sign_bytes(b"kani-receipt-signing-model")
156 .map_err(|error| ReceiptSigningError::SigningFailed(error.to_string()))?;
157 return Ok(ChioReceipt {
158 id: body.id,
159 timestamp: body.timestamp,
160 capability_id: body.capability_id,
161 tool_server: body.tool_server,
162 tool_name: body.tool_name,
163 action: body.action,
164 decision: body.decision,
165 receipt_kind: Default::default(),
166 boundary_class: Default::default(),
167 observation_outcome: None,
168 tool_origin: Default::default(),
169 redaction_mode: Default::default(),
170 actor_chain: Vec::new(),
171 content_hash: body.content_hash,
172 policy_hash: body.policy_hash,
173 evidence: body.evidence,
174 metadata: body.metadata,
175 trust_level: body.trust_level,
176 tenant_id: body.tenant_id,
177 bbs_projection_version: None,
178 kernel_key: body.kernel_key,
179 bbs_signature: None,
180 algorithm: Some(backend.algorithm()),
181 signature,
182 });
183 }
184
185 #[cfg(not(kani))]
186 ChioReceipt::sign_with_backend(body, backend)
187 .map_err(|error| ReceiptSigningError::SigningFailed(error.to_string()))
188}
189
190/// WYSIWYS receipt signing bound to a *specific* evaluated artifact via a
191/// one-time [`ReceiptSigningHandle`]. **This is the strongest signing API.**
192///
193/// Like [`sign_receipt`], this recomputes `content_hash` over canonical content
194/// inside the trust boundary and refuses to sign on mismatch (fail-closed). It
195/// adds the one-time, move-only guarantee: the handle is consumed by value, so
196/// a single handle backs at most one signature and cannot be replayed. The
197/// handle recomputed `content_hash` over the artifact's canonical content when
198/// it was constructed; here we forward that handle's canonical bytes to
199/// [`sign_receipt`], which refuses to sign unless the body's claimed
200/// `content_hash` equals the recomputed hash.
201///
202/// The kernel `build_and_sign_receipt` path and the mpsc signing task both route
203/// through here, so every signature the kernel mints recomputes
204/// `content_hash` inside the trust boundary and is bound to a single artifact.
205/// The kernel key check from [`sign_receipt`] still applies.
206///
207/// # Seam note ( follow-up / )
208///
209/// Producers currently build the handle from canonical content they supply
210/// (see [`ReceiptSigningHandle::from_content_preimage`] / [`ReceiptSigningHandle::from_content`]).
211/// On the kernel path that preimage is the exact bytes
212/// `receipt_content_for_output` hashed to produce `content_hash`, so the gate
213/// is non-tautological there. The intended end state is for `evaluate()` to
214/// return the handle so the only way to obtain one is to have actually run an
215/// evaluation; threading the handle out of [`crate::evaluate`] is a larger
216/// follow-up, but the recompute + refuse gate here already closes the
217/// render-A / sign-B regression on the production path.
218///
219/// # Errors
220///
221/// - [`ReceiptSigningError::ContentHashMismatch`] when the body's claimed
222/// `content_hash` does not match the handle's recomputed hash.
223/// - [`ReceiptSigningError::KernelKeyMismatch`] when `body.kernel_key` does not
224/// match `backend.public_key()`.
225/// - [`ReceiptSigningError::SigningFailed`] when canonical signing fails.
226pub fn sign_receipt_with_handle(
227 body: ChioReceiptBody,
228 backend: &dyn SigningBackend,
229 handle: ReceiptSigningHandle,
230) -> Result<ChioReceipt, ReceiptSigningError> {
231 // Consume the handle by value (enforcing one-time use per signature) and
232 // forward its immutable canonical bytes to the production recompute gate.
233 // `sign_receipt` recomputes `sha256_hex(canonical_content)` and refuses on
234 // mismatch with `body.content_hash`, which is exactly the handle's
235 // recompute-and-refuse contract.
236 let (_recomputed_hash, canonical_content) = handle.into_parts();
237 sign_receipt(body, backend, &canonical_content)
238}