Skip to main content

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}