Skip to main content

continuity_receipt/
disclose.rs

1//! Selective disclosure tooling for Continuity Receipt bundles.
2//!
3//! Mirrors `continuity_receipt/disclose.py`: replace optional fields with
4//! salted commitments, keep the salt+value map separate, and merge a map back
5//! into a bundle for verification. Path grammar matches the verifier:
6//! `receipts[i].body.<field>[.<nested>...]`.
7//!
8//! Redaction is an issuance-time act: the modified receipts (and everything
9//! after them) are re-signed, so `redact` requires the issuer's signing key.
10
11use std::fmt;
12
13use base64::engine::general_purpose::URL_SAFE_NO_PAD;
14use base64::Engine as _;
15use ed25519_dalek::{Signer, SigningKey};
16use serde_json::{Map, Value};
17use sha2::{Digest, Sha256};
18
19use crate::canon::{canonical_bytes, commit_field, CanonError};
20use crate::verify::{receipt_digest, required_field_for_path, unsigned_view};
21
22/// Disclosure tooling failure (malformed path, required field, missing signer).
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct DiscloseError(pub String);
25
26impl fmt::Display for DiscloseError {
27    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
28        formatter.write_str(&self.0)
29    }
30}
31
32impl std::error::Error for DiscloseError {}
33
34impl From<CanonError> for DiscloseError {
35    fn from(error: CanonError) -> Self {
36        Self(error.0)
37    }
38}
39
40fn malformed(path: &str) -> DiscloseError {
41    DiscloseError(format!("malformed path: {path:?}"))
42}
43
44fn tokens(path: &str) -> Result<Vec<&str>, DiscloseError> {
45    let parts: Vec<&str> = path.split('.').collect();
46    if parts.is_empty() || parts.iter().any(|part| part.is_empty()) {
47        return Err(malformed(path));
48    }
49    Ok(parts)
50}
51
52/// Parse one `name[123]` index token (`^([A-Za-z_][A-Za-z0-9_]*)\[(\d+)\]$`).
53fn index_token(token: &str) -> Option<(&str, usize)> {
54    let (name, rest) = token.split_once('[')?;
55    let digits = rest.strip_suffix(']')?;
56    let mut characters = name.chars();
57    let first = characters.next()?;
58    if !(first.is_ascii_alphabetic() || first == '_') {
59        return None;
60    }
61    if !characters.all(|character| character.is_ascii_alphanumeric() || character == '_') {
62        return None;
63    }
64    if digits.is_empty() || !digits.bytes().all(|byte| byte.is_ascii_digit()) {
65        return None;
66    }
67    Some((name, digits.parse().ok()?))
68}
69
70fn descend<'a>(node: &'a Value, token: &str) -> Option<&'a Value> {
71    match index_token(token) {
72        Some((name, index)) => node.get(name)?.as_array()?.get(index),
73        None => node.get(token),
74    }
75}
76
77fn descend_mut<'a>(node: &'a mut Value, token: &str) -> Result<&'a mut Value, DiscloseError> {
78    let missing = || DiscloseError(format!("path not found: {token}"));
79    match index_token(token) {
80        Some((name, index)) => node
81            .get_mut(name)
82            .and_then(Value::as_array_mut)
83            .and_then(|array| array.get_mut(index))
84            .ok_or_else(missing),
85        None => node.get_mut(token).ok_or_else(missing),
86    }
87}
88
89fn walk<'a>(node: &'a Value, path: &str) -> Result<&'a Value, DiscloseError> {
90    let mut current = node;
91    for token in tokens(path)? {
92        current = descend(current, token)
93            .ok_or_else(|| DiscloseError(format!("path not found: {path}")))?;
94    }
95    Ok(current)
96}
97
98fn set_value(node: &mut Value, path: &str, value: Value) -> Result<(), DiscloseError> {
99    let parts = tokens(path)?;
100    let (last, parents) = parts.split_last().expect("tokens are non-empty");
101    let mut current = node;
102    for token in parents {
103        current = descend_mut(current, token)?;
104    }
105    match index_token(last) {
106        Some((name, index)) => {
107            let slot = current
108                .get_mut(name)
109                .and_then(Value::as_array_mut)
110                .and_then(|array| array.get_mut(index))
111                .ok_or_else(|| DiscloseError(format!("path not found: {path}")))?;
112            *slot = value;
113        }
114        None => {
115            let map = current
116                .as_object_mut()
117                .ok_or_else(|| DiscloseError(format!("path not found: {path}")))?;
118            map.insert((*last).to_string(), value);
119        }
120    }
121    Ok(())
122}
123
124fn receipt_index(path: &str) -> Result<usize, DiscloseError> {
125    let parts = tokens(path)?;
126    match index_token(parts[0]) {
127        Some(("receipts", index)) => Ok(index),
128        _ => Err(DiscloseError(format!(
129            "path must start with receipts[i]: {path:?}"
130        ))),
131    }
132}
133
134/// `did:key` for an Ed25519 signing key (base58btc multicodec `0xed01`).
135#[must_use]
136pub fn did_from_signing_key(key: &SigningKey) -> String {
137    let public = key.verifying_key().to_bytes();
138    let mut multicodec = Vec::with_capacity(34);
139    multicodec.extend_from_slice(&[0xed, 0x01]);
140    multicodec.extend_from_slice(&public);
141    format!("did:key:z{}", bs58::encode(multicodec).into_string())
142}
143
144fn random_salt_hex() -> Result<String, DiscloseError> {
145    let mut bytes = [0u8; 16];
146    getrandom::getrandom(&mut bytes)
147        .map_err(|error| DiscloseError(format!("os randomness unavailable: {error}")))?;
148    let mut out = String::with_capacity(32);
149    for byte in bytes {
150        out.push_str(&format!("{byte:02x}"));
151    }
152    Ok(out)
153}
154
155/// `records.sign_receipt`: sign the unsigned view, then append the signature.
156fn sign_receipt(receipt: &Map<String, Value>, key: &SigningKey) -> Result<Value, DiscloseError> {
157    let message = canonical_bytes(&Value::Object(unsigned_view(receipt)))?;
158    let signature = key.sign(&message);
159    let mut signed = receipt.clone();
160    signed.insert(
161        "sig".to_string(),
162        serde_json::json!({
163            "alg": "ed25519",
164            "key": did_from_signing_key(key),
165            "value": URL_SAFE_NO_PAD.encode(signature.to_bytes()),
166        }),
167    );
168    Ok(Value::Object(signed))
169}
170
171/// Rebuild `prev` links and signatures from `start` to the end of the chain.
172fn resign_tail(bundle: &mut Value, start: usize, key: &SigningKey) -> Result<(), DiscloseError> {
173    let did = did_from_signing_key(key);
174    let receipts = bundle
175        .get_mut("receipts")
176        .and_then(Value::as_array_mut)
177        .ok_or_else(|| DiscloseError("bundle has no receipts list".to_string()))?;
178    let mut prev = if start > 0 {
179        let previous = receipts
180            .get(start - 1)
181            .and_then(Value::as_object)
182            .ok_or_else(|| DiscloseError("receipt is not an object".to_string()))?;
183        Some(receipt_digest(previous)?)
184    } else {
185        None
186    };
187    for (index, slot) in receipts.iter_mut().enumerate().skip(start) {
188        let mut receipt = std::mem::take(slot);
189        let map = receipt
190            .as_object_mut()
191            .ok_or_else(|| DiscloseError("receipt is not an object".to_string()))?;
192        let issuer = map
193            .get("issuer")
194            .and_then(Value::as_object)
195            .and_then(|issuer| issuer.get("id"))
196            .and_then(Value::as_str)
197            .map(str::to_string);
198        if issuer.as_deref() != Some(did.as_str()) {
199            return Err(DiscloseError(format!(
200                "receipt {index} issuer {issuer:?} != signer {did:?}; cannot re-sign"
201            )));
202        }
203        map.insert("seq".to_string(), serde_json::json!(index));
204        map.insert(
205            "prev".to_string(),
206            prev.clone().map_or(Value::Null, Value::String),
207        );
208        map.remove("sig");
209        *slot = sign_receipt(map, key)?;
210        let updated = slot
211            .as_object()
212            .ok_or_else(|| DiscloseError("receipt is not an object".to_string()))?;
213        prev = Some(receipt_digest(updated)?);
214    }
215    Ok(())
216}
217
218/// Replace each optional field with a commitment; return `(redacted, map)`.
219///
220/// `salts` supplies deterministic salts per path (tests and vectors); paths
221/// absent from it get 16 random bytes, hex-encoded. `signer` is required when
222/// any path is modified, because the redacted tail is re-signed.
223pub fn redact(
224    bundle: &Value,
225    paths: &[String],
226    salts: Option<&Map<String, Value>>,
227    signer: Option<&SigningKey>,
228) -> Result<(Value, Value), DiscloseError> {
229    let mut redacted = bundle.clone();
230    let receipts_snapshot: Vec<Value> = redacted
231        .get("receipts")
232        .and_then(Value::as_array)
233        .ok_or_else(|| DiscloseError("bundle has no receipts list".to_string()))?
234        .clone();
235    let mut disclosure = Map::new();
236    let mut modified: Vec<usize> = Vec::new();
237    for path in paths {
238        if required_field_for_path(path, &receipts_snapshot).is_some() {
239            return Err(DiscloseError(format!(
240                "required field cannot be redacted: {path}"
241            )));
242        }
243        let value = walk(&redacted, path)?.clone();
244        let salt = match salts
245            .and_then(|map| map.get(path))
246            .and_then(Value::as_str)
247            .filter(|salt| !salt.is_empty())
248        {
249            Some(salt) => salt.to_string(),
250            None => random_salt_hex()?,
251        };
252        let commit = commit_field(&salt, &value)?;
253        set_value(
254            &mut redacted,
255            path,
256            serde_json::json!({"redacted": true, "commit": commit}),
257        )?;
258        disclosure.insert(
259            path.clone(),
260            serde_json::json!({"salt": salt, "value": value}),
261        );
262        modified.push(receipt_index(path)?);
263    }
264    if !modified.is_empty() {
265        let key = signer.ok_or_else(|| {
266            DiscloseError(
267                "redaction rewrites signed receipts; pass signer=(private_key, did)".to_string(),
268            )
269        })?;
270        let start = *modified.iter().min().expect("modified is non-empty");
271        resign_tail(&mut redacted, start, key)?;
272    }
273    Ok((redacted, Value::Object(disclosure)))
274}
275
276/// Merge a disclosure map into a bundle (`disclosure_map`), keeping entries
277/// that were already attached.
278pub fn attach(bundle: &Value, disclosure: &Value) -> Result<Value, DiscloseError> {
279    let mut attached = bundle.clone();
280    let mut merged = attached
281        .get("disclosure_map")
282        .and_then(Value::as_object)
283        .cloned()
284        .unwrap_or_default();
285    if let Some(entries) = disclosure.as_object() {
286        for (path, entry) in entries {
287            merged.insert(path.clone(), entry.clone());
288        }
289    }
290    let root = attached
291        .as_object_mut()
292        .ok_or_else(|| DiscloseError("bundle is not an object".to_string()))?;
293    root.insert("disclosure_map".to_string(), Value::Object(merged));
294    Ok(attached)
295}
296
297/// Build a package disclosing only the requested paths from a full map.
298pub fn reveal(
299    redacted: &Value,
300    disclosure: &Value,
301    paths: &[String],
302) -> Result<Value, DiscloseError> {
303    let map = disclosure
304        .as_object()
305        .ok_or_else(|| DiscloseError("disclosure map is not an object".to_string()))?;
306    let missing: Vec<&String> = paths
307        .iter()
308        .filter(|path| !map.contains_key(*path))
309        .collect();
310    if !missing.is_empty() {
311        return Err(DiscloseError(format!(
312            "paths not in disclosure map: {missing:?}"
313        )));
314    }
315    let mut subset = Map::new();
316    for path in paths {
317        if let Some(entry) = map.get(path) {
318            subset.insert(path.clone(), entry.clone());
319        }
320    }
321    attach(redacted, &Value::Object(subset))
322}
323
324/// Deterministic 32-byte seed used by tests and vectors (never for production).
325#[must_use]
326pub fn deterministic_seed(label: &str) -> [u8; 32] {
327    let mut hasher = Sha256::new();
328    hasher.update(format!("continuity-receipt/{label}").as_bytes());
329    hasher.finalize().into()
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335
336    #[test]
337    fn index_tokens_match_python_grammar() {
338        assert_eq!(index_token("receipts[12]"), Some(("receipts", 12)));
339        assert_eq!(index_token("receipts[0]"), Some(("receipts", 0)));
340        assert_eq!(index_token("_x[3]"), Some(("_x", 3)));
341        assert_eq!(index_token("receipts[]"), None);
342        assert_eq!(index_token("receipts[a]"), None);
343        assert_eq!(index_token("9receipts[1]"), None);
344        assert_eq!(index_token("receipts[1]x"), None);
345        assert_eq!(index_token("receipts"), None);
346    }
347
348    #[test]
349    fn malformed_paths_are_rejected() {
350        assert!(tokens("").is_err());
351        assert!(tokens("a..b").is_err());
352        assert!(tokens(".a").is_err());
353        assert_eq!(tokens("a.b[1].c").expect("valid").len(), 3);
354    }
355
356    #[test]
357    fn deterministic_seed_matches_python_labels() {
358        let key = SigningKey::from_bytes(&deterministic_seed("gate-1"));
359        assert_eq!(
360            did_from_signing_key(&key),
361            "did:key:z6MkwSG2hFkD41K85fvQFtNGCZXYFUwEZDqrzahZvWHt5hm1"
362        );
363    }
364
365    fn vector(name: &str) -> Value {
366        let path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
367            .join("../vectors")
368            .join(name);
369        let text = std::fs::read_to_string(&path)
370            .unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display()));
371        serde_json::from_str(&text)
372            .unwrap_or_else(|error| panic!("cannot parse {}: {error}", path.display()))
373    }
374
375    fn gate_key() -> SigningKey {
376        SigningKey::from_bytes(&deterministic_seed("gate-1"))
377    }
378
379    /// Vector 02 plus a benign optional field on the first receipt, re-signed
380    /// so the whole chain can be re-signed from index 0.
381    fn optional_note_bundle() -> Value {
382        let mut bundle = vector("02_happy_full.json");
383        bundle["receipts"][0]["body"]["note"] = serde_json::json!("synthetic-optional");
384        resign_tail(&mut bundle, 0, &gate_key()).expect("setup re-sign");
385        bundle
386    }
387
388    #[test]
389    fn whole_chain_resign_after_first_receipt_redaction() {
390        let bundle = optional_note_bundle();
391        let original: Vec<String> = bundle["receipts"]
392            .as_array()
393            .expect("receipts")
394            .iter()
395            .map(|receipt| {
396                receipt["sig"]["value"]
397                    .as_str()
398                    .expect("signature")
399                    .to_string()
400            })
401            .collect();
402        let salts: Map<String, Value> =
403            [("receipts[0].body.note".to_string(), serde_json::json!("00"))]
404                .into_iter()
405                .collect();
406        let (redacted, map) = redact(
407            &bundle,
408            &["receipts[0].body.note".to_string()],
409            Some(&salts),
410            Some(&gate_key()),
411        )
412        .expect("redaction succeeds");
413        for index in 0..original.len() {
414            assert_ne!(
415                redacted["receipts"][index]["sig"]["value"]
416                    .as_str()
417                    .expect("signature"),
418                original[index],
419                "receipt {index} must be re-signed"
420            );
421        }
422        let attached = attach(&redacted, &map).expect("attach");
423        assert_eq!(
424            crate::verify::verify_bundle(&attached, false).verdict(),
425            "TRUSTED"
426        );
427    }
428
429    #[test]
430    fn reveal_withholds_unrequested_paths() {
431        let bundle = optional_note_bundle();
432        let salts: Map<String, Value> = [
433            ("receipts[0].body.note".to_string(), serde_json::json!("00")),
434            (
435                "receipts[3].body.spec_ref".to_string(),
436                serde_json::json!("11"),
437            ),
438        ]
439        .into_iter()
440        .collect();
441        let (redacted, map) = redact(
442            &bundle,
443            &[
444                "receipts[0].body.note".to_string(),
445                "receipts[3].body.spec_ref".to_string(),
446            ],
447            Some(&salts),
448            Some(&gate_key()),
449        )
450        .expect("redaction succeeds");
451        let package = reveal(
452            &redacted,
453            &map,
454            &["receipts[3].body.spec_ref".to_string()],
455        )
456        .expect("reveal");
457        assert_eq!(
458            crate::verify::verify_bundle(&package, false).verdict(),
459            "PROVISIONAL",
460            "a withheld path must stay PROVISIONAL"
461        );
462        let full = attach(&redacted, &map).expect("attach");
463        assert_eq!(
464            crate::verify::verify_bundle(&full, false).verdict(),
465            "TRUSTED"
466        );
467    }
468}