diff --git a/jcp/jcp-rs/Cargo.lock b/jcp/jcp-rs/Cargo.lock index 2ba3fb10e..e30feef88 100644 --- a/jcp/jcp-rs/Cargo.lock +++ b/jcp/jcp-rs/Cargo.lock @@ -598,12 +598,23 @@ dependencies = [ "nvml-wrapper", ] +[[package]] +name = "jcp-flow" +version = "0.1.0" +dependencies = [ + "blake3", + "jcp-schema", + "serde", + "thiserror 2.0.18", +] + [[package]] name = "jcp-gateway" version = "0.1.0" dependencies = [ "ed25519-dalek", "jcp-bridge", + "jcp-flow", "jcp-grant", "jcp-receipt", "jcp-runtime", diff --git a/jcp/jcp-rs/Cargo.toml b/jcp/jcp-rs/Cargo.toml index fcb6f59b4..6779a01fe 100644 --- a/jcp/jcp-rs/Cargo.toml +++ b/jcp/jcp-rs/Cargo.toml @@ -22,6 +22,7 @@ members = [ "crates/jcp-orchestrate", "crates/jcp-intercept", "crates/jcp-efficiency", + "crates/jcp-flow", ] [workspace.package] diff --git a/jcp/jcp-rs/crates/jcp-flow/Cargo.toml b/jcp/jcp-rs/crates/jcp-flow/Cargo.toml new file mode 100644 index 000000000..6e7aa8545 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-flow/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "jcp-flow" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +rust-version.workspace = true +description = "Propagated provenance for JCP — the witness scan and session store that fold a derived value's flow Label from the tool outputs it was computed from, so authorization can be conditioned on where a value CAME FROM, not just its literal bytes. An exact blake3 window index detects verbatim carry across tool calls; each verdict emits a content-addressed, offline-checkable Witness." + +[dependencies] +jcp-schema = { workspace = true } +blake3 = { workspace = true } +serde = { workspace = true } +thiserror = { workspace = true } + +[lints] +workspace = true diff --git a/jcp/jcp-rs/crates/jcp-flow/src/lib.rs b/jcp/jcp-rs/crates/jcp-flow/src/lib.rs new file mode 100644 index 000000000..c355ac11b --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-flow/src/lib.rs @@ -0,0 +1,235 @@ +//! Propagated provenance for JCP — the witness scan and session store. +//! +//! The [flow lattice](jcp_schema::flow) says what a label *is* and how two labels +//! fold. This crate computes labels from real bytes: as a run reads tool outputs +//! and derives new values, the [`FlowStore`] tracks where each value's bytes came +//! from and folds a derived value's label from its ancestors. The result is that +//! authorization can be conditioned on **where a value came from**, not merely on +//! its literal bytes — the gap JCP's one-bit `Trust` tag cannot express. +//! +//! Two things make this a reference monitor rather than a heuristic: +//! +//! - The carry detector is an **exact** `blake3` window index. Two values share a +//! fingerprint exactly when they share a verbatim [`SCAN_W`]-byte run — no +//! rolling-hash state to get wrong, and the same bytes fingerprint identically +//! on every platform. +//! - Every verdict emits a self-contained [`Witness`]: the ancestor cut, the taint +//! edges, and the folded label, content-addressed. A receipt commits only its +//! `id()`; an auditor re-folds it offline to re-derive the verdict +//! ([`verify_witness`]). +//! +//! **Scope.** This crate is the propagation and its offline witness check. The +//! runtime *gate* (does a label pass a grant's ceiling), the `jcp/3` receipt seal, +//! and the gateway relay that drives them are later layers; see the FL-2 plan. +//! +//! **Honest residuals.** The scan sees verbatim carry, not meaning: base64, +//! translation, or paraphrase defeat it, and soundness then rests on a registered +//! sensitive term or the `HighWater` floor. Sub-`SCAN_W` unregistered secrets are +//! invisible to the scan. `verify_witness` checks *consistency* (the cut folds to +//! the sealed label), not *truth* against original bytes — that needs an opt-in +//! disclosure tier. The lattice laws prove taint never decreases and a verdict is +//! reproducible; none of them says prompt injection cannot succeed. + +pub mod store; +pub mod witness; + +pub use store::{ambient_source, windows, FlowStore, Mode, ScanParams, SCAN_W}; +pub use witness::{ + verify_witness, CutNode, Edge, EdgeKind, Witness, WitnessError, WITNESS_SCHEMA, +}; + +#[cfg(test)] +mod tests { + use super::*; + use jcp_schema::flow::{FlowCeiling, Integrity, Label, Origins}; + use jcp_schema::{ContentId, Did}; + use std::collections::BTreeSet; + + fn channel(byte: u8) -> ContentId { + ContentId::of_bytes(&[byte; 8]) + } + fn anchor() -> ContentId { + ContentId([0u8; 32]) + } + fn no_declared() -> BTreeSet { + BTreeSet::new() + } + + #[test] + fn windows_are_platform_independent_and_exact() { + // Same bytes ⇒ same fingerprint set, regardless of who computes them. + let a = windows(b"the quick brown fox jumps", SCAN_W); + let b = windows(b"the quick brown fox jumps", SCAN_W); + assert_eq!(a, b); + assert!(!a.is_empty()); + // Fewer than W bytes ⇒ no windows. + assert!(windows(b"short", SCAN_W).is_empty()); + // A 25-byte string has 25-16+1 = 10 windows. + assert_eq!(windows(b"the quick brown fox jumps", SCAN_W).len(), 10); + } + + #[test] + fn l17_carry_of_at_least_w_bytes_yields_a_witnessed_edge() { + let mut store = FlowStore::new(64); + let secret = b"ATTACKER-CONTROLLED-PAYLOAD-abcdefghij"; // > 16 bytes + let (email_id, email_label) = store.admit_output( + secret, + channel(1), + Some(Did([9u8; 32])), + &Label::bottom(), + jcp_schema::flow::Readers::Public, + &[], + anchor(), + ); + assert_eq!(email_label.integrity, Integrity::Untrusted); + + // A "summary" that carries a verbatim run of the email. + let summary = b"Summary: ATTACKER-CONTROLLED-PAYLOAD-abcdefghij was mentioned."; + let (_id, label, witness) = store.label_payload(summary, &no_declared(), Mode::Explicit, anchor()); + + let edge = witness.edges.iter().find(|e| e.parent == email_id).expect("edge to the email"); + assert_eq!(edge.kind, EdgeKind::Witnessed, "L17: verbatim carry is witnessed"); + assert!(!edge.grams.is_empty(), "the overlap is proven by grams"); + // The taint propagated: the summary is now untrusted and carries the origin. + assert_eq!(label.integrity, Integrity::Untrusted); + assert!(!label.origins.is_empty(), "summary carries the email's origin"); + assert!(verify_witness(&witness).is_ok()); + } + + #[test] + fn sub_w_unregistered_carry_is_not_found() { + // The documented residual, pinned as a test so it can never be mistaken for + // a guarantee: a short verbatim token below the window floor, not registered, + // produces no witnessed edge. + let mut store = FlowStore::new(64); + let (_id, _l) = store.admit_output(b"code=A1B2C3", channel(1), None, &Label::bottom(), + jcp_schema::flow::Readers::Public, &[], anchor()); + let (_id, label, witness) = + store.label_payload(b"the value A1B2C3 appears here", &no_declared(), Mode::Explicit, anchor()); + assert!(witness.edges.is_empty(), "a sub-W token yields no scan edge"); + assert!(label.is_bottom(), "so nothing propagates"); + } + + #[test] + fn l19_a_registered_sensitive_term_of_any_length_is_found() { + let mut store = FlowStore::new(64); + // An 11-char account number, registered by the boundary adapter. + let (src_id, _l) = store.admit_output(b"balance page", channel(2), None, &Label::bottom(), + jcp_schema::flow::Readers::Public, &[b"12345678901"], anchor()); + + let (_id, label, witness) = + store.label_payload(b"transfer from 12345678901 now", &no_declared(), Mode::Explicit, anchor()); + let edge = witness.edges.iter().find(|e| e.parent == src_id).expect("edge to the source"); + assert_eq!(edge.kind, EdgeKind::Witnessed, "L19: a registered term taints even below W"); + assert_eq!(edge.witnessed_bytes, 11); + assert!(!label.is_bottom()); + assert!(verify_witness(&witness).is_ok()); + } + + #[test] + fn l18_a_shared_window_only_ever_adds_a_parent() { + // Over-approximation soundness: two admitted values sharing a window both + // become parents of anything carrying it — a match is never missed, and a + // (spurious) collision would only add taint, never remove it. + let mut store = FlowStore::new(64); + let run = b"SHARED-VERBATIM-RUN-0123456789"; + let (a, _) = store.admit_output(run, channel(1), None, &Label::bottom(), + jcp_schema::flow::Readers::Public, &[], anchor()); + let (b, _) = store.admit_output(run, channel(2), None, &Label::bottom(), + jcp_schema::flow::Readers::Public, &[], anchor()); + // Distinct channels ⇒ distinct content? No — identical bytes ⇒ same id. + assert_eq!(a, b, "identical bytes content-address identically"); + + // Two *different* payloads sharing one window. + let mut store = FlowStore::new(64); + let (p, _) = store.admit_output(b"prefix SHARED-VERBATIM-RUN-0123456789 alpha", + channel(1), None, &Label::bottom(), jcp_schema::flow::Readers::Public, &[], anchor()); + let (q, _) = store.admit_output(b"beta SHARED-VERBATIM-RUN-0123456789 suffix", + channel(2), None, &Label::bottom(), jcp_schema::flow::Readers::Public, &[], anchor()); + assert_ne!(p, q); + let (_id, _label, witness) = store.label_payload( + b"carries SHARED-VERBATIM-RUN-0123456789 from both", &no_declared(), Mode::Explicit, anchor()); + let parents: BTreeSet = witness.edges.iter().map(|e| e.parent).collect(); + assert!(parents.contains(&p) && parents.contains(&q), "both sharers are parents, never missed"); + } + + #[test] + fn l20_verify_refolds_the_cut_and_catches_tampering() { + let mut store = FlowStore::new(64); + store.admit_output(b"ATTACKER-CONTROLLED-PAYLOAD-abcdefghij", channel(1), Some(Did([9u8; 32])), + &Label::bottom(), jcp_schema::flow::Readers::Public, &[], anchor()); + let (_id, _label, witness) = store.label_payload( + b"echo ATTACKER-CONTROLLED-PAYLOAD-abcdefghij", &no_declared(), Mode::Explicit, anchor()); + assert!(verify_witness(&witness).is_ok(), "a well-formed witness verifies"); + + // Tamper the sealed label to look clean; the refold of the cut exposes it. + let mut forged = witness.clone(); + forged.label = Label::bottom(); + assert_eq!(verify_witness(&forged), Err(WitnessError::FoldMismatch)); + } + + #[test] + fn a_clean_payload_stays_bottom_and_is_byte_identical_ground() { + let mut store = FlowStore::new(64); + store.admit_principal(b"a principal instruction", anchor()); + let (_id, label, witness) = + store.label_payload(b"an unrelated derived value", &no_declared(), Mode::Explicit, anchor()); + assert!(label.is_bottom(), "no untrusted ancestor ⇒ ⊥"); + assert!(witness.edges.is_empty()); + assert!(verify_witness(&witness).is_ok()); + } + + #[test] + fn a_declared_but_absent_parent_fails_closed_to_top() { + let mut store = FlowStore::new(64); + let phantom = ContentId::of_bytes(b"a value never admitted"); + let declared: BTreeSet = [phantom].into_iter().collect(); + let (_id, label, witness) = + store.label_payload(b"derived from something we lost", &declared, Mode::Explicit, anchor()); + assert_eq!(label, Label::top(), "a lost declared dependency folds to ⊤"); + assert!(verify_witness(&witness).is_ok(), "the ⊤ fold is witness-consistent"); + } + + #[test] + fn the_attack_shape_a_ceiling_would_deny() { + // The store side of read_email → summarize → send_email: after summarizing + // an attacker email, the summary's label does not sit below a Principal-only + // ceiling, which is exactly what the runtime gate will reject. + let mut store = FlowStore::new(64); + store.admit_output(b"From attacker: EXFIL-THIS-SECRET-abcdefghij", channel(7), + Some(Did([3u8; 32])), &Label::bottom(), jcp_schema::flow::Readers::Public, &[], anchor()); + let (_id, summary_label, _w) = store.label_payload( + b"The email said EXFIL-THIS-SECRET-abcdefghij", &no_declared(), Mode::Explicit, anchor()); + + // A send_email grant permitting only clean, principal-origin arguments. + let ceiling = FlowCeiling::new(Label { + integrity: Integrity::Principal, + origins: Origins::none(), + readers: jcp_schema::flow::Readers::Public, + }); + assert!(!ceiling.admits(&summary_label), "the tainted summary must not pass the ceiling"); + // And a bottom argument still would. + assert!(ceiling.admits(&Label::bottom())); + } + + #[test] + fn high_water_floor_taints_a_laundered_payload_when_the_scan_cannot() { + // The negative companion to the carry test: base64/paraphrase defeats the + // window index, but HighWater's session floor still denies at the sink. + let mut store = FlowStore::new(64); + store.admit_output(b"ATTACKER-CONTROLLED-PAYLOAD-abcdefghij", channel(1), + Some(Did([9u8; 32])), &Label::bottom(), jcp_schema::flow::Readers::Public, &[], anchor()); + + // A laundered value sharing no window with the email. + let laundered = b"totally different words with no overlap whatsoever xyzzy"; + let (_id, explicit_label, _w) = + store.label_payload(laundered, &no_declared(), Mode::Explicit, anchor()); + assert!(explicit_label.is_bottom(), "the scan alone cannot see laundering (documented residual)"); + + // But HighWater joins the floor, which the untrusted read already raised. + let (_id, hw_label, witness) = + store.label_payload(laundered, &no_declared(), Mode::HighWater, anchor()); + assert_eq!(hw_label.integrity, Integrity::Untrusted, "the floor still taints the sink"); + assert!(witness.edges.iter().any(|e| e.kind == EdgeKind::Ambient)); + } +} diff --git a/jcp/jcp-rs/crates/jcp-flow/src/store.rs b/jcp/jcp-rs/crates/jcp-flow/src/store.rs new file mode 100644 index 000000000..2042d0161 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-flow/src/store.rs @@ -0,0 +1,353 @@ +//! The session store and the exact carry detector. +//! +//! The store holds every value a run has admitted — tool outputs, principal +//! messages — each with its flow label, indexed by an **exact** window +//! fingerprint. When a later value is computed, [`FlowStore::label_payload`] folds +//! its label from the ancestors it verbatim-carries bytes from, plus any the +//! caller declared, plus (optionally) a coarse session floor. +//! +//! The detector is an exact `blake3` window index, deliberately *not* a rolling +//! hash: a rolling hash carries state between windows and a carry-arithmetic bug +//! silently misses overlaps. Here each W-byte window is hashed independently, so +//! two byte sequences share a fingerprint exactly when they share a verbatim +//! W-byte run — provable, platform-independent, with no state to get wrong. + +use std::collections::{BTreeMap, BTreeSet}; + +use jcp_schema::flow::{Integrity, Label, Origin, Origins, Readers}; +use jcp_schema::{ContentId, Did}; +use serde::{Deserialize, Serialize}; + +use crate::witness::{CutNode, Edge, EdgeKind, Witness, WITNESS_SCHEMA}; + +/// The detection floor: overlaps of at least this many verbatim bytes are found; +/// shorter runs are not (a registered sensitive term closes the sub-window gap). +pub const SCAN_W: usize = 16; + +/// Domain separation for the window hash, so a fingerprint here cannot collide by +/// construction with any other `blake3` use in the system. +const WINDOW_DOMAIN: &[u8] = b"jcp/flow/window/v1"; + +/// The scan parameters pinned into every [`Witness`], so a replay uses the exact +/// window size and the exact sensitive-term commitment the verdict was made under. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ScanParams { + pub w: u16, + /// Content address of the registered sensitive-term set at emission time. + pub sensitive_root: ContentId, +} + +/// Every W-byte window of `bytes`, each hashed independently with a domain-keyed +/// `blake3` and truncated to `u64`. Fewer than `w` bytes yields no windows — the +/// sub-window gap the sensitive-term index exists to cover. +pub fn windows(bytes: &[u8], w: usize) -> BTreeSet { + let mut out = BTreeSet::new(); + if w == 0 || bytes.len() < w { + return out; + } + let key = *blake3::hash(WINDOW_DOMAIN).as_bytes(); + for start in 0..=bytes.len() - w { + let digest = blake3::keyed_hash(&key, &bytes[start..start + w]); + let mut eight = [0u8; 8]; + eight.copy_from_slice(&digest.as_bytes()[0..8]); + out.insert(u64::from_le_bytes(eight)); + } + out +} + +/// Naive verbatim substring search over bytes — the sub-window sensitive-term test. +fn contains(haystack: &[u8], needle: &[u8]) -> bool { + if needle.is_empty() || needle.len() > haystack.len() { + return false; + } + haystack.windows(needle.len()).any(|window| window == needle) +} + +/// Which mode [`FlowStore::label_payload`] folds in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Mode { + /// Only explicit evidence: verbatim carry, registered terms, and declarations. + Explicit, + /// Adds the monotone session floor — everything after the first untrusted read + /// joins it. A blunt over-approximation of implicit flow; sound, not precise. + HighWater, +} + +/// What the store knows about one admitted value. +#[derive(Clone, Debug)] +struct Admitted { + label: Label, + /// Receipt-log head at admission — the anchor a receipt replay uses to prove + /// this value existed before anything derived from it. + anchor: ContentId, + /// This value's own window fingerprints, kept so eviction can remove them + /// without rescanning. + windows: BTreeSet, +} + +/// The session's flow state: labels, the exact window index, registered sensitive +/// terms, and the coarse floor. +pub struct FlowStore { + admitted: BTreeMap, + /// fingerprint → the admitted ids that contain that window. + window_index: BTreeMap>, + /// registered short/exact secret → the source ids that carry it. + sensitive: BTreeMap, BTreeSet>, + /// FIFO admission order, for deterministic eviction. + order: Vec, + /// The monotone `HighWater` floor. + high_water: Label, + /// Receipt-log head at the last admission — the next witness's anchor. + head: ContentId, + cap: usize, + w: usize, +} + +/// The sentinel parent id an `Ambient` edge points at (the session floor has no +/// single source payload). +pub fn ambient_source() -> ContentId { + ContentId::of_bytes(b"jcp/flow/high-water") +} + +impl FlowStore { + /// A store holding at most `cap` values (FIFO eviction past that) with window + /// size [`SCAN_W`]. + pub fn new(cap: usize) -> Self { + FlowStore { + admitted: BTreeMap::new(), + window_index: BTreeMap::new(), + sensitive: BTreeMap::new(), + order: Vec::new(), + high_water: Label::bottom(), + head: ContentId([0u8; 32]), + cap: cap.max(1), + w: SCAN_W, + } + } + + pub fn head(&self) -> ContentId { + self.head + } + + pub fn get(&self, id: &ContentId) -> Option<&Label> { + self.admitted.get(id).map(|entry| &entry.label) + } + + /// The content address of the registered sensitive-term set — pinned into a + /// witness so a replay knows which terms were in force. + pub fn sensitive_root(&self) -> ContentId { + let terms: Vec<&Vec> = self.sensitive.keys().collect(); + ContentId::of(&terms).expect("term set is canonically serializable") + } + + fn params(&self) -> ScanParams { + ScanParams { w: self.w as u16, sensitive_root: self.sensitive_root() } + } + + fn register_terms(&mut self, id: ContentId, terms: &[&[u8]]) { + for term in terms { + if term.is_empty() { + continue; + } + self.sensitive.entry(term.to_vec()).or_default().insert(id); + } + } + + /// Insert a value with a fixed label, indexing its windows and advancing the + /// floor. Shared by the boundary admissions and by `label_payload`. + fn admit(&mut self, bytes: &[u8], label: Label, anchor: ContentId) -> ContentId { + let id = ContentId::of_bytes(bytes); + let value_windows = windows(bytes, self.w); + for fingerprint in &value_windows { + self.window_index.entry(*fingerprint).or_default().insert(id); + } + // A re-admission of identical bytes keeps the earlier (already-anchored) + // entry; its label is content-determined and equal. + if !self.admitted.contains_key(&id) { + self.order.push(id); + } + self.admitted.insert(id, Admitted { label: label.clone(), anchor, windows: value_windows }); + self.high_water = self.high_water.join(&label); + self.evict_if_needed(); + id + } + + fn evict_if_needed(&mut self) { + while self.order.len() > self.cap { + let victim = self.order.remove(0); + if let Some(entry) = self.admitted.remove(&victim) { + for fingerprint in &entry.windows { + if let Some(ids) = self.window_index.get_mut(fingerprint) { + ids.remove(&victim); + if ids.is_empty() { + self.window_index.remove(fingerprint); + } + } + } + } + // An evicted id, if later declared, is named-but-absent ⇒ folded to ⊤ + // (fail closed). Its sensitive registrations are left; a term still + // points at a gone id, which `label_payload` treats as absent ⇒ ⊤. + } + } + + /// Boundary: admit a tool **result**. Its label is the channel origin (always + /// `Untrusted`, joined with the args that produced the call and the tool's + /// reader policy), and any adapter-declared sensitive terms are registered. + pub fn admit_output( + &mut self, + bytes: &[u8], + channel: ContentId, + principal: Option, + args: &Label, + readers: Readers, + terms: &[&[u8]], + anchor: ContentId, + ) -> (ContentId, Label) { + let origin = match principal { + Some(did) => Origin::from(channel, did), + None => Origin::channel(channel), + }; + let channel_label = Label { + integrity: Integrity::Untrusted, + origins: Origins::one(origin), + readers, + }; + let label = channel_label.join(args); + let id = self.admit(bytes, label.clone(), anchor); + self.register_terms(id, terms); + self.head = id; + (id, label) + } + + /// Boundary: admit a **principal** message — a clean, bottom-labelled value. + pub fn admit_principal(&mut self, bytes: &[u8], anchor: ContentId) -> (ContentId, Label) { + let id = self.admit(bytes, Label::bottom(), anchor); + self.head = id; + (id, Label::bottom()) + } + + /// The propagation entry point: fold a payload's label from every ancestor it + /// verbatim-carries bytes from (`Witnessed`), every registered sensitive term + /// it contains (`Witnessed`), every caller-declared parent (`Declared`, or ⊤ + /// if that parent is absent — fail closed), and — in `HighWater` mode — the + /// session floor (`Ambient`). Admits the payload under that label and returns a + /// self-contained [`Witness`]. + pub fn label_payload( + &mut self, + bytes: &[u8], + declared: &BTreeSet, + mode: Mode, + anchor: ContentId, + ) -> (ContentId, Label, Witness) { + let subject_windows = windows(bytes, self.w); + let params = self.params(); + + // 1. Window-found parents and the grams that prove each overlap. + let mut grams_by_parent: BTreeMap> = BTreeMap::new(); + for fingerprint in &subject_windows { + if let Some(ids) = self.window_index.get(fingerprint) { + for id in ids { + grams_by_parent.entry(*id).or_default().insert(*fingerprint); + } + } + } + + // 2. Sensitive-found parents and the longest matching term per parent. + let mut sensitive_bytes: BTreeMap = BTreeMap::new(); + for (term, sources) in &self.sensitive { + if contains(bytes, term) { + for source in sources { + let entry = sensitive_bytes.entry(*source).or_insert(0); + *entry = (*entry).max(term.len()); + } + } + } + + // 3. Assemble the cut and edges over the union of parents. + let mut parent_ids: BTreeSet = BTreeSet::new(); + parent_ids.extend(grams_by_parent.keys().copied()); + parent_ids.extend(sensitive_bytes.keys().copied()); + parent_ids.extend(declared.iter().copied()); + + let mut cut: Vec = Vec::new(); + let mut edges: Vec = Vec::new(); + + for parent in &parent_ids { + match self.admitted.get(parent) { + Some(entry) => { + cut.push(CutNode { id: *parent, label: entry.label.clone(), anchor: entry.anchor }); + if let Some(grams) = grams_by_parent.get(parent) { + // Verbatim overlap: at least one W-window matched, so at + // least W bytes are verbatim (a sound lower bound). + edges.push(Edge { + parent: *parent, + kind: EdgeKind::Witnessed, + witnessed_bytes: self.w as u64, + grams: grams.clone(), + }); + } else if let Some(bytes_len) = sensitive_bytes.get(parent) { + // A registered term appears verbatim. Below the window floor + // there are no grams; the byte count is the evidence. + edges.push(Edge { + parent: *parent, + kind: EdgeKind::Witnessed, + witnessed_bytes: *bytes_len as u64, + grams: BTreeSet::new(), + }); + } else { + // Declared with no verbatim overlap: verifiable only via a + // named artifact. + edges.push(Edge { + parent: *parent, + kind: EdgeKind::Declared, + witnessed_bytes: 0, + grams: BTreeSet::new(), + }); + } + } + None => { + // Declared but absent (e.g. evicted): fail closed — fold ⊤ so + // the subject inherits maximal taint rather than silently + // dropping a dependency. + cut.push(CutNode { id: *parent, label: Label::top(), anchor: ContentId([0u8; 32]) }); + edges.push(Edge { + parent: *parent, + kind: EdgeKind::Declared, + witnessed_bytes: 0, + grams: BTreeSet::new(), + }); + } + } + } + + // 4. HighWater: join the coarse session floor as an Ambient edge. + if mode == Mode::HighWater && !self.high_water.is_bottom() { + let ambient = ambient_source(); + cut.push(CutNode { id: ambient, label: self.high_water.clone(), anchor: self.head }); + edges.push(Edge { + parent: ambient, + kind: EdgeKind::Ambient, + witnessed_bytes: 0, + grams: BTreeSet::new(), + }); + } + + let label = jcp_schema::flow::fold( + &cut.iter().map(|node| node.label.clone()).collect::>(), + ); + + let id = self.admit(bytes, label.clone(), anchor); + self.head = id; + + let witness = Witness { + schema: WITNESS_SCHEMA.to_string(), + params, + subject: id, + label: label.clone(), + cut, + edges, + }; + (id, label, witness) + } +} diff --git a/jcp/jcp-rs/crates/jcp-flow/src/witness.rs b/jcp/jcp-rs/crates/jcp-flow/src/witness.rs new file mode 100644 index 000000000..c9581d918 --- /dev/null +++ b/jcp/jcp-rs/crates/jcp-flow/src/witness.rs @@ -0,0 +1,145 @@ +//! The witness — the content-addressed, offline-checkable record of *why* a value +//! carries the label it does. +//! +//! A receipt commits only the witness's `id()`. An entitled auditor fetches the +//! witness and rechecks it: re-fold the disclosed ancestor cut and confirm it +//! reproduces the sealed label ([`verify_witness`]). That is what makes a +//! provenance verdict replayable by a third party rather than a claim to trust. + +use std::collections::BTreeSet; + +use jcp_schema::{flow::Label, ContentId}; +use serde::{Deserialize, Serialize}; + +/// How strongly a taint edge is justified — sealed, so a receipt shows whether a +/// verdict rested on hard bytes or the conservative floor. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum EdgeKind { + /// A verbatim overlap the scan found between this value and an ancestor: the + /// grams prove it and a third party rechecks them. The strongest evidence. + Witnessed, + /// The caller (or a registered sensitive term) named this ancestor. Verifiable + /// only with the named artifact — a citation, an endorsement, or the term set. + Declared, + /// The coarse session floor (`HighWater` mode): everything after the first + /// untrusted read joins it. Sound but imprecise, and flagged as such in replay. + Ambient, +} + +fn u64_is_zero(value: &u64) -> bool { + *value == 0 +} + +/// One taint edge: a derived value depends on an ancestor payload, and this says +/// how that was established. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Edge { + /// The ancestor payload's content id — its key in the [`crate::FlowStore`] and + /// the cut node it justifies. + pub parent: ContentId, + pub kind: EdgeKind, + /// For a `Witnessed` edge, how many verbatim bytes were matched (informational; + /// the grams are the proof). + #[serde(skip_serializing_if = "u64_is_zero", default)] + pub witnessed_bytes: u64, + /// The shared window fingerprints proving the overlap. A verifier rechecks + /// these are a subset of both endpoints' window sets. Empty for `Declared` and + /// `Ambient` edges, and for a sub-window sensitive-term match. + #[serde(skip_serializing_if = "BTreeSet::is_empty", default)] + pub grams: BTreeSet, +} + +/// A direct ancestor in the cut: its content id, the label it was admitted with, +/// and the receipt-log head at admission time (the anchor a receipt replay uses to +/// prove the read happened before this value existed). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct CutNode { + pub id: ContentId, + pub label: Label, + pub anchor: ContentId, +} + +/// The minimal ancestor cut, its edges, and the folded label — self-contained and +/// content-addressed. The one artifact a third party needs (beyond the receipt +/// chain) to re-derive a flow verdict offline. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Witness { + /// Schema tag, `"flow/1"`. + pub schema: String, + /// The scan parameters in force when this witness was emitted — pinned so a + /// replay uses the exact window size and sensitive-term commitment. + pub params: crate::store::ScanParams, + /// The payload this witness explains (`== frame.payload_hash` of the call). + pub subject: ContentId, + /// The folded effective label — what the ceiling was tested against. + pub label: Label, + /// Each direct ancestor. + pub cut: Vec, + /// How each ancestor taints the subject. + pub edges: Vec, +} + +/// The witness schema tag. +pub const WITNESS_SCHEMA: &str = "flow/1"; + +impl Witness { + /// The content id a receipt commits to. Order-free because every field is + /// canonically serialized (`BTree`/`Vec` in insertion order the store fixes). + pub fn id(&self) -> ContentId { + ContentId::of(self).expect("witness is canonically serializable") + } +} + +/// Why a witness failed to verify. +#[derive(Debug, thiserror::Error, PartialEq, Eq)] +pub enum WitnessError { + #[error("witness schema is not {WITNESS_SCHEMA}")] + BadSchema, + /// Re-folding the cut did not reproduce the sealed label — the witness does not + /// justify its own verdict (violates the fold law L20). + #[error("re-folded label does not match the sealed label")] + FoldMismatch, + /// An edge names a parent that is not in the cut. + #[error("edge parent {0:?} is not in the cut")] + DanglingEdge(ContentId), + /// A `Witnessed` edge carries no evidence (no grams and zero bytes). + #[error("witnessed edge to {0:?} carries no evidence")] + EmptyWitnessedEdge(ContentId), +} + +/// Verify a witness against itself: the schema is right, every edge points at a +/// cut node, every `Witnessed` edge carries evidence, and — the load-bearing check +/// (law L20) — re-folding the cut's labels reproduces the sealed label. +/// +/// This is the *consistency* tier: it proves the witness is internally coherent and +/// that its verdict follows from its own cut. It does **not** re-derive the grams +/// from the original bytes; that truth tier requires disclosing the endpoint bytes +/// and is a separate, opt-in check (see the crate residuals). Consistency is enough +/// to catch a witness that was tampered to soften its own verdict, because the +/// label is refolded from the cut rather than trusted. +pub fn verify_witness(witness: &Witness) -> Result<(), WitnessError> { + if witness.schema != WITNESS_SCHEMA { + return Err(WitnessError::BadSchema); + } + + let cut_ids: BTreeSet<&ContentId> = witness.cut.iter().map(|node| &node.id).collect(); + for edge in &witness.edges { + if !cut_ids.contains(&edge.parent) { + return Err(WitnessError::DanglingEdge(edge.parent)); + } + if edge.kind == EdgeKind::Witnessed && edge.grams.is_empty() && edge.witnessed_bytes == 0 { + return Err(WitnessError::EmptyWitnessedEdge(edge.parent)); + } + } + + // L20 — re-fold the cut. Join is commutative and associative, so any order + // reproduces the same label; a tampered `label` field is caught here. + let refolded = jcp_schema::flow::fold( + &witness.cut.iter().map(|node| node.label.clone()).collect::>(), + ); + if refolded != witness.label { + return Err(WitnessError::FoldMismatch); + } + Ok(()) +} diff --git a/jcp/jcp-rs/crates/jcp-gateway/Cargo.toml b/jcp/jcp-rs/crates/jcp-gateway/Cargo.toml index 111e9ad09..ce35e114d 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/Cargo.toml +++ b/jcp/jcp-rs/crates/jcp-gateway/Cargo.toml @@ -13,6 +13,7 @@ jcp-grant = { workspace = true } jcp-runtime = { workspace = true } jcp-receipt = { path = "../jcp-receipt" } jcp-bridge = { path = "../jcp-bridge" } +jcp-flow = { path = "../jcp-flow" } sandbox-meter = { path = "../../../../sandbox/sandbox-rs/crates/sandbox-meter" } ed25519-dalek = { workspace = true } serde_json = { workspace = true } diff --git a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs index df97b91e9..da9073ca4 100644 --- a/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs +++ b/jcp/jcp-rs/crates/jcp-gateway/src/lib.rs @@ -29,14 +29,16 @@ //! stdio binary implements over a child process and tests implement as a mock. use ed25519_dalek::SigningKey; -use jcp_bridge::{mcp_call_frame, mcp_resource}; +use jcp_bridge::{mcp_call_frame, mcp_resource, mcp_result_frame}; +use jcp_flow::{FlowStore, Mode, Witness}; use jcp_grant::issue; -use jcp_receipt::ReceiptLog; +use jcp_receipt::{FlowSeal, ReceiptLog}; use jcp_runtime::Runtime; -use jcp_schema::{Capability, Did, Energy, Grant, SignedGrant}; +use jcp_schema::flow::{FlowCeiling, Readers}; +use jcp_schema::{Capability, Caveat, ContentId, Did, Energy, FlowFacts, Grant, SignedGrant}; use sandbox_meter::PowerMeter; use serde_json::{json, Value}; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; /// The upstream MCP server, abstracted so the gateway's policy is testable /// without spawning a process. `call` sends one JSON-RPC request line and @@ -69,6 +71,18 @@ pub struct Gateway { meter: PowerMeter, log: ReceiptLog, now: u64, + /// Signs re-issued grants when a flow ceiling is attached to a tool. The same + /// fixed local issuer `new` uses — the consumer is its own grant authority. + issuer_sk: SigningKey, + /// Propagated provenance across this session's calls: a tool result is admitted + /// as a labelled source, and a later call's arguments are folded against it. + flow: FlowStore, + /// How flow is folded — explicit evidence only, or with the coarse `HighWater` + /// session floor. + mode: Mode, + /// The witnesses this session emitted, by id — the disclosure store an auditor + /// fetches to re-derive a sealed verdict. + witnesses: BTreeMap, } impl Gateway { @@ -105,9 +119,45 @@ impl Gateway { meter: PowerMeter::detect(), log: ReceiptLog::new(steward_sk, format!("did:jcp:gateway:{server}")), now: 0, + issuer_sk: issuer, + flow: FlowStore::new(4096), + mode: Mode::Explicit, + witnesses: BTreeMap::new(), } } + /// Attach a flow ceiling to a tool: re-issue its grant with a + /// [`Caveat::FlowBelow`], so a call whose arguments carry provenance above the + /// ceiling is denied at the gate. A no-op if the tool is not granted. + pub fn set_flow_ceiling(&mut self, tool: &str, ceiling: FlowCeiling) { + let Some(sg) = self.grants.get(tool) else { return }; + let mut grant = sg.grant.clone(); + grant.caveats.retain(|c| c.flow_ceiling().is_none()); + grant.caveats.push(Caveat::FlowBelow(ceiling)); + if let Ok(reissued) = issue(&self.issuer_sk, grant) { + self.grants.insert(tool.to_string(), reissued); + } + } + + /// Turn on the coarse `HighWater` session floor — every call after the first + /// untrusted read joins the session's taint. A blunt over-approximation of + /// implicit flow; off by default. + pub fn enable_high_water(&mut self) { + self.mode = Mode::HighWater; + } + + /// Forbid values carrying `mask`'s origins from reaching `tool`, regardless of + /// grant — an enforcement-point taint floor (SPEC §4.10). + pub fn forbid_flow(&mut self, tool: &str, mask: jcp_schema::flow::Origins) { + self.runtime.forbid_flow(mcp_resource(&self.server, tool), mask); + } + + /// The witness for a sealed flow verdict, by id — the disclosure an auditor + /// fetches to re-derive the verdict offline. + pub fn witness(&self, id: &ContentId) -> Option<&Witness> { + self.witnesses.get(id) + } + /// The granted tool names, sorted. pub fn tools(&self) -> Vec<&str> { self.grants.keys().map(String::as_str).collect() @@ -159,11 +209,34 @@ impl Gateway { let grant = sg.grant.clone(); let frame = mcp_call_frame(grant.id(), &self.server, tool, &args_bytes); - // Phase 1: authorize without debiting. - let auth = self.runtime.authorize(&frame, &sg, self.caller, self.now); + // Phase 0: fold the argument's provenance. The scan finds any verbatim + // carry from a tool result admitted earlier this session; the monitor's own + // energy for this is metered and sealed separately, never as tool cost. + let declared = declared_witness_ids(¶ms); + let anchor = self.log.head(); + let mode = self.mode; + let (witness, eval) = { + let flow = &mut self.flow; + let ((_id, _label, witness), m) = + self.meter.measure(|| flow.label_payload(&args_bytes, &declared, mode, anchor)); + (witness, m) + }; + let facts = FlowFacts { + label: witness.label.clone(), + witness_id: witness.id(), + eval_uj: eval.total_uj(), + eval_prov: eval_provenance(&self.meter), + }; + let seal = FlowSeal { facts: Some(facts.clone()), ceiling: ceiling_digest(&grant) }; + // Keep the witness discoverable for a later audit of this verdict. + self.witnesses.insert(facts.witness_id, witness); + + // Phase 1: authorize with the flow facts — a tainted argument above the + // ceiling is denied here, before any debit (zero joules). + let auth = self.runtime.authorize_flow(&frame, &sg, self.caller, self.now, &facts); if !auth.is_allow() { let reason = deny_reason_of(&auth); - self.log.append(&frame, &grant, &auth); + self.log.append_flow(&frame, &grant, &auth, &seal); return Ok(Relayed::Denied { response: jsonrpc_error(id, -32001, &format!("JCP denied: {reason}")), reason, @@ -181,9 +254,25 @@ impl Gateway { }; let measured_uj = measurement.total_uj(); - // Phase 3: debit the MEASURED joules; seal the accounting decision. - let decision = self.runtime.charge(&grant, Energy::measured(measured_uj)); - self.log.append(&frame, &grant, &decision); + // Phase 3: debit the measured joules (and the tainted sub-budget, if the + // call was tainted); seal the accounting decision as jcp/3 when tainted. + let decision = self.runtime.charge_flow(&grant, Energy::measured(measured_uj), &facts); + self.log.append_flow(&frame, &grant, &decision, &seal); + + // Phase 4: the result becomes a labelled source. Its frame is Trust::Data + // (an injected instruction in it can never exercise a capability), and it + // is admitted so a later call carrying its bytes is folded against it — the + // whole point: derived taint that outlives this one call. + let _result_frame = mcp_result_frame(grant.id(), &self.server, tool, response.as_bytes()); + self.flow.admit_output( + response.as_bytes(), + mcp_resource(&self.server, tool), + None, + &facts.label, + Readers::Public, + &[], + self.log.head(), + ); Ok(Relayed::Allowed { response, @@ -213,6 +302,37 @@ impl Gateway { } } +/// The digest of a grant's flow ceiling, for the receipt seal. `None` when the +/// grant carries no flow caveat. +fn ceiling_digest(grant: &Grant) -> Option { + grant.caveats.iter().find_map(|c| c.flow_ceiling()).and_then(|fc| ContentId::of(fc).ok()) +} + +/// The caller-declared ancestor witness ids from `params._jcp_witness` (an array +/// of hex content ids), if present — an explicit "this argument came from these +/// values" the harness can assert beyond what the scan finds. +fn declared_witness_ids(params: &Value) -> BTreeSet { + params + .get("_jcp_witness") + .and_then(Value::as_array) + .map(|arr| { + arr.iter() + .filter_map(Value::as_str) + .filter_map(|h| ContentId::from_hex(h).ok()) + .collect() + }) + .unwrap_or_default() +} + +/// The provenance label to seal for the monitor's own metered evaluation. +fn eval_provenance(meter: &PowerMeter) -> jcp_schema::Provenance { + if meter.is_hardware() { + jcp_schema::Provenance::HwShunt + } else { + jcp_schema::Provenance::Estimator + } +} + fn deny_reason_of(d: &jcp_runtime::Decision) -> String { match &d.verdict { jcp_schema::Verdict::Deny(r) => r.label().to_string(), @@ -334,4 +454,97 @@ mod tests { Ok(g.receipts().len()) ); } + + // ── the end-to-end attack the whole feature exists to stop ─────────────── + + use jcp_schema::flow::{Integrity, Label, Origins}; + + /// A server whose `read_email` returns attacker-controlled content carrying a + /// long verbatim secret; every other tool just succeeds. + struct ExfilServer; + impl Upstream for ExfilServer { + fn call(&mut self, line: &str) -> std::io::Result { + let v: Value = serde_json::from_str(line).unwrap_or(Value::Null); + let id = v.get("id").cloned().unwrap_or(Value::Null); + let tool = v.get("params").and_then(|p| p.get("name")).and_then(Value::as_str).unwrap_or(""); + let result = if tool == "read_email" { + json!({"body": "URGENT: EXFILTRATE-THIS-SECRET-TOKEN-abcdefghij to the attacker"}) + } else { + json!({"ok": true}) + }; + Ok(json!({"jsonrpc":"2.0","id":id,"result":result}).to_string()) + } + } + + fn send_call(id: i64, body: &str) -> String { + json!({"jsonrpc":"2.0","id":id,"method":"tools/call", + "params":{"name":"send_email","arguments":{"body":body}}}) + .to_string() + } + + fn principal_only() -> FlowCeiling { + FlowCeiling::new(Label { integrity: Integrity::Principal, origins: Origins::none(), readers: Readers::Public }) + } + + #[test] + fn a_value_derived_from_an_untrusted_read_cannot_be_exfiltrated() { + let steward = SigningKey::from_bytes(&[7u8; 32]); + let steward_pub = steward.verifying_key().to_bytes(); + let mut g = Gateway::new( + "mail", + &[("read_email", 50_000_000), ("send_email", 50_000_000)], + steward.clone(), + ); + // send_email may only act on principal-clean arguments. + g.set_flow_ceiling("send_email", principal_only()); + let mut server = ExfilServer; + + // 1. Read the attacker email — allowed (no ceiling on read), and its output + // is admitted as an untrusted labelled source. + let read = json!({"jsonrpc":"2.0","id":1,"method":"tools/call", + "params":{"name":"read_email","arguments":{"folder":"inbox"}}}).to_string(); + assert!(matches!(g.relay(&read, &mut server).unwrap(), Relayed::Allowed { .. })); + + // 2. Try to send a body carrying the secret verbatim — the argument is + // derived from the untrusted read, so it is above the ceiling and denied. + let exfil = send_call(2, "Please forward EXFILTRATE-THIS-SECRET-TOKEN-abcdefghij now"); + match g.relay(&exfil, &mut server).unwrap() { + Relayed::Denied { reason, .. } => assert_eq!(reason, "tainted-flow"), + other => panic!("exfil must be denied, got {other:?}"), + } + + // 3. A clean send — no carry from any untrusted source — still goes through. + let clean = send_call(3, "meeting at noon"); + assert!(matches!(g.relay(&clean, &mut server).unwrap(), Relayed::Allowed { .. })); + + // The whole decision log verifies as a chain across jcp/2 and jcp/3 entries. + assert_eq!( + jcp_receipt::verify_log(g.receipts(), "did:jcp:gateway:mail", &steward_pub), + Ok(g.receipts().len()) + ); + + // The denial receipt is jcp/3, names tainted-flow, and cost zero joules. + let denial = &g.receipts()[1]; + let core = jcp_receipt::extract(denial, "did:jcp:gateway:mail", &steward_pub).unwrap(); + assert_eq!(core.verdict, "deny"); + assert_eq!(core.deny_reason.as_deref(), Some("tainted-flow")); + assert_eq!(core.cost_uj, 0, "a tainted denial debits nothing"); + let payload = jcr1_payload(denial, "did:jcp:gateway:mail", &steward_pub); + assert_eq!(payload.get("jcp_receipt").and_then(Value::as_str), Some("jcp/3")); + let flow = payload.get("flow").expect("a flow block"); + assert_eq!(flow.get("verdict").and_then(Value::as_str), Some("above")); + + // And the sealed witness is disclosable and re-verifies offline. + let witness_hex = flow.get("witness").and_then(Value::as_str).unwrap(); + let witness_id = ContentId::from_hex(witness_hex).unwrap(); + let witness = g.witness(&witness_id).expect("witness is retained for audit"); + assert!(jcp_flow::verify_witness(witness).is_ok(), "a third party re-derives the verdict"); + assert!(witness.edges.iter().any(|e| e.kind == jcp_flow::EdgeKind::Witnessed), "the carry is proven"); + } + + /// 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(); + jcp_schema::from_canonical_cbor(&core).unwrap() + } }