jcp-receipt: Seal a flow verdict as jcp/3 #41

Closed
dcharlot wants to merge 1 commit from jcp-receipt-flow-seal into jcp-runtime-flow-gate

View file

@ -35,6 +35,37 @@ pub const PROFILE: &str = "jcp/2";
/// (see [`verify`]), but are no longer minted.
pub const PROFILE_V1: &str = "jcp/1";
/// The **flow** profile (SPEC §8, §4.10): `jcp/2` plus a `flow` block recording a
/// call's propagated provenance verdict. Minted **only** when a call is tainted;
/// a clean call still seals as [`PROFILE`] (`jcp/2`), byte-for-byte, so nothing
/// on the existing path moves.
pub const PROFILE_FLOW: &str = "jcp/3";
/// What a flow verdict seals: the argument's provenance facts and, if the grant
/// carried one, the digest of the flow ceiling it was tested against. A `None`
/// or bottom `facts` means the call was clean and the receipt stays `jcp/2`.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FlowSeal {
pub facts: Option<jcp_schema::FlowFacts>,
/// The ceiling's content id ([`jcp_schema::FlowCeiling`] digest), if any.
pub ceiling: Option<jcp_schema::ContentId>,
}
impl FlowSeal {
/// A clean seal — the call touched no untrusted provenance, so the receipt is
/// an ordinary `jcp/2`.
pub fn clean() -> Self {
FlowSeal { facts: None, ceiling: None }
}
/// Is this seal tainted (a non-bottom label)? Decides whether a receipt is
/// minted as `jcp/3` rather than `jcp/2`.
fn is_tainted(&self) -> bool {
self.facts.as_ref().is_some_and(|f| !f.is_bottom())
}
}
/// Project a decision to the **legacy `jcp/1`** wire — byte-for-byte what this
/// library sealed before `payload_hash` existed. Retained solely so previously
/// sealed receipts keep verifying; never used to mint.
@ -76,6 +107,117 @@ pub fn to_jcr1_wire(frame: &Frame, grant: &Grant, decision: &Decision, steward:
Value::Object(obj)
}
/// The `flow` block of a `jcp/3` receipt: the sealed provenance verdict. All
/// text/uint/array (no float/bool/null), so it survives canonical CBOR, and the
/// witness is a **commitment** — the artifact itself is disclosed out of band, not
/// carried here.
fn flow_block(facts: &jcp_schema::FlowFacts, seal: &FlowSeal, decision: &Decision) -> Value {
use jcp_schema::flow::{Integrity, Origins, Readers};
let label = &facts.label;
let integrity = match label.integrity {
Integrity::Untrusted => "untrusted",
Integrity::Derived => "derived",
Integrity::Attested => "attested",
Integrity::Principal => "principal",
};
let origins = match &label.origins {
Origins::Any => json!("any"),
Origins::Set(set) => {
Value::Array(set.iter().map(|o| json!(o.channel.to_hex())).collect())
}
};
let readers = match &label.readers {
Readers::Public => json!("public"),
Readers::Only(dids) => {
Value::Array(dids.iter().map(|d| json!(d.to_did_key())).collect())
}
};
// The flow verdict is about the gate specifically: "above" the ceiling exactly
// when the decision was denied for flow. A tainted call denied for another
// reason (or allowed under a permissive ceiling) passed the flow gate: "below".
let verdict = if decision.verdict == Verdict::Deny(jcp_schema::DenyReason::TaintedFlow) {
"above"
} else {
"below"
};
let mut block = serde_json::Map::new();
block.insert("label".into(), json!(label.digest().map(|c| c.to_hex()).unwrap_or_default()));
block.insert("integrity".into(), json!(integrity));
block.insert("origins".into(), origins);
block.insert("readers".into(), readers);
block.insert("witness".into(), json!(facts.witness_id.to_hex()));
if let Some(ceiling) = &seal.ceiling {
block.insert("ceiling".into(), json!(ceiling.to_hex()));
}
block.insert("verdict".into(), json!(verdict));
// The monitor's own evaluation cost — sealed as its OWN field, never folded
// into `cost_uj` (the tool's energy), so a call's energy keeps its meaning.
block.insert("flow_uj".into(), json!(facts.eval_uj));
block.insert("flow_energy_provenance".into(), json!(facts.eval_prov.jcr1_label()));
Value::Object(block)
}
/// Project a decision to flow-aware wire JSON. When the call is clean this is
/// byte-for-byte [`to_jcr1_wire`] (`jcp/2`); when tainted it is `jcp/3` — the same
/// core plus a `flow` block. The clean case producing identical bytes is what lets
/// a non-flow deployment and a flow deployment share a receipt stream.
pub fn to_jcr1_wire_flow(
frame: &Frame,
grant: &Grant,
decision: &Decision,
steward: &str,
seal: &FlowSeal,
) -> Value {
let mut wire = to_jcr1_wire(frame, grant, decision, steward);
if seal.is_tainted() {
if let (Value::Object(ref mut o), Some(facts)) = (&mut wire, &seal.facts) {
o.insert("jcp_receipt".into(), json!(PROFILE_FLOW));
o.insert("flow".into(), flow_block(facts, seal, decision));
}
}
wire
}
/// Seal a decision with its flow verdict. A clean call yields an ordinary `jcp/2`
/// envelope indistinguishable from [`seal`]; a tainted call yields `jcp/3`.
pub fn seal_flow(
frame: &Frame,
grant: &Grant,
decision: &Decision,
key: &SigningKey,
steward: &str,
seal: &FlowSeal,
) -> Result<Vec<u8>, ReceiptError> {
let core = jcr1::signed_core_bytes(&to_jcr1_wire_flow(frame, grant, decision, steward, seal))?;
Ok(jcr1::cose_sign1_envelope(&core, key, steward))
}
/// Verify a flow envelope: like [`verify`], but recomputes the `jcp/3` core from
/// the same `seal`, so a `jcp/3` receipt round-trips. A `jcp/2` envelope (a clean
/// call) verifies here too, since a clean seal recomputes the `jcp/2` core.
pub fn verify_flow(
frame: &Frame,
grant: &Grant,
decision: &Decision,
seal: &FlowSeal,
envelope: &[u8],
steward: &str,
pubkey: &[u8; 32],
) -> Result<(), ReceiptError> {
let payload = jcr1::verify_cose_sign1(envelope, steward, pubkey)?;
let expected = match profile_of(&payload)?.as_str() {
PROFILE | PROFILE_FLOW => {
jcr1::signed_core_bytes(&to_jcr1_wire_flow(frame, grant, decision, steward, seal))?
}
PROFILE_V1 => jcr1::signed_core_bytes(&to_jcr1_wire_v1(frame, grant, decision, steward))?,
other => return Err(ReceiptError::UnknownProfile(other.to_string())),
};
if payload != expected {
return Err(ReceiptError::PayloadMismatch);
}
Ok(())
}
fn frame_kind_tag(f: &Frame) -> &'static str {
use jcp_schema::FrameKind::*;
match f.kind {
@ -404,6 +546,32 @@ impl ReceiptLog {
env
}
/// Seal a decision as the next link, carrying its flow verdict. A clean call
/// appends a `jcp/2` entry byte-identical to [`append`](Self::append); a
/// tainted call appends a `jcp/3` entry. Either way the chaining (`seq`/`prev`)
/// is identical, so a mixed stream verifies end to end via [`verify_log`].
pub fn append_flow(
&mut self,
frame: &Frame,
grant: &Grant,
decision: &Decision,
seal: &FlowSeal,
) -> Vec<u8> {
let mut wire = to_jcr1_wire_logged(frame, grant, decision, &self.steward, self.seq, &self.head);
if seal.is_tainted() {
if let (Value::Object(ref mut o), Some(facts)) = (&mut wire, &seal.facts) {
o.insert("jcp_receipt".into(), json!(PROFILE_FLOW));
o.insert("flow".into(), flow_block(facts, seal, decision));
}
}
let core = jcr1::signed_core_bytes(&wire).expect("canonical core");
let env = jcr1::cose_sign1_envelope(&core, &self.steward_sk, &self.steward);
self.head = jcp_schema::ContentId::of_bytes(&env);
self.seq += 1;
self.entries.push(env.clone());
env
}
/// The sealed entries, in order.
pub fn entries(&self) -> &[Vec<u8>] {
&self.entries
@ -663,4 +831,101 @@ mod tests {
let env = seal(&f, &g, &d, &key, "s").unwrap();
assert!(verify(&f, &g, &d, &env, "s", &key.verifying_key().to_bytes()).is_ok());
}
// ── jcp/3 flow seal (SPEC §8, §4.10) ─────────────────────────────────────
use jcp_schema::flow::{Integrity, Label, Origin, Origins, Readers};
use jcp_schema::{FlowFacts, Provenance};
fn tainted_facts() -> FlowFacts {
FlowFacts {
label: Label {
integrity: Integrity::Untrusted,
origins: Origins::one(Origin::channel(ContentId::of_bytes(b"mail:read"))),
readers: Readers::Public,
},
witness_id: ContentId::of_bytes(b"a witness"),
eval_uj: 42,
eval_prov: Provenance::ModelBased,
}
}
#[test]
fn a_clean_call_is_byte_identical_to_jcp2() {
// The load-bearing compatibility property: a clean flow seal produces the
// exact bytes of the plain path, so a flow deployment and a non-flow one
// share a stream and every existing receipt is unmoved.
let (f, g, d) = scenario();
let key = sk(7);
let plain = seal(&f, &g, &d, &key, "s").unwrap();
let clean = seal_flow(&f, &g, &d, &key, "s", &FlowSeal::clean()).unwrap();
assert_eq!(plain, clean, "a clean flow seal == the plain jcp/2 seal");
// Even facts that were computed but are bottom stay jcp/2.
let bottom = FlowSeal { facts: Some(FlowFacts::bottom()), ceiling: None };
assert_eq!(plain, seal_flow(&f, &g, &d, &key, "s", &bottom).unwrap());
}
#[test]
fn a_tainted_call_seals_jcp3_with_a_flow_block() {
let (f, g, d) = scenario();
let key = sk(7);
let seal_data = FlowSeal {
facts: Some(tainted_facts()),
ceiling: Some(ContentId::of_bytes(b"the ceiling")),
};
let env = seal_flow(&f, &g, &d, &key, "s", &seal_data).unwrap();
// It verifies against the same seal, and NOT as a plain jcp/2 (the flow
// block is part of the signed core).
let pk = key.verifying_key().to_bytes();
assert!(verify_flow(&f, &g, &d, &seal_data, &env, "s", &pk).is_ok());
assert!(verify(&f, &g, &d, &env, "s", &pk).is_err(), "jcp/3 needs the seal to verify");
// The profile and flow block are present and correct.
let payload = jcr1::verify_cose_sign1(&env, "s", &pk).unwrap();
let v: Value = jcp_schema::from_canonical_cbor(&payload).unwrap();
assert_eq!(v.get("jcp_receipt").and_then(Value::as_str), Some("jcp/3"));
let flow = v.get("flow").expect("flow block");
assert_eq!(flow.get("integrity").and_then(Value::as_str), Some("untrusted"));
assert_eq!(flow.get("verdict").and_then(Value::as_str), Some("below")); // the scenario allowed
assert_eq!(flow.get("flow_uj").and_then(Value::as_u64), Some(42));
assert_eq!(flow.get("flow_energy_provenance").and_then(Value::as_str), Some("ModelBased"));
assert!(flow.get("witness").and_then(Value::as_str).is_some());
assert!(flow.get("ceiling").and_then(Value::as_str).is_some());
}
#[test]
fn a_flow_denied_call_seals_verdict_above() {
// A tainted call denied for flow records "above" the ceiling.
let (f, g, _d) = scenario();
let denied = Decision {
verdict: Verdict::Deny(jcp_schema::DenyReason::TaintedFlow),
cost: jcp_schema::Energy::measured(0),
budget_remaining_uj: 100,
};
let key = sk(7);
let seal_data = FlowSeal { facts: Some(tainted_facts()), ceiling: None };
let env = seal_flow(&f, &g, &denied, &key, "s", &seal_data).unwrap();
let payload = jcr1::verify_cose_sign1(&env, "s", &key.verifying_key().to_bytes()).unwrap();
let v: Value = jcp_schema::from_canonical_cbor(&payload).unwrap();
assert_eq!(v["flow"].get("verdict").and_then(Value::as_str), Some("above"));
assert_eq!(v.get("deny_reason").and_then(Value::as_str), Some("tainted-flow"));
}
#[test]
fn a_mixed_jcp2_and_jcp3_log_verifies_end_to_end() {
let (f, g, d) = scenario();
let key = sk(7);
let mut log = ReceiptLog::new(key.clone(), "s");
log.append_flow(&f, &g, &d, &FlowSeal::clean()); // jcp/2
log.append_flow(&f, &g, &d, &FlowSeal { facts: Some(tainted_facts()), ceiling: None }); // jcp/3
log.append(&f, &g, &d); // jcp/2 plain
let pk = key.verifying_key().to_bytes();
assert_eq!(verify_log(log.entries(), "s", &pk), Ok(3), "the chain is intact across profiles");
// extract reads a jcp/3 entry's core generically (it is jcp/2 + a block).
let core = extract(&log.entries()[1], "s", &pk).unwrap();
assert_eq!(core.verdict, "allow");
assert_eq!(core.cost_uj, 30, "cost_uj is the tool energy, untouched by flow_uj");
}
}