Skip to main content

ratify_protocol/
receipts.rs

1//! Receipts and verdicts — SPEC §17.5–§17.6.
2//!
3//! Additive primitives that sit on top of `verify_bundle`:
4//!   - `VerificationReceipt`: hybrid-signed attestation that a bundle was
5//!     verified with a specific decision at a specific time. Chains by
6//!     `prev_hash` so the chain is tamper-evident.
7//!   - `PolicyVerdict`: HMAC-bound cached policy decision; lets a commercial
8//!     policy backend skip live evaluation on subsequent calls.
9//!
10//! Wire format unchanged: these wrap output of the verifier rather than
11//! adding fields to existing signed objects.
12
13#[cfg(not(feature = "std"))]
14use alloc::{format, string::String, string::ToString, vec, vec::Vec};
15
16use crate::canonical::{
17    encode_bool, encode_bytes_b64, encode_f64, encode_hybrid_pub_key, encode_hybrid_sig, encode_i32,
18    encode_i64, encode_str, encode_str_array,
19};
20use crate::crypto::{sign_both, verify_both};
21use crate::types::{
22    DelegationCert, HybridPrivateKey, HybridPublicKey, HybridSignature, PolicyVerdict, ProofBundle,
23    VerificationReceipt, VerifierContext, VerifyResult,
24};
25
26use hmac::{Hmac, Mac};
27use sha2::{Digest, Sha256};
28
29type HmacSha256 = Hmac<Sha256>;
30
31// ---------------------------------------------------------------------------
32// Lever 1: VerificationReceipt — SPEC §17.5
33// ---------------------------------------------------------------------------
34
35/// Canonical delegation object for the bundle hash (all fields present,
36/// no skip, including signature). Keys in lex order.
37fn encode_delegation_for_hash(d: &DelegationCert, out: &mut String) {
38    use crate::canonical::encode_constraints;
39    out.push('{');
40    out.push_str("\"cert_id\":");        encode_str(&d.cert_id, out);
41    out.push_str(",\"constraints\":");   encode_constraints(&d.constraints, out);
42    out.push_str(",\"expires_at\":");    encode_i64(d.expires_at, out);
43    out.push_str(",\"issued_at\":");     encode_i64(d.issued_at, out);
44    out.push_str(",\"issuer_id\":");     encode_str(&d.issuer_id, out);
45    out.push_str(",\"issuer_pub_key\":"); encode_hybrid_pub_key(&d.issuer_pub_key, out);
46    out.push_str(",\"scope\":");         encode_str_array(&d.scope, out);
47    out.push_str(",\"signature\":");     encode_hybrid_sig(&d.signature, out);
48    out.push_str(",\"subject_id\":");    encode_str(&d.subject_id, out);
49    out.push_str(",\"subject_pub_key\":"); encode_hybrid_pub_key(&d.subject_pub_key, out);
50    out.push_str(",\"version\":");       encode_i32(d.version, out);
51    out.push('}');
52}
53
54/// SHA-256 of a fixed-shape canonical form of a ProofBundle (SPEC §17.5).
55///
56/// Cross-SDK byte equivalence requires every field to be present (no skip),
57/// keys alphabetical at every level, and empty bytes / empty lists / zero
58/// ints serialized as `""` / `[]` / `0`. Every reference SDK (Go,
59/// TypeScript, Python, Rust) produces the same 32-byte digest for the
60/// same logical bundle. Verified against
61/// `testvectors/v1/cross_sdk_vectors.json`.
62pub fn bundle_hash(bundle: &ProofBundle) -> Result<Vec<u8>, String> {
63    let mut out = String::new();
64    // Outer keys in lex order: agent_id, agent_pub_key, challenge,
65    // challenge_at, challenge_sig, delegations, session_context, stream_id,
66    // stream_seq.
67    out.push('{');
68    out.push_str("\"agent_id\":"); encode_str(&bundle.agent_id, &mut out);
69    out.push_str(",\"agent_pub_key\":"); encode_hybrid_pub_key(&bundle.agent_pub_key, &mut out);
70    // challenge and session_context/stream_id always b64 (empty slice → "")
71    out.push_str(",\"challenge\":"); encode_bytes_b64(&bundle.challenge, &mut out);
72    out.push_str(",\"challenge_at\":"); encode_i64(bundle.challenge_at, &mut out);
73    out.push_str(",\"challenge_sig\":"); encode_hybrid_sig(&bundle.challenge_sig, &mut out);
74    // delegations array (all fields present, including signature)
75    out.push_str(",\"delegations\":[");
76    for (i, d) in bundle.delegations.iter().enumerate() {
77        if i > 0 {
78            out.push(',');
79        }
80        encode_delegation_for_hash(d, &mut out);
81    }
82    out.push(']');
83    out.push_str(",\"session_context\":"); encode_bytes_b64(&bundle.session_context, &mut out);
84    out.push_str(",\"stream_id\":"); encode_bytes_b64(&bundle.stream_id, &mut out);
85    out.push_str(",\"stream_seq\":"); encode_i64(bundle.stream_seq, &mut out);
86    out.push('}');
87    let bytes = out.into_bytes();
88    Ok(Sha256::digest(&bytes).to_vec())
89}
90
91/// Canonical signable bytes for a VerificationReceipt. Public so tests
92/// (and any AuditProvider that wants to chain its own signatures) can
93/// recompute the bytes.
94pub fn verification_receipt_sign_bytes_buf(
95    r: &VerificationReceipt,
96) -> Result<Vec<u8>, String> {
97    verification_receipt_sign_bytes(r)
98}
99
100fn verification_receipt_sign_bytes(r: &VerificationReceipt) -> Result<Vec<u8>, String> {
101    // Keys in lex order. Optional fields (agent_id, error_reason, granted_scope,
102    // human_id) are omitted when empty, matching Go's omitempty.
103    // Sorted: agent_id < bundle_hash < decision < error_reason <
104    // granted_scope < human_id < prev_hash < verified_at < verifier_id <
105    // verifier_pub < version.
106    let mut out = String::new();
107    out.push('{');
108
109    // Build a vec of (key, writer) pairs for present fields, then join with commas.
110    // Simpler: just track separator manually.
111    let mut sep = "";
112
113    if !r.agent_id.is_empty() {
114        out.push_str(sep); sep = ",";
115        out.push_str("\"agent_id\":"); encode_str(&r.agent_id, &mut out);
116    }
117    out.push_str(sep); sep = ",";
118    out.push_str("\"bundle_hash\":"); encode_bytes_b64(&r.bundle_hash, &mut out);
119    out.push_str(sep);
120    out.push_str("\"decision\":"); encode_str(&r.decision, &mut out);
121    if !r.error_reason.is_empty() {
122        out.push_str(sep);
123        out.push_str("\"error_reason\":"); encode_str(&r.error_reason, &mut out);
124    }
125    if !r.granted_scope.is_empty() {
126        let mut scope = r.granted_scope.clone();
127        scope.sort();
128        out.push_str(sep);
129        out.push_str("\"granted_scope\":"); encode_str_array(&scope, &mut out);
130    }
131    if !r.human_id.is_empty() {
132        out.push_str(sep);
133        out.push_str("\"human_id\":"); encode_str(&r.human_id, &mut out);
134    }
135    out.push_str(sep);
136    out.push_str("\"prev_hash\":"); encode_bytes_b64(&r.prev_hash, &mut out);
137    out.push_str(sep);
138    out.push_str("\"verified_at\":"); encode_i64(r.verified_at, &mut out);
139    out.push_str(sep);
140    out.push_str("\"verifier_id\":"); encode_str(&r.verifier_id, &mut out);
141    out.push_str(sep);
142    out.push_str("\"verifier_pub\":"); encode_hybrid_pub_key(&r.verifier_pub, &mut out);
143    out.push_str(sep);
144    out.push_str("\"version\":"); encode_i32(r.version, &mut out);
145    out.push('}');
146    Ok(out.into_bytes())
147}
148
149/// Construct and hybrid-sign a VerificationReceipt over a (bundle, result,
150/// prev) triple. `prev_hash` is `None` for genesis (becomes 32 zero bytes).
151pub fn issue_verification_receipt(
152    bundle: &ProofBundle,
153    result: &VerifyResult,
154    verifier_id: &str,
155    verifier_pub: &HybridPublicKey,
156    verifier_priv: &HybridPrivateKey,
157    prev_hash: Option<&[u8]>,
158    verified_at: i64,
159) -> Result<VerificationReceipt, String> {
160    let prev = match prev_hash {
161        Some(p) if p.len() == 32 => p.to_vec(),
162        None => vec![0u8; 32],
163        Some(p) => return Err(format!("prev_hash must be 32 bytes, got {}", p.len())),
164    };
165    let mut r = VerificationReceipt {
166        version: 1,
167        verifier_id: verifier_id.to_string(),
168        verifier_pub: verifier_pub.clone(),
169        bundle_hash: bundle_hash(bundle)?,
170        decision: result.identity_status.as_str().to_string(),
171        human_id: result.human_id.clone(),
172        agent_id: result.agent_id.clone(),
173        granted_scope: result.granted_scope.clone(),
174        error_reason: result.error_reason.clone(),
175        verified_at,
176        prev_hash: prev,
177        signature: HybridSignature {
178            ed25519: Vec::new(),
179            ml_dsa_65: Vec::new(),
180        },
181    };
182    let signable = verification_receipt_sign_bytes(&r)?;
183    r.signature = sign_both(&signable, verifier_priv);
184    Ok(r)
185}
186
187/// Verify the hybrid signature on a VerificationReceipt against the
188/// receipt's declared verifier_pub. Returns Ok(()) iff both component
189/// signatures verify.
190pub fn verify_verification_receipt(r: &VerificationReceipt) -> Result<(), String> {
191    if r.version != 1 {
192        return Err(format!("unsupported version {}", r.version));
193    }
194    if r.bundle_hash.len() != 32 {
195        return Err(format!("bundle_hash must be 32 bytes, got {}", r.bundle_hash.len()));
196    }
197    if r.prev_hash.len() != 32 {
198        return Err(format!("prev_hash must be 32 bytes, got {}", r.prev_hash.len()));
199    }
200    let signable = verification_receipt_sign_bytes(r)?;
201    verify_both(&signable, &r.signature, &r.verifier_pub)
202}
203
204/// SHA-256 of a receipt's canonical signable bytes. Use as `prev_hash` for
205/// the next receipt in the chain.
206pub fn receipt_hash(r: &VerificationReceipt) -> Result<Vec<u8>, String> {
207    let signable = verification_receipt_sign_bytes(r)?;
208    Ok(Sha256::digest(&signable).to_vec())
209}
210
211// ---------------------------------------------------------------------------
212// Lever 2: PolicyVerdict — SPEC §17.6
213// ---------------------------------------------------------------------------
214
215/// SHA-256 of the canonical-byte representation of the policy-relevant
216/// subset of a VerifierContext. Used as `context_hash` on a PolicyVerdict.
217/// `invocations_in_window` is excluded — closures don't serialize.
218/// Keys in lex order: current_alt_m, current_lat, current_lon,
219/// current_speed_mps, has_amount, has_location, has_speed,
220/// requested_amount, requested_currency.
221pub fn verifier_context_hash(ctx: &VerifierContext) -> Result<Vec<u8>, String> {
222    let has_amount = ctx.requested_amount.is_some();
223    let has_location = ctx.current_lat.is_some() && ctx.current_lon.is_some();
224    let has_speed = ctx.current_speed_mps.is_some();
225    let mut out = String::new();
226    out.push('{');
227    out.push_str("\"current_alt_m\":"); encode_f64(ctx.current_alt_m.unwrap_or(0.0), &mut out);
228    out.push_str(",\"current_lat\":"); encode_f64(ctx.current_lat.unwrap_or(0.0), &mut out);
229    out.push_str(",\"current_lon\":"); encode_f64(ctx.current_lon.unwrap_or(0.0), &mut out);
230    out.push_str(",\"current_speed_mps\":"); encode_f64(ctx.current_speed_mps.unwrap_or(0.0), &mut out);
231    out.push_str(",\"has_amount\":"); encode_bool(has_amount, &mut out);
232    out.push_str(",\"has_location\":"); encode_bool(has_location, &mut out);
233    out.push_str(",\"has_speed\":"); encode_bool(has_speed, &mut out);
234    out.push_str(",\"requested_amount\":"); encode_f64(ctx.requested_amount.unwrap_or(0.0), &mut out);
235    out.push_str(",\"requested_currency\":"); encode_str(ctx.requested_currency.as_deref().unwrap_or(""), &mut out);
236    out.push('}');
237    let bytes = out.into_bytes();
238    Ok(Sha256::digest(&bytes).to_vec())
239}
240
241/// Canonical signable bytes for a PolicyVerdict. Public so tests and
242/// alternative issuance backends can recompute the bytes.
243/// Keys: agent_id, allow, context_hash, issued_at, scope, valid_until,
244/// verdict_id, version.
245pub fn policy_verdict_sign_bytes_buf(v: &PolicyVerdict) -> Result<Vec<u8>, String> {
246    policy_verdict_sign_bytes(v)
247}
248
249fn policy_verdict_sign_bytes(v: &PolicyVerdict) -> Result<Vec<u8>, String> {
250    let mut out = String::new();
251    out.push('{');
252    out.push_str("\"agent_id\":"); encode_str(&v.agent_id, &mut out);
253    out.push_str(",\"allow\":"); encode_bool(v.allow, &mut out);
254    out.push_str(",\"context_hash\":"); encode_bytes_b64(&v.context_hash, &mut out);
255    out.push_str(",\"issued_at\":"); encode_i64(v.issued_at, &mut out);
256    out.push_str(",\"scope\":"); encode_str(&v.scope, &mut out);
257    out.push_str(",\"valid_until\":"); encode_i64(v.valid_until, &mut out);
258    out.push_str(",\"verdict_id\":"); encode_str(&v.verdict_id, &mut out);
259    out.push_str(",\"version\":"); encode_i32(v.version, &mut out);
260    out.push('}');
261    Ok(out.into_bytes())
262}
263
264/// Construct and HMAC-bind a PolicyVerdict.
265pub fn issue_policy_verdict(
266    verdict_id: &str,
267    agent_id: &str,
268    scope: &str,
269    allow: bool,
270    context_hash: &[u8],
271    issued_at: i64,
272    valid_until: i64,
273    policy_secret: &[u8],
274) -> Result<PolicyVerdict, String> {
275    if policy_secret.is_empty() {
276        return Err("policy_secret must not be empty".into());
277    }
278    if verdict_id.is_empty() {
279        return Err("verdict_id must not be empty".into());
280    }
281    if agent_id.is_empty() {
282        return Err("agent_id must not be empty".into());
283    }
284    if scope.is_empty() {
285        return Err("scope must not be empty".into());
286    }
287    if context_hash.len() != 32 {
288        return Err(format!("context_hash must be 32 bytes, got {}", context_hash.len()));
289    }
290    if valid_until <= issued_at {
291        return Err("valid_until must be strictly after issued_at".into());
292    }
293    let mut v = PolicyVerdict {
294        version: 1,
295        verdict_id: verdict_id.to_string(),
296        agent_id: agent_id.to_string(),
297        scope: scope.to_string(),
298        allow,
299        context_hash: context_hash.to_vec(),
300        issued_at,
301        valid_until,
302        mac: Vec::new(),
303    };
304    let signable = policy_verdict_sign_bytes(&v)?;
305    let mut mac = HmacSha256::new_from_slice(policy_secret).map_err(|e| e.to_string())?;
306    mac.update(&signable);
307    v.mac = mac.finalize().into_bytes().to_vec();
308    Ok(v)
309}
310
311/// Check a PolicyVerdict's HMAC and validity. Returns `Ok(())` on success
312/// (cached allow); returns `Err("policy_verdict_denied: ...")` on cached
313/// deny; any other `Err` indicates the verdict is unusable.
314pub fn verify_policy_verdict(
315    v: &PolicyVerdict,
316    policy_secret: &[u8],
317    expected_agent_id: &str,
318    expected_scope: &str,
319    expected_context_hash: &[u8],
320    now: i64,
321) -> Result<(), String> {
322    if policy_secret.is_empty() {
323        return Err("policy_secret must not be empty".into());
324    }
325    if v.version != 1 {
326        return Err(format!("unsupported version {}", v.version));
327    }
328    if v.context_hash.len() != 32 {
329        return Err(format!("context_hash must be 32 bytes, got {}", v.context_hash.len()));
330    }
331    if v.mac.len() != 32 {
332        return Err(format!("mac must be 32 bytes, got {}", v.mac.len()));
333    }
334    let signable = policy_verdict_sign_bytes(v)?;
335    let mut mac = HmacSha256::new_from_slice(policy_secret).map_err(|e| e.to_string())?;
336    mac.update(&signable);
337    mac.verify_slice(&v.mac)
338        .map_err(|_| "policy_verdict MAC invalid".to_string())?;
339    if now < v.issued_at {
340        return Err("policy_verdict not yet valid".into());
341    }
342    if now > v.valid_until {
343        return Err("policy_verdict expired".into());
344    }
345    if v.agent_id != expected_agent_id {
346        return Err("policy_verdict agent_id mismatch".into());
347    }
348    if v.scope != expected_scope {
349        return Err("policy_verdict scope mismatch".into());
350    }
351    if v.context_hash != expected_context_hash {
352        return Err("policy_verdict context_hash mismatch".into());
353    }
354    if !v.allow {
355        return Err(format!(
356            "policy_verdict_denied: cached deny for scope \"{}\"",
357            v.scope
358        ));
359    }
360    Ok(())
361}