use rand::rngs::OsRng;
use rand::RngCore;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::collections::BTreeSet;
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Disclosure {
pub salt: String,
pub name: String,
pub value: Value,
}
#[derive(Debug, Clone)]
pub struct Commitment {
pub sd: Vec<String>,
pub disclosures: Vec<Disclosure>,
}
pub fn new_salt() -> String {
let mut bytes = [0u8; 16];
OsRng.fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
impl Disclosure {
pub fn new(salt: impl Into<String>, name: impl Into<String>, value: Value) -> Self {
Self {
salt: salt.into(),
name: name.into(),
value,
}
}
pub fn encode(&self) -> String {
let array = Value::Array(vec![
Value::String(self.salt.clone()),
Value::String(self.name.clone()),
self.value.clone(),
]);
canonical_json_string(&array)
}
pub fn digest(&self) -> String {
digest_of_encoded(&self.encode())
}
}
pub fn commit(claims: &[(String, Value)]) -> Commitment {
let disclosures: Vec<Disclosure> = claims
.iter()
.map(|(name, value)| Disclosure::new(new_salt(), name.clone(), value.clone()))
.collect();
let mut sd: Vec<String> = disclosures.iter().map(|d| d.digest()).collect();
sd.sort();
Commitment { sd, disclosures }
}
pub fn verify_disclosure(encoded: &str, sd_set: &BTreeSet<String>) -> Option<(String, Value)> {
if !sd_set.contains(&digest_of_encoded(encoded)) {
return None;
}
let parsed: Value = serde_json::from_str(encoded).ok()?;
let arr = parsed.as_array()?;
if arr.len() != 3 {
return None;
}
let name = arr[1].as_str()?.to_string();
let value = arr[2].clone();
Some((name, value))
}
fn digest_of_encoded(encoded: &str) -> String {
let digest = Sha256::digest(encoded.as_bytes());
format!("sha256:{}", hex::encode(digest))
}
fn canonical_json_string(value: &Value) -> String {
use std::collections::BTreeMap;
match value {
Value::Object(map) => {
let sorted: BTreeMap<&String, String> = map
.iter()
.map(|(k, v)| (k, canonical_json_string(v)))
.collect();
let mut out = String::from("{");
let mut first = true;
for (k, v) in sorted {
if !first {
out.push(',');
}
first = false;
let key_json = serde_json::to_string(k).expect("string serializes to JSON");
out.push_str(&key_json);
out.push(':');
out.push_str(&v);
}
out.push('}');
out
}
Value::Array(items) => {
let mut out = String::from("[");
let mut first = true;
for v in items {
if !first {
out.push(',');
}
first = false;
out.push_str(&canonical_json_string(v));
}
out.push(']');
out
}
other => serde_json::to_string(other).expect("scalar JSON value serializes"),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn digest_matches_independently_computed_sha256() {
let salt = "c2FsdHNhbHRzYWx0c2Fs"; let d = Disclosure::new(salt, "tools", json!("payments.charge"));
let expected_encoded = r#"["c2FsdHNhbHRzYWx0c2Fs","tools","payments.charge"]"#;
assert_eq!(d.encode(), expected_encoded, "canonical encoding is exact");
let independent = {
let h = Sha256::digest(expected_encoded.as_bytes());
format!("sha256:{}", hex::encode(h))
};
assert_eq!(d.digest(), independent);
}
#[test]
fn object_value_canonicalizes_with_sorted_keys() {
let a = Disclosure::new("s", "limit", json!({"max": 100, "ccy": "USD"}));
let b = Disclosure::new("s", "limit", json!({"ccy": "USD", "max": 100}));
assert_eq!(a.encode(), b.encode());
assert_eq!(a.encode(), r#"["s","limit",{"ccy":"USD","max":100}]"#);
}
#[test]
fn verify_accepts_committed_and_rejects_tampered_or_foreign() {
let claims = vec![
("tools".to_string(), json!("payments.charge")),
("tools".to_string(), json!("email.send")),
("owner".to_string(), json!("human://alice")),
];
let c = commit(&claims);
let sd_set: BTreeSet<String> = c.sd.iter().cloned().collect();
let first = c.disclosures[0].encode();
let revealed = verify_disclosure(&first, &sd_set).expect("committed disclosure verifies");
assert_eq!(revealed.0, "tools");
assert_eq!(revealed.1, json!("payments.charge"));
let tampered = first.replace("payments.charge", "payments.refund");
assert_ne!(tampered, first);
assert!(verify_disclosure(&tampered, &sd_set).is_none());
let foreign = Disclosure::new(new_salt(), "tools", json!("admin.root")).encode();
assert!(verify_disclosure(&foreign, &sd_set).is_none());
assert!(verify_disclosure("not json", &sd_set).is_none());
assert!(verify_disclosure("[1,2]", &sd_set).is_none());
}
#[test]
fn commit_sorts_digests_to_hide_input_order() {
let forward = vec![
Disclosure::new("salt-a", "a", json!(1)),
Disclosure::new("salt-b", "b", json!(2)),
Disclosure::new("salt-c", "c", json!(3)),
];
let sd_of = |ds: &[Disclosure]| {
let mut sd: Vec<String> = ds.iter().map(|d| d.digest()).collect();
sd.sort();
sd
};
let mut reversed = forward.clone();
reversed.reverse();
assert_eq!(sd_of(&forward), sd_of(&reversed));
assert!(sd_of(&forward).windows(2).all(|w| w[0] <= w[1]), "sorted");
}
#[test]
fn salt_is_128_bit_and_fresh() {
let s1 = new_salt();
let s2 = new_salt();
assert_ne!(s1, s2, "salts are random");
let bytes = URL_SAFE_NO_PAD.decode(&s1).expect("salt is base64url");
assert_eq!(bytes.len(), 16, "128-bit salt");
}
}