Skip to main content

chio_federation/
bilateral.rs

1//! Bilateral cross-kernel runtime co-signing.
2//!
3//! When an agent from Organisation A invokes a tool hosted by Organisation B,
4//! both kernels need to sign the same receipt so that either org can
5//! independently verify the chain. This module defines the wire-level
6//! [`CoSigningRequest`] / [`CoSigningResponse`] envelope, the
7//! [`DualSignedReceipt`] artifact (which carries both signatures side-by-
8//! side without mutating the core `ChioReceipt` body), and a
9//! [`BilateralCoSigningProtocol`] trait that the kernel calls after it
10//! signs a receipt locally.
11//!
12//! ## Design notes
13//!
14//! * `chio-core-types::ChioReceipt` is intentionally untouched -- co-signatures
15//!   ride in this federation-specific envelope. An Org A verifier that only
16//!   understands the base receipt can still verify it in isolation; a Dual
17//!   verifier checks the base receipt plus the remote org's detached
18//!   signature over the same canonical body.
19//! * Verification is strict: a `DualSignedReceipt` only verifies when BOTH
20//!   signatures validate against their declared kernel IDs and both kernel
21//!   IDs match the expected pinned peers. Either half alone is not
22//!   sufficient.
23//! * Signing happens over canonical JSON (RFC 8785) of the
24//!   [`CoSigningBody`]: receipt body bytes + both kernel IDs. This keeps the
25//!   detached remote signature deterministic across implementations.
26
27use chio_core_types::canonical::canonical_json_bytes;
28use chio_core_types::crypto::{Ed25519Backend, Keypair, PublicKey, Signature, SigningBackend};
29use chio_core_types::receipt::ChioReceipt;
30use serde::{Deserialize, Serialize};
31
32pub const BILATERAL_COSIGNING_SCHEMA: &str = "chio.federation-bilateral-cosigning.v1";
33pub const BILATERAL_DUAL_RECEIPT_SCHEMA: &str = "chio.federation-dual-signed-receipt.v1";
34
35/// Canonical body that the local and remote kernels both sign. The bytes of
36/// this structure (in canonical JSON) are the signed message for
37/// [`DualSignedReceipt::org_a_signature`] and
38/// [`DualSignedReceipt::org_b_signature`].
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
40#[serde(rename_all = "camelCase", deny_unknown_fields)]
41pub struct CoSigningBody {
42    pub schema: String,
43    /// Canonical JSON encoding of the underlying `ChioReceipt`, as a UTF-8
44    /// string. The string form (rather than a nested object) keeps signing
45    /// stable even if the receipt schema grows new `skip_serializing_if`
46    /// fields later: both kernels sign exactly the bytes they saw.
47    pub receipt_canonical_json: String,
48    pub org_a_kernel_id: String,
49    pub org_b_kernel_id: String,
50}
51
52impl CoSigningBody {
53    /// Construct the canonical body from a receipt and the two kernel IDs
54    /// participating in the exchange. Returns the body plus the canonical
55    /// bytes of the receipt, so callers can persist them.
56    pub fn from_receipt(
57        receipt: &ChioReceipt,
58        org_a_kernel_id: &str,
59        org_b_kernel_id: &str,
60    ) -> Result<Self, BilateralCoSigningError> {
61        let bytes = canonical_json_bytes(receipt)
62            .map_err(|e| BilateralCoSigningError::CanonicalJson(e.to_string()))?;
63        let receipt_canonical_json = String::from_utf8(bytes)
64            .map_err(|e| BilateralCoSigningError::CanonicalJson(e.to_string()))?;
65        Ok(Self {
66            schema: BILATERAL_COSIGNING_SCHEMA.to_string(),
67            receipt_canonical_json,
68            org_a_kernel_id: org_a_kernel_id.to_string(),
69            org_b_kernel_id: org_b_kernel_id.to_string(),
70        })
71    }
72
73    pub fn canonical_bytes(&self) -> Result<Vec<u8>, BilateralCoSigningError> {
74        canonical_json_bytes(self)
75            .map_err(|e| BilateralCoSigningError::CanonicalJson(e.to_string()))
76    }
77}
78
79/// A receipt co-signed by two kernels across a federation boundary.
80///
81/// * `body` -- the underlying `ChioReceipt` that both kernels agreed on.
82/// * `org_a_signature` -- detached signature by the origin (Org A) kernel
83///   over the canonical [`CoSigningBody`].
84/// * `org_b_signature` -- detached signature by the tool-host (Org B) kernel
85///   over the same canonical body.
86///
87/// The existing receipt's built-in `signature` and `kernel_key` fields are
88/// unchanged: a classic verifier can still check the receipt in isolation,
89/// while a federation-aware verifier additionally checks both detached
90/// signatures via [`DualSignedReceipt::verify`].
91#[derive(Debug, Clone, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase", deny_unknown_fields)]
93pub struct DualSignedReceipt {
94    pub schema: String,
95    pub body: ChioReceipt,
96    pub org_a_kernel_id: String,
97    pub org_b_kernel_id: String,
98    pub org_a_signature: Signature,
99    pub org_b_signature: Signature,
100}
101
102impl DualSignedReceipt {
103    /// Verify both detached signatures against the provided pinned peer
104    /// public keys. Returns `Ok(())` only when BOTH signatures validate.
105    ///
106    /// Neither half of the dual signature is sufficient on its own; a
107    /// caller that can only check one side must still refuse the receipt.
108    pub fn verify(
109        &self,
110        org_a_public_key: &PublicKey,
111        org_b_public_key: &PublicKey,
112    ) -> Result<(), BilateralCoSigningError> {
113        let body =
114            CoSigningBody::from_receipt(&self.body, &self.org_a_kernel_id, &self.org_b_kernel_id)?;
115        let bytes = body.canonical_bytes()?;
116
117        if !org_a_public_key.verify(&bytes, &self.org_a_signature) {
118            return Err(BilateralCoSigningError::OrgASignatureInvalid);
119        }
120        if !org_b_public_key.verify(&bytes, &self.org_b_signature) {
121            return Err(BilateralCoSigningError::OrgBSignatureInvalid);
122        }
123        Ok(())
124    }
125}
126
127/// Request sent from the tool-host kernel (Org B) to the origin kernel
128/// (Org A) asking it to co-sign a receipt that Org B already signed
129/// locally.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131#[serde(rename_all = "camelCase", deny_unknown_fields)]
132pub struct CoSigningRequest {
133    pub schema: String,
134    pub body: ChioReceipt,
135    pub org_a_kernel_id: String,
136    pub org_b_kernel_id: String,
137    /// Org B's own signature over the canonical cosigning body. The origin
138    /// kernel verifies this before agreeing to sign.
139    pub org_b_signature: Signature,
140}
141
142impl CoSigningRequest {
143    pub fn new(
144        body: ChioReceipt,
145        org_a_kernel_id: String,
146        org_b_kernel_id: String,
147        org_b_signature: Signature,
148    ) -> Self {
149        Self {
150            schema: BILATERAL_COSIGNING_SCHEMA.to_string(),
151            body,
152            org_a_kernel_id,
153            org_b_kernel_id,
154            org_b_signature,
155        }
156    }
157}
158
159/// Response from the origin kernel (Org A) carrying its co-signature.
160#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
161#[serde(rename_all = "camelCase", deny_unknown_fields)]
162pub struct CoSigningResponse {
163    pub schema: String,
164    pub org_a_signature: Signature,
165}
166
167/// Errors surfaced by the bilateral co-signing protocol. All variants are
168/// fail-closed: on any error the kernel MUST refuse to persist a dual-signed
169/// receipt for the failing exchange.
170#[derive(Debug, thiserror::Error, PartialEq, Eq)]
171pub enum BilateralCoSigningError {
172    #[error("canonical JSON encoding failed: {0}")]
173    CanonicalJson(String),
174
175    #[error("origin (Org A) signature failed verification")]
176    OrgASignatureInvalid,
177
178    #[error("tool-host (Org B) signature failed verification")]
179    OrgBSignatureInvalid,
180
181    #[error("remote peer {0} is not a trusted federation peer")]
182    UnknownPeer(String),
183
184    #[error("remote peer {0} has exceeded its rotation window and must re-handshake")]
185    PeerExpired(String),
186
187    #[error("co-signing transport failed: {0}")]
188    TransportFailure(String),
189
190    #[error("co-signing request rejected by peer: {0}")]
191    PeerRejected(String),
192
193    #[error("receipt body mismatch between request and signed body")]
194    ReceiptMismatch,
195}
196
197/// Trait implemented by an object that can obtain a co-signature from a
198/// remote kernel. Production deployments plug an mTLS-backed RPC client
199/// in here; in-process tests use [`InProcessCoSigner`].
200pub trait BilateralCoSigningProtocol: Send + Sync {
201    /// Request a co-signature for a receipt that this kernel already
202    /// signed. The caller is the tool-host kernel (Org B); the remote is
203    /// the origin kernel (Org A) whose agent initiated the call.
204    fn request_cosignature(
205        &self,
206        request: &CoSigningRequest,
207    ) -> Result<CoSigningResponse, BilateralCoSigningError>;
208}
209
210/// In-process reference implementation of [`BilateralCoSigningProtocol`].
211///
212/// Holds the origin kernel's signing keypair directly, so tests and
213/// single-host integration environments can exercise the co-signing path
214/// without an actual mTLS transport. Production deployments should wrap
215/// the remote kernel behind an attested RPC client instead.
216pub struct InProcessCoSigner {
217    origin_kernel_id: String,
218    origin_keypair: Keypair,
219    /// Expected public key of the tool-host kernel (Org B). The origin
220    /// kernel verifies Org B's signature against this key before it is
221    /// willing to co-sign.
222    tool_host_public_key: PublicKey,
223}
224
225impl core::fmt::Debug for InProcessCoSigner {
226    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
227        f.debug_struct("InProcessCoSigner")
228            .field("origin_kernel_id", &self.origin_kernel_id)
229            .finish_non_exhaustive()
230    }
231}
232
233impl InProcessCoSigner {
234    pub fn new(
235        origin_kernel_id: impl Into<String>,
236        origin_keypair: Keypair,
237        tool_host_public_key: PublicKey,
238    ) -> Self {
239        Self {
240            origin_kernel_id: origin_kernel_id.into(),
241            origin_keypair,
242            tool_host_public_key,
243        }
244    }
245
246    pub fn origin_kernel_id(&self) -> &str {
247        &self.origin_kernel_id
248    }
249
250    pub fn origin_public_key(&self) -> PublicKey {
251        self.origin_keypair.public_key()
252    }
253}
254
255impl BilateralCoSigningProtocol for InProcessCoSigner {
256    fn request_cosignature(
257        &self,
258        request: &CoSigningRequest,
259    ) -> Result<CoSigningResponse, BilateralCoSigningError> {
260        if request.org_a_kernel_id != self.origin_kernel_id {
261            return Err(BilateralCoSigningError::UnknownPeer(
262                request.org_a_kernel_id.clone(),
263            ));
264        }
265        let body = CoSigningBody::from_receipt(
266            &request.body,
267            &request.org_a_kernel_id,
268            &request.org_b_kernel_id,
269        )?;
270        let bytes = body.canonical_bytes()?;
271
272        if !self
273            .tool_host_public_key
274            .verify(&bytes, &request.org_b_signature)
275        {
276            return Err(BilateralCoSigningError::OrgBSignatureInvalid);
277        }
278
279        let backend = Ed25519Backend::new(self.origin_keypair.clone());
280        let signature = backend
281            .sign_bytes(&bytes)
282            .map_err(|e| BilateralCoSigningError::TransportFailure(e.to_string()))?;
283        Ok(CoSigningResponse {
284            schema: BILATERAL_COSIGNING_SCHEMA.to_string(),
285            org_a_signature: signature,
286        })
287    }
288}
289
290/// Helper used by the tool-host (Org B) side to drive the full protocol:
291/// locally sign the canonical body, ask the remote [`BilateralCoSigningProtocol`]
292/// for a co-signature, and assemble the verified [`DualSignedReceipt`].
293pub fn co_sign_with_origin(
294    origin_kernel_id: &str,
295    origin_public_key: &PublicKey,
296    tool_host_kernel_id: &str,
297    tool_host_keypair: &Keypair,
298    receipt: ChioReceipt,
299    cosigner: &dyn BilateralCoSigningProtocol,
300) -> Result<DualSignedReceipt, BilateralCoSigningError> {
301    let body = CoSigningBody::from_receipt(&receipt, origin_kernel_id, tool_host_kernel_id)?;
302    let bytes = body.canonical_bytes()?;
303
304    let backend = Ed25519Backend::new(tool_host_keypair.clone());
305    let org_b_signature = backend
306        .sign_bytes(&bytes)
307        .map_err(|e| BilateralCoSigningError::TransportFailure(e.to_string()))?;
308
309    let request = CoSigningRequest::new(
310        receipt.clone(),
311        origin_kernel_id.to_string(),
312        tool_host_kernel_id.to_string(),
313        org_b_signature.clone(),
314    );
315    let response = cosigner.request_cosignature(&request)?;
316
317    if !origin_public_key.verify(&bytes, &response.org_a_signature) {
318        return Err(BilateralCoSigningError::OrgASignatureInvalid);
319    }
320
321    let dual = DualSignedReceipt {
322        schema: BILATERAL_DUAL_RECEIPT_SCHEMA.to_string(),
323        body: receipt,
324        org_a_kernel_id: origin_kernel_id.to_string(),
325        org_b_kernel_id: tool_host_kernel_id.to_string(),
326        org_a_signature: response.org_a_signature,
327        org_b_signature,
328    };
329    // Double-check the assembled artifact verifies end-to-end. The kernel
330    // relies on this invariant to persist only dual-signed artifacts that
331    // would themselves pass third-party verification.
332    dual.verify(origin_public_key, &tool_host_keypair.public_key())?;
333    Ok(dual)
334}