Skip to main content

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}