jcp-receipt: Seal a flow verdict as jcp/3 #41
1 changed files with 265 additions and 0 deletions
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue