From efb7ee647b3248da148f2f8691cc4714aed91578 Mon Sep 17 00:00:00 2001 From: Transaction Science Date: Mon, 19 Oct 2026 11:23:23 -0400 Subject: [PATCH] jcp-gateway: everything a gated request carries to the server is sealed and flow-checked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gateway decided a gated request over its arguments alone, but it forwarded the whole request. So a `_meta` key, a `params` member the method does not define, or a member of the JSON-RPC message beside `params` reached the server without passing the flow ceiling. A value from an untrusted read could leave by any of them. Each of those is now sealed and flow-checked along with the arguments. Two things are left out, and the code documents why: - The protocol's own `_meta` keys: `progressToken`, and keys under a prefix MCP reserves for itself, such as `io.modelcontextprotocol/`. These are machinery written by the client's runtime, and a server names them back in every reply. The flow store admits every payload it labels, so folding these keys would make the first tainted request taint every request after it. - This gateway's own namespace and its legacy witness key. A request that carries nothing else is decided over exactly the bytes it was before. This also fixes a latent false refusal. A `resources/read` was gated over its whole `params`, `_meta` included. Under MCP 2026-07-28 the protocol keys in every request matched the server's keys in every reply, so once one reply had been admitted, every later read under a flow ceiling was refused as tainted. A request's parts (arguments, answers to an interim result, what it carries beside them) are now scanned joined by a separator unique to that request, not wrapped in one JSON object. A wrapper's keys would be the same text in every request of that shape, and the stored admission of one tainted request would carry its taint into all the rest. SPEC §4.10 now says what "the argument" covers for a forwarding enforcement point, and what it may leave out if it says so. Tests: 6 new. All 13 mutations of the new logic are killed. Co-Authored-By: Claude Opus 5.5 --- jcp/SPEC.md | 14 + jcp/jcp-rs/crates/jcp-gateway/src/lib.rs | 408 ++++++++++++++++++++--- 2 files changed, 383 insertions(+), 39 deletions(-) diff --git a/jcp/SPEC.md b/jcp/SPEC.md index 543a148de..eabcb481c 100644 --- a/jcp/SPEC.md +++ b/jcp/SPEC.md @@ -506,6 +506,20 @@ policy — and *before* caveats and the budget test, so that **a tainted call co zero joules**. A denial on flow is the same invariant as a denial on capability: nothing is debited and nothing runs. +**What "the argument" covers.** Where an enforcement point forwards a request to +an endpoint, the argument whose label is gated is everything the request carries +to the endpoint except the routing that names the action. That includes fields +the protocol leaves open: extension parameters, side-channel metadata, and +envelope members the enforcement point does not otherwise interpret. Any such +field it leaves unlabelled is a way past the ceiling. The enforcement point MUST +label each of these fields, and MUST seal it with the arguments, or else MUST +NOT forward it. It MAY leave out the protocol's own fixed machinery (a version, a +capability set, a progress token) where folding that machinery would taint every +request. If it does, it MUST say which fields it leaves out. A host that admits +every value it labels SHOULD label a request's parts so that combining them adds +no text of its own. Otherwise the framing becomes shared text that carries one +tainted request's label into every request after it. + A grant carrying no `FlowBelow` caveat is byte-identical to one minted before flow existed, and an enforcement point with no taint floor configured decides exactly as it did before. Conformance class **C25**. diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index 392af9765..8d027e4a1 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -716,6 +716,25 @@ impl Gateway { /// restarted gateway knows none of them: an exchange naming a task opened /// before the restart is refused (fail closed), and a retry after one is /// decided as a call with nothing to continue. + /// + /// # Everything the server receives is checked (SPEC §4.10) + /// + /// A gated request was decided and flow-checked over its arguments alone, + /// and forwarded whole, so whatever else it carried reached the server + /// unchecked. That covered a `_meta` key, a `params` member the method + /// does not define, and a member of the JSON-RPC message beside `params`. + /// A value from an untrusted read could leave by any of those past the + /// flow ceiling. Now each of those is sealed and flow-checked with the + /// arguments (`beside`). The exceptions are the protocol's own `_meta` + /// keys (`protocol_meta_key`), its machinery, which a server names back in + /// every reply, and this gateway's own namespace. A request carrying + /// nothing else is decided over exactly the bytes it was before. + /// + /// A `resources/read` was gated over its whole `params`, `_meta` + /// included, so under MCP 2026-07-28 the protocol's own keys in every + /// request matched the server's in every reply. After one reply was + /// admitted, every read under a flow ceiling was refused as tainted. Its + /// `_meta` is now accounted for as a call's is. pub fn relay(&mut self, line: &str, up: &mut impl Upstream) -> std::io::Result { let msg: Value = match serde_json::from_str(line) { Ok(v) => v, @@ -759,31 +778,42 @@ impl Gateway { // ---- A gated method: the enforcement point. let tool = action.as_str(); - // A retry answering an interim result carries the answers beside the - // arguments. They go to the server, so they are payload; and the - // interim they answer is found by the action and the arguments alone. - let (args, answers) = split_answers(method, args, ¶ms); + // Everything the server receives is payload, sealed and flow-checked + // (bar the protocol's own `_meta` keys: `protocol_meta_key`): the + // arguments; a retry's answers to an interim result, found by the + // action and the arguments alone; and whatever the request carries + // beside them. + let (args, answers, extra) = split_request(method, args, &msg); let args_bytes = serde_json::to_vec(&args).unwrap_or_default(); let round = (action.clone(), ContentId::of_bytes(&args_bytes)); let interim = answers.as_ref().and_then(|_| self.awaiting_input.get(&round).cloned()); let continues = interim.as_ref().map(|i| i.receipt_id); - let (payload, scanned) = match answers { - None => (args_bytes.clone(), args_bytes), - Some(mut p) => { - let none = BTreeSet::new(); - let (keys, state) = match &interim { - Some(i) => (&i.keys, i.request_state.as_ref()), - None => (&none, None), - }; - let answers = authored(p.get("inputResponses"), p.get("requestState"), keys, state); - let scanned = serde_json::to_vec(&json!({"arguments": &args, "answers": answers})) - .unwrap_or_default(); - p.insert("arguments".into(), args); - if let Some(c) = continues { - p.insert("continues".into(), json!(c.to_hex())); - } - (serde_json::to_vec(&Value::Object(p)).unwrap_or_default(), scanned) + let mut parts = vec![args_bytes.clone()]; + if let Some(p) = &answers { + let none = BTreeSet::new(); + let (keys, state) = match &interim { + Some(i) => (&i.keys, i.request_state.as_ref()), + None => (&none, None), + }; + let authored = authored(p.get("inputResponses"), p.get("requestState"), keys, state); + parts.push(serde_json::to_vec(&authored).unwrap_or_default()); + } + if !extra.is_empty() { + parts.push(serde_json::to_vec(&extra).unwrap_or_default()); + } + let scanned = scan_bytes(&parts); + let payload = if answers.is_none() && extra.is_empty() { + args_bytes + } else { + let mut p = answers.unwrap_or_default(); + p.insert("arguments".into(), args); + if let Some(c) = continues { + p.insert("continues".into(), json!(c.to_hex())); } + if !extra.is_empty() { + p.insert("extra".into(), Value::Object(extra)); + } + serde_json::to_vec(&Value::Object(p)).unwrap_or_default() }; // A gated method sent as a notification (no `id`) cannot be answered: @@ -906,9 +936,17 @@ impl Gateway { if let Some(answers) = answers { payload.insert("inputResponses".into(), answers.clone()); } + // The `_meta` goes upstream as sent, so its keys beside the + // protocol's own are sealed and checked with the answers. + let rebuilt = json!({"params": sent}); + let extra = beside(&rebuilt, &["taskId"]); + let mut parts = vec![serde_json::to_vec(&authored(answers, None, &task.request_keys, None)).unwrap_or_default()]; + if !extra.is_empty() { + parts.push(serde_json::to_vec(&extra).unwrap_or_default()); + payload.insert("extra".into(), Value::Object(extra)); + } let payload = serde_json::to_vec(&Value::Object(payload)).unwrap_or_default(); - let scanned = - serde_json::to_vec(&authored(answers, None, &task.request_keys, None)).unwrap_or_default(); + let scanned = scan_bytes(&parts); if id.is_none() { return Ok(self.deny_notification(tool, &payload)); } @@ -1579,7 +1617,7 @@ fn ceiling_digest(grant: &Grant) -> Option { fn declared_witness_ids(params: &Value) -> BTreeSet { jcp_meta(params) .and_then(|m| m.get("witness")) - .or_else(|| params.get("_jcp_witness")) + .or_else(|| params.get(LEGACY_WITNESS_KEY)) .and_then(Value::as_array) .map(|arr| { arr.iter() @@ -1738,26 +1776,127 @@ fn finished(response: &str) -> Option<(String, Option)> { Some((status.to_string(), outcome)) } -/// A gated call's arguments, and apart from them the answers it carries to -/// an interim result: `inputResponses`, with the `requestState` the server -/// handed back, if any. A call that answers nothing has none. A -/// `resources/read` is gated over its whole `params`, so the answers are -/// taken out of that; a `tools/call` or `prompts/get` is gated over its -/// `arguments`, which are left exactly as sent. -fn split_answers(method: &str, mut args: Value, params: &Value) -> (Value, Option>) { - let Some(responses) = params.get("inputResponses") else { return (args, None) }; - let mut answers = serde_json::Map::new(); - answers.insert("inputResponses".into(), responses.clone()); - if let Some(state) = params.get("requestState") { - answers.insert("requestState".into(), state.clone()); - } +/// A gated call's parts: its arguments; the answers it carries to an interim +/// result (`inputResponses`, with the `requestState` the server handed back), +/// if any; and what it carries beside both ([`beside`]). +/// +/// A `tools/call` or `prompts/get` is gated over its `arguments`, which are +/// left exactly as sent. A `resources/read` is gated over its `params`, so +/// the answers and the `_meta` are taken out of those, and with them this +/// gateway's own legacy witness key: they are accounted for apart. +fn split_request( + method: &str, + mut args: Value, + msg: &Value, +) -> (Value, Option>, serde_json::Map) { + let params = msg.get("params").cloned().unwrap_or(Value::Null); if method == "resources/read" { if let Some(obj) = args.as_object_mut() { - obj.remove("inputResponses"); - obj.remove("requestState"); + for k in ["inputResponses", "requestState", "_meta", LEGACY_WITNESS_KEY] { + obj.remove(k); + } } } - (args, Some(answers)) + let answers = params.get("inputResponses").map(|responses| { + let mut answers = serde_json::Map::new(); + answers.insert("inputResponses".into(), responses.clone()); + if let Some(state) = params.get("requestState") { + answers.insert("requestState".into(), state.clone()); + } + answers + }); + let defined: &[&str] = match method { + // Everything else in a read's params is in its payload already. + "resources/read" => &[], + _ => &["name", "arguments"], + }; + (args, answers, beside(msg, defined)) +} + +/// The legacy flat slot for this gateway's witness declarations, read before +/// MCP had a namespaced `_meta` ([`declared_witness_ids`]). +const LEGACY_WITNESS_KEY: &str = "_jcp_witness"; + +/// What a gated request carries that is neither its routing nor its payload, +/// and that the server would otherwise receive unchecked: +/// +/// - `message`: members of the JSON-RPC message beside `jsonrpc`, `id`, +/// `method` and `params`; +/// - `params`: members of `params` beside those the method defines (`defined`, +/// for a call its `name` and `arguments`), the answers to an interim +/// result, `_meta`, and this gateway's legacy witness key — all accounted +/// for elsewhere, and a `resources/read`'s other members are its payload; +/// - `_meta`: `_meta` keys beside the protocol's own ([`protocol_meta_key`]) +/// and this gateway's namespace. +/// +/// Each is sealed and flow-checked with the arguments. Empty for a request +/// with none, whose payload is then its arguments alone, byte for byte as +/// before. +fn beside(msg: &Value, defined: &[&str]) -> serde_json::Map { + let others = |v: Option<&Value>, skip: &dyn Fn(&str) -> bool| -> serde_json::Map { + v.and_then(Value::as_object) + .map(|m| m.iter().filter(|(k, _)| !skip(k)).map(|(k, v)| (k.clone(), v.clone())).collect()) + .unwrap_or_default() + }; + let mut out = serde_json::Map::new(); + let message = others(Some(msg), &|k| matches!(k, "jsonrpc" | "id" | "method" | "params")); + let params = others(msg.get("params"), &|k| { + defined.contains(&k) + || matches!(k, "inputResponses" | "requestState" | "_meta") + || k == LEGACY_WITNESS_KEY + }); + let meta = others(msg.get("params").and_then(|p| p.get("_meta")), &|k| { + k == JCP_META_NS || protocol_meta_key(k) + }); + for (part, m) in [("message", message), ("params", params), ("_meta", meta)] { + if !m.is_empty() { + out.insert(part.into(), Value::Object(m)); + } + } + out +} + +/// Whether a `_meta` key is the protocol's own: `progressToken`, or a key +/// under a prefix MCP reserves for itself (one whose second label is +/// `modelcontextprotocol` or `mcp`, as `io.modelcontextprotocol/`). +/// +/// These are left out of the flow check. They are the protocol's machinery, +/// written by the client's runtime rather than composed by the model, and a +/// server names them back in every reply (`io.modelcontextprotocol/serverInfo` +/// rides in each result's `_meta`): the flow store admits every payload it +/// labels, so folding them would make the first tainted request taint every +/// request after it. They are the one part of a request the server receives +/// unchecked. +fn protocol_meta_key(key: &str) -> bool { + key == "progressToken" + || key.split_once('/').is_some_and(|(prefix, _)| { + prefix.split('.').nth(1).is_some_and(|label| label == "modelcontextprotocol" || label == "mcp") + }) +} + +/// The bytes a gated exchange is flow-checked over, from its parts in order, +/// each as it would be scanned alone. A lone part is scanned as it is, so a +/// call with nothing beside its arguments is scanned exactly as before. +/// Several are joined by a separator of one scan window's length unique to +/// this exchange (a NUL, which JSON text never holds, then a digest of the +/// parts), so no window that spans two parts matches anything but this same +/// exchange: the joining adds no text a later exchange could share. Joining +/// them as one JSON object would add its keys, the same in every exchange of +/// the shape, and the flow store admits every payload it labels, so the first +/// tainted exchange would then taint every one after it. +fn scan_bytes(parts: &[Vec]) -> Vec { + if let [one] = parts { + return one.clone(); + } + let mut framed = Vec::new(); + for p in parts { + framed.extend_from_slice(&(p.len() as u64).to_le_bytes()); + framed.extend_from_slice(p); + } + let digest = ContentId::of_bytes(&framed).0; + let mut sep = vec![0u8]; + sep.extend_from_slice(&digest[..jcp_flow::SCAN_W - 1]); + parts.join(sep.as_slice()) } /// The handles the server minted in a reply for the client to hand back: @@ -3766,6 +3905,197 @@ mod tests { assert_eq!(adv.enforced, JCP_ENFORCED_METHODS); } + // ── everything the server receives is checked (SPEC §4.10) ────────────── + + /// The `_meta` a client of MCP 2026-07-28 puts on every request. + fn protocol_meta() -> Value { + json!({ + "io.modelcontextprotocol/protocolVersion": "2026-07-28", + "io.modelcontextprotocol/clientInfo": {"name": "an-mcp-client-for-tests", "version": "1.0.0"}, + "io.modelcontextprotocol/clientCapabilities": {"extensions": {"io.modelcontextprotocol/tasks": {}}}, + "progressToken": "progress-token-0001", + }) + } + + /// A gateway that has read the inbox, with `send_email` held to + /// principal-clean arguments. + fn read_the_inbox() -> (Gateway, TaskServer) { + let mut g = mail_gw(); + g.set_flow_ceiling("send_email", principal_only()); + let mut up = TaskServer::new(); + g.relay(&tool_call(1, "read_inbox", json!({})), &mut up).unwrap(); + (g, up) + } + + /// A request was decided over its arguments and forwarded whole, so a + /// value could leave beside them. Each way out is now checked, on a + /// gateway of its own: a refused payload is admitted under its taint, and + /// a later one sharing a scan window of it would join that taint. + #[test] + fn a_value_cannot_leave_beside_the_arguments() { + let leak = format!("fwd {INBOX_SECRET}"); + let ways = [ + ( + "a _meta key", + json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": {"name": "send_email", "arguments": {"body": "hello"}, + "_meta": {"org.example/note": leak}}}), + ), + ( + "a params member", + json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": {"name": "send_email", "arguments": {"body": "hello"}, "note": leak}}), + ), + ( + "a message member", + json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "note": leak, + "params": {"name": "send_email", "arguments": {"body": "hello"}}}), + ), + ( + "a _meta key beside the protocol's own", + json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": {"name": "send_email", "arguments": {"body": "hello"}, + "_meta": {"io.modelcontextprotocol/protocolVersion": "2026-07-28", + "org.example/note": leak}}}), + ), + ]; + for (way, request) in ways { + let (mut g, mut up) = read_the_inbox(); + match g.relay(&request.to_string(), &mut up).unwrap() { + Relayed::Denied { reason, .. } => assert_eq!(reason, "tainted-flow", "{way}"), + other => panic!("a value from the inbox left by {way}: {other:?}"), + } + assert_eq!(up.seen.len(), 1, "{way}: nothing past the read reached the server"); + } + + // A clean request carrying the protocol's own `_meta`, and a key of + // its own with a clean value, goes through. + let (mut g, mut up) = read_the_inbox(); + let clean = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", + "params": {"name": "send_email", "arguments": {"body": "hello"}, + "_meta": {"org.example/note": "routine", "io.modelcontextprotocol/protocolVersion": "2026-07-28"}}}); + assert!(matches!(g.relay(&clean.to_string(), &mut up).unwrap(), Relayed::Allowed { .. })); + } + + /// A server whose every reply carries the protocol's `_meta`, as MCP + /// 2026-07-28 asks of a server. + struct ProtocolServer; + impl Upstream for ProtocolServer { + fn call(&mut self, line: &str) -> std::io::Result { + let v: Value = serde_json::from_str(line).unwrap_or(Value::Null); + Ok(json!({"jsonrpc": "2.0", "id": v["id"], "result": { + "resultType": "complete", + "contents": [{"text": "a quarterly report"}], + "_meta": {"io.modelcontextprotocol/serverInfo": {"name": "files", "version": "2.0.0"}}, + }}) + .to_string()) + } + } + + /// A `resources/read` was gated over its whole `params`, `_meta` and all, + /// so once a reply carrying the protocol's `_meta` was admitted, every + /// read carrying it matched that reply and was refused under a ceiling. + #[test] + fn the_protocols_own_meta_does_not_taint_a_clean_request() { + let mut g = Gateway::new( + "files", + &[("resources/read:file:///a", 50_000_000), ("resources/read:file:///b", 50_000_000)], + SigningKey::from_bytes(&[7u8; 32]), + ); + g.set_flow_ceiling("resources/read:file:///b", principal_only()); + let mut up = ProtocolServer; + let read = |id: i64, uri: &str| { + json!({"jsonrpc": "2.0", "id": id, "method": "resources/read", + "params": {"uri": uri, "_meta": protocol_meta()}}) + .to_string() + }; + assert!(matches!(g.relay(&read(1, "file:///a"), &mut up).unwrap(), Relayed::Allowed { .. })); + match g.relay(&read(2, "file:///b"), &mut up).unwrap() { + Relayed::Allowed { .. } => {} + other => panic!("the protocol's own _meta tainted a clean read: {other:?}"), + } + } + + /// A request with nothing beside its arguments but the protocol's `_meta` + /// and this gateway's own is decided over its arguments, byte for byte as + /// before; one with more is decided over that too. + #[test] + fn what_a_request_is_sealed_over() { + let mut g = mail_gw(); + let mut up = TaskServer::new(); + let mut meta = protocol_meta(); + meta[JCP_META_NS] = json!({"witness": []}); + let plain = json!({"jsonrpc": "2.0", "id": 1, "method": "tools/call", + "params": {"name": "send_email", "arguments": {"body": "hello"}, "_meta": meta, + "_jcp_witness": []}}); + g.relay(&plain.to_string(), &mut up).unwrap(); + let sealed = jcr1_payload(&g.receipts()[0], MAIL, &mail_pub()); + let args = serde_json::to_vec(&json!({"body": "hello"})).unwrap(); + assert_eq!(sealed["payload_hash"], ContentId::of_bytes(&args).to_hex().as_str()); + assert!(up.seen[0].contains("io.modelcontextprotocol/protocolVersion"), "the _meta is forwarded"); + + let more = json!({"jsonrpc": "2.0", "id": 2, "method": "tools/call", "note": "n", + "params": {"name": "send_email", "arguments": {"body": "hello"}, "flag": true, + "_meta": {"org.example/note": "routine", "progressToken": "p-1"}}}); + g.relay(&more.to_string(), &mut up).unwrap(); + let sealed = jcr1_payload(&g.receipts()[1], MAIL, &mail_pub()); + let payload = json!({ + "arguments": {"body": "hello"}, + "extra": {"message": {"note": "n"}, "params": {"flag": true}, "_meta": {"org.example/note": "routine"}}, + }); + assert_eq!( + sealed["payload_hash"], + ContentId::of_bytes(&serde_json::to_vec(&payload).unwrap()).to_hex().as_str() + ); + } + + /// A task's exchange forwards the request's `_meta`, so a key in it beside + /// the protocol's own is checked with the answers. + #[test] + fn a_task_exchange_cannot_carry_a_value_out_in_its_meta() { + let mut g = mail_gw(); + g.set_flow_ceiling("read_email", principal_only()); + let mut up = TaskServer::new(); + g.relay(&tool_call(1, "read_email", json!({"folder": "inbox"})), &mut up).unwrap(); + g.relay(&tool_call(2, "read_inbox", json!({})), &mut up).unwrap(); + let poll = |id: i64, meta: Value| { + json!({"jsonrpc": "2.0", "id": id, "method": "tasks/get", "params": {"taskId": TASK_ID, "_meta": meta}}) + .to_string() + }; + assert!(matches!(g.relay(&poll(3, protocol_meta()), &mut up).unwrap(), Relayed::Allowed { .. })); + match g.relay(&poll(4, json!({"org.example/note": INBOX_SECRET})), &mut up).unwrap() { + Relayed::Denied { reason, .. } => assert_eq!(reason, "tainted-flow"), + other => panic!("a poll's _meta carried the inbox out: {other:?}"), + } + } + + /// Joining a request's parts adds no text of its own: two requests whose + /// parts share no scan window share none once joined, so one's taint + /// cannot reach the other through the joining. + #[test] + fn joining_parts_adds_no_text_a_later_request_shares() { + let a = br#"{"body":"first request"}"#.to_vec(); + let b = br#"{"_meta":{"org.example/x":"one"}}"#.to_vec(); + let c = br#"{"text":"another thing entirely"}"#.to_vec(); + let d = br#"[{"accept":"different"}]"#.to_vec(); + assert_eq!(scan_bytes(std::slice::from_ref(&a)), a, "a lone part is scanned as it is"); + let one = jcp_flow::windows(&scan_bytes(&[a.clone(), b.clone()]), jcp_flow::SCAN_W); + let two = jcp_flow::windows(&scan_bytes(&[c.clone(), d.clone()]), jcp_flow::SCAN_W); + assert!(one.is_disjoint(&two), "the joining put shared text into both"); + // A repeat of the same parts is the same bytes, and matches itself. + assert_eq!(scan_bytes(&[a.clone(), b.clone()]), scan_bytes(&[a, b])); + } + + #[test] + fn the_protocols_own_meta_keys() { + for key in ["progressToken", "io.modelcontextprotocol/protocolVersion", "dev.mcp/x", "io.modelcontextprotocol/clientInfo"] { + assert!(protocol_meta_key(key), "{key}"); + } + for key in ["org.example/note", "modelcontextprotocol/x", "io.modelcontextprotocolx/y", "progress", JCP_META_NS, "mcp"] { + assert!(!protocol_meta_key(key), "{key}"); + } + } + /// Decode a verified receipt envelope to its JSON payload (test helper). fn jcr1_payload(envelope: &[u8], steward: &str, pubkey: &[u8; 32]) -> Value { let core = jcp_receipt::jcr1::verify_cose_sign1(envelope, steward, pubkey).unwrap(); -- 2.54.0 (Apple Git-157)