1use 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#[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
52fn 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#[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
155fn 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
171fn 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
218pub 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
276pub 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
297pub 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#[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 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}