treeship_core/disclosure.rs
1//! Selective disclosure over signed statements (SD-JWT-style, ported to DSSE).
2//!
3//! The problem: a plain Ed25519 signature commits to the *entire* payload, so
4//! a verifier cannot check the signature without seeing every field. To let an
5//! agent reveal one capability (or one mandate field) while hiding the rest, we
6//! borrow the SD-JWT construction and apply it to Treeship's JCS-canonical JSON
7//! payloads instead of JWT serialization.
8//!
9//! The construction:
10//!
11//! * Each disclosable claim `(name, value)` is bound to a fresh 128-bit salt
12//! as a **disclosure**: the canonical JSON array `[salt, name, value]`.
13//! * The claim's **digest** is `sha256:<hex>` of that canonical encoding,
14//! matching Treeship's existing digest convention (`nonce_digest`,
15//! `canonical_json_digest`).
16//! * The signed payload carries only the *sorted list of digests* (`_sd`),
17//! never the raw disclosable values. The Ed25519 signature therefore
18//! commits to the digests, which are always present, so it verifies even
19//! when the values are withheld.
20//! * To reveal a claim, the holder presents its disclosure string. The
21//! verifier recomputes the digest over the exact received bytes and checks
22//! membership in the signed `_sd` set. A digest that is not in the set
23//! reveals nothing (fail-closed); a holder cannot present a value that was
24//! not committed, because it could not produce a matching digest.
25//!
26//! This layer answers "reveal this claim or not." Proving a *property* of a
27//! withheld claim without revealing it (a range, set membership without
28//! revealing which) is the zero-knowledge layer, which opens the *same*
29//! committed values; see `docs/specs/private-verification.md`. The salt makes each
30//! digest unguessable, so a verifier cannot brute-force a low-entropy withheld
31//! value from its digest.
32
33use rand::rngs::OsRng;
34use rand::RngCore;
35use serde_json::Value;
36use sha2::{Digest, Sha256};
37use std::collections::BTreeSet;
38
39use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
40
41/// A single disclosable claim bound to a salt. The wire form is `encode()`;
42/// the signed payload stores `digest()`.
43#[derive(Debug, Clone, PartialEq, Eq)]
44pub struct Disclosure {
45 pub salt: String,
46 pub name: String,
47 pub value: Value,
48}
49
50/// The output of committing a set of disclosable claims: the sorted digest
51/// list that goes into the signed payload, and the disclosures the holder
52/// keeps to reveal later.
53#[derive(Debug, Clone)]
54pub struct Commitment {
55 /// Sorted `sha256:<hex>` digests. Goes into the signed statement as `_sd`.
56 /// Sorted so the on-wire order does not leak the claims' insertion order.
57 pub sd: Vec<String>,
58 /// One disclosure per committed claim, in input order. The holder stores
59 /// these and presents the subset it chooses to reveal.
60 pub disclosures: Vec<Disclosure>,
61}
62
63/// A fresh 128-bit salt from the OS CSPRNG, base64url-nopad encoded. Security
64/// material (unguessability of low-entropy values rests on it), so `OsRng`,
65/// never `thread_rng`.
66pub fn new_salt() -> String {
67 let mut bytes = [0u8; 16];
68 OsRng.fill_bytes(&mut bytes);
69 URL_SAFE_NO_PAD.encode(bytes)
70}
71
72impl Disclosure {
73 /// Bind a claim to the given salt. Callers use `new_salt()` for the salt;
74 /// tests pin it for reproducible vectors.
75 pub fn new(salt: impl Into<String>, name: impl Into<String>, value: Value) -> Self {
76 Self {
77 salt: salt.into(),
78 name: name.into(),
79 value,
80 }
81 }
82
83 /// Canonical wire encoding: the JCS-canonical JSON of `[salt, name, value]`.
84 /// Deterministic even when `value` is an object, because nested objects are
85 /// serialized with sorted keys.
86 pub fn encode(&self) -> String {
87 let array = Value::Array(vec![
88 Value::String(self.salt.clone()),
89 Value::String(self.name.clone()),
90 self.value.clone(),
91 ]);
92 canonical_json_string(&array)
93 }
94
95 /// `sha256:<hex>` over the canonical encoding.
96 pub fn digest(&self) -> String {
97 digest_of_encoded(&self.encode())
98 }
99}
100
101/// Commit a set of disclosable claims. Each gets a fresh salt; the returned
102/// `sd` list is sorted to hide input order.
103pub fn commit(claims: &[(String, Value)]) -> Commitment {
104 let disclosures: Vec<Disclosure> = claims
105 .iter()
106 .map(|(name, value)| Disclosure::new(new_salt(), name.clone(), value.clone()))
107 .collect();
108 let mut sd: Vec<String> = disclosures.iter().map(|d| d.digest()).collect();
109 sd.sort();
110 Commitment { sd, disclosures }
111}
112
113/// Verify a presented disclosure against a signed `_sd` set. Returns the
114/// revealed `(name, value)` only when the disclosure's digest, recomputed over
115/// the *exact received bytes*, is present in the set. Fails closed on a
116/// malformed disclosure or a digest not in the set.
117pub fn verify_disclosure(encoded: &str, sd_set: &BTreeSet<String>) -> Option<(String, Value)> {
118 // Membership is decided over the received bytes, so a holder must present
119 // the exact bytes that were committed; a re-encoded or altered disclosure
120 // simply fails the set check.
121 if !sd_set.contains(&digest_of_encoded(encoded)) {
122 return None;
123 }
124 let parsed: Value = serde_json::from_str(encoded).ok()?;
125 let arr = parsed.as_array()?;
126 if arr.len() != 3 {
127 return None;
128 }
129 let name = arr[1].as_str()?.to_string();
130 let value = arr[2].clone();
131 Some((name, value))
132}
133
134fn digest_of_encoded(encoded: &str) -> String {
135 let digest = Sha256::digest(encoded.as_bytes());
136 format!("sha256:{}", hex::encode(digest))
137}
138
139/// Sorted-key canonical JSON. Mirrors the copies in `statements::invitation`
140/// and `merkle::checkpoint` (intentionally duplicated rather than a cross-module
141/// `pub use`, to keep each module self-contained per the existing convention).
142fn canonical_json_string(value: &Value) -> String {
143 use std::collections::BTreeMap;
144 match value {
145 Value::Object(map) => {
146 let sorted: BTreeMap<&String, String> = map
147 .iter()
148 .map(|(k, v)| (k, canonical_json_string(v)))
149 .collect();
150 let mut out = String::from("{");
151 let mut first = true;
152 for (k, v) in sorted {
153 if !first {
154 out.push(',');
155 }
156 first = false;
157 let key_json = serde_json::to_string(k).expect("string serializes to JSON");
158 out.push_str(&key_json);
159 out.push(':');
160 out.push_str(&v);
161 }
162 out.push('}');
163 out
164 }
165 Value::Array(items) => {
166 let mut out = String::from("[");
167 let mut first = true;
168 for v in items {
169 if !first {
170 out.push(',');
171 }
172 first = false;
173 out.push_str(&canonical_json_string(v));
174 }
175 out.push(']');
176 out
177 }
178 other => serde_json::to_string(other).expect("scalar JSON value serializes"),
179 }
180}
181
182#[cfg(test)]
183mod tests {
184 use super::*;
185 use serde_json::json;
186
187 // A hand-constructed vector. The disclosure `[salt, "tools", "payments.charge"]`
188 // canonicalizes to exactly this string (scalars, no whitespace), and its
189 // digest is sha256 of those bytes. The expected string and hash are computed
190 // here independently of the module's `encode()`/`digest()`, per the
191 // AI-assisted development policy on real test vectors.
192 #[test]
193 fn digest_matches_independently_computed_sha256() {
194 let salt = "c2FsdHNhbHRzYWx0c2Fs"; // fixed, not from new_salt()
195 let d = Disclosure::new(salt, "tools", json!("payments.charge"));
196
197 let expected_encoded = r#"["c2FsdHNhbHRzYWx0c2Fs","tools","payments.charge"]"#;
198 assert_eq!(d.encode(), expected_encoded, "canonical encoding is exact");
199
200 // Independent digest: hash the literal bytes with sha2 directly.
201 let independent = {
202 let h = Sha256::digest(expected_encoded.as_bytes());
203 format!("sha256:{}", hex::encode(h))
204 };
205 assert_eq!(d.digest(), independent);
206 }
207
208 // Object-valued claims canonicalize with sorted keys, so the digest is
209 // stable regardless of the input map's key order.
210 #[test]
211 fn object_value_canonicalizes_with_sorted_keys() {
212 let a = Disclosure::new("s", "limit", json!({"max": 100, "ccy": "USD"}));
213 let b = Disclosure::new("s", "limit", json!({"ccy": "USD", "max": 100}));
214 assert_eq!(a.encode(), b.encode());
215 assert_eq!(a.encode(), r#"["s","limit",{"ccy":"USD","max":100}]"#);
216 }
217
218 #[test]
219 fn verify_accepts_committed_and_rejects_tampered_or_foreign() {
220 let claims = vec![
221 ("tools".to_string(), json!("payments.charge")),
222 ("tools".to_string(), json!("email.send")),
223 ("owner".to_string(), json!("human://alice")),
224 ];
225 let c = commit(&claims);
226 let sd_set: BTreeSet<String> = c.sd.iter().cloned().collect();
227
228 // A genuinely committed disclosure verifies and reveals exactly its claim.
229 let first = c.disclosures[0].encode();
230 let revealed = verify_disclosure(&first, &sd_set).expect("committed disclosure verifies");
231 assert_eq!(revealed.0, "tools");
232 assert_eq!(revealed.1, json!("payments.charge"));
233
234 // A tampered disclosure (value changed) is not in the set -> rejected.
235 let tampered = first.replace("payments.charge", "payments.refund");
236 assert_ne!(tampered, first);
237 assert!(verify_disclosure(&tampered, &sd_set).is_none());
238
239 // A disclosure for a claim that was never committed -> rejected.
240 let foreign = Disclosure::new(new_salt(), "tools", json!("admin.root")).encode();
241 assert!(verify_disclosure(&foreign, &sd_set).is_none());
242
243 // Malformed input -> rejected, not a panic.
244 assert!(verify_disclosure("not json", &sd_set).is_none());
245 assert!(verify_disclosure("[1,2]", &sd_set).is_none());
246 }
247
248 #[test]
249 fn commit_sorts_digests_to_hide_input_order() {
250 // Fixed disclosures (salt tied to the claim, not its position) so the
251 // only thing that changes between the two runs is input order.
252 let forward = vec![
253 Disclosure::new("salt-a", "a", json!(1)),
254 Disclosure::new("salt-b", "b", json!(2)),
255 Disclosure::new("salt-c", "c", json!(3)),
256 ];
257 let sd_of = |ds: &[Disclosure]| {
258 let mut sd: Vec<String> = ds.iter().map(|d| d.digest()).collect();
259 sd.sort();
260 sd
261 };
262 let mut reversed = forward.clone();
263 reversed.reverse();
264 // Sorting makes the on-wire digest list identical regardless of the
265 // order the claims were committed in -> input order is not leaked.
266 assert_eq!(sd_of(&forward), sd_of(&reversed));
267 assert!(sd_of(&forward).windows(2).all(|w| w[0] <= w[1]), "sorted");
268 }
269
270 #[test]
271 fn salt_is_128_bit_and_fresh() {
272 let s1 = new_salt();
273 let s2 = new_salt();
274 assert_ne!(s1, s2, "salts are random");
275 let bytes = URL_SAFE_NO_PAD.decode(&s1).expect("salt is base64url");
276 assert_eq!(bytes.len(), 16, "128-bit salt");
277 }
278}