baseid_sd_jwt/
disclosure.rs

1//! SD-JWT disclosure types and encoding.
2
3use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
4use baseid_core::error::CryptoError;
5use rand::Rng;
6use serde::{Deserialize, Serialize};
7use sha2::{Digest, Sha256};
8
9/// A decoded SD-JWT disclosure.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct Disclosure {
12    /// Random salt.
13    pub salt: String,
14    /// Claim name (None for array elements).
15    pub claim_name: Option<String>,
16    /// Claim value.
17    pub claim_value: serde_json::Value,
18}
19
20impl Disclosure {
21    /// Create a new disclosure with a random 128-bit salt.
22    pub fn new(claim_name: Option<String>, value: serde_json::Value) -> Self {
23        let mut salt_bytes = [0u8; 16];
24        rand::thread_rng().fill(&mut salt_bytes);
25        let salt = URL_SAFE_NO_PAD.encode(salt_bytes);
26        Self {
27            salt,
28            claim_name,
29            claim_value: value,
30        }
31    }
32
33    /// Encode this disclosure as a base64url string.
34    ///
35    /// Format: base64url(`[salt, name, value]`) for object claims,
36    /// or base64url(`[salt, value]`) for array elements.
37    pub fn encode(&self) -> baseid_core::Result<String> {
38        let array = match &self.claim_name {
39            Some(name) => serde_json::json!([self.salt, name, self.claim_value]),
40            None => serde_json::json!([self.salt, self.claim_value]),
41        };
42        let json =
43            serde_json::to_string(&array).map_err(baseid_core::error::SerializationError::Json)?;
44        Ok(URL_SAFE_NO_PAD.encode(json.as_bytes()))
45    }
46
47    /// Decode a disclosure from a base64url-encoded string.
48    pub fn decode(encoded: &str) -> baseid_core::Result<Self> {
49        let bytes = URL_SAFE_NO_PAD
50            .decode(encoded)
51            .map_err(|_| CryptoError::VerificationFailed)?;
52        let array: Vec<serde_json::Value> =
53            serde_json::from_slice(&bytes).map_err(baseid_core::error::SerializationError::Json)?;
54
55        match array.len() {
56            // Array element disclosure: [salt, value]
57            2 => {
58                let salt = array[0]
59                    .as_str()
60                    .ok_or(CryptoError::VerificationFailed)?
61                    .to_string();
62                Ok(Self {
63                    salt,
64                    claim_name: None,
65                    claim_value: array[1].clone(),
66                })
67            }
68            // Object claim disclosure: [salt, name, value]
69            3 => {
70                let salt = array[0]
71                    .as_str()
72                    .ok_or(CryptoError::VerificationFailed)?
73                    .to_string();
74                let name = array[1]
75                    .as_str()
76                    .ok_or(CryptoError::VerificationFailed)?
77                    .to_string();
78                Ok(Self {
79                    salt,
80                    claim_name: Some(name),
81                    claim_value: array[2].clone(),
82                })
83            }
84            _ => Err(CryptoError::VerificationFailed.into()),
85        }
86    }
87
88    /// Compute the SHA-256 digest of the encoded disclosure (base64url of the hash).
89    pub fn digest(&self) -> baseid_core::Result<String> {
90        let encoded = self.encode()?;
91        let hash = Sha256::digest(encoded.as_bytes());
92        Ok(URL_SAFE_NO_PAD.encode(hash))
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn encode_decode_roundtrip_object() {
102        let d = Disclosure::new(Some("name".to_string()), serde_json::json!("Alice"));
103        let encoded = d.encode().unwrap();
104        let decoded = Disclosure::decode(&encoded).unwrap();
105        assert_eq!(decoded.salt, d.salt);
106        assert_eq!(decoded.claim_name, Some("name".to_string()));
107        assert_eq!(decoded.claim_value, serde_json::json!("Alice"));
108    }
109
110    #[test]
111    fn encode_decode_roundtrip_array() {
112        let d = Disclosure::new(None, serde_json::json!(42));
113        let encoded = d.encode().unwrap();
114        let decoded = Disclosure::decode(&encoded).unwrap();
115        assert_eq!(decoded.salt, d.salt);
116        assert_eq!(decoded.claim_name, None);
117        assert_eq!(decoded.claim_value, serde_json::json!(42));
118    }
119
120    #[test]
121    fn random_salt_uniqueness() {
122        let d1 = Disclosure::new(Some("a".to_string()), serde_json::json!(1));
123        let d2 = Disclosure::new(Some("a".to_string()), serde_json::json!(1));
124        assert_ne!(d1.salt, d2.salt);
125    }
126
127    #[test]
128    fn digest_determinism() {
129        let d = Disclosure {
130            salt: "fixed_salt".to_string(),
131            claim_name: Some("name".to_string()),
132            claim_value: serde_json::json!("Alice"),
133        };
134        let h1 = d.digest().unwrap();
135        let h2 = d.digest().unwrap();
136        assert_eq!(h1, h2);
137    }
138
139    // --- B2: SD-JWT test vector verification ---
140
141    fn verify_disclosure_vector(v: &baseid_test_vectors::sd_jwt::DisclosureVector) {
142        let d = Disclosure {
143            salt: v.salt.to_string(),
144            claim_name: v.claim_name.map(|s| s.to_string()),
145            claim_value: serde_json::from_str(v.claim_value).unwrap(),
146        };
147
148        // Verify encoding matches expected
149        let encoded = d.encode().unwrap();
150        assert_eq!(encoded, v.encoded, "encoding mismatch for salt={}", v.salt);
151
152        // Verify digest matches expected
153        let digest = d.digest().unwrap();
154        assert_eq!(digest, v.digest, "digest mismatch for salt={}", v.salt);
155
156        // Verify decode roundtrip
157        let decoded = Disclosure::decode(&encoded).unwrap();
158        assert_eq!(decoded.salt, v.salt);
159        assert_eq!(decoded.claim_name.as_deref(), v.claim_name);
160        assert_eq!(decoded.claim_value, d.claim_value);
161    }
162
163    #[test]
164    fn vector_all_disclosures() {
165        for v in baseid_test_vectors::sd_jwt::ALL {
166            verify_disclosure_vector(v);
167        }
168    }
169
170    #[test]
171    fn vector_ietf_disclosure_decode() {
172        use baseid_test_vectors::sd_jwt;
173
174        // IETF spec uses spaces in JSON; our decoder should handle it
175        let d = Disclosure::decode(sd_jwt::IETF_DISCLOSURE_WITH_SPACES).unwrap();
176        assert_eq!(d.salt, sd_jwt::IETF_DISCLOSURE_SALT);
177        assert_eq!(d.claim_name.as_deref(), Some(sd_jwt::IETF_DISCLOSURE_NAME));
178        assert_eq!(
179            d.claim_value,
180            serde_json::json!(sd_jwt::IETF_DISCLOSURE_VALUE)
181        );
182    }
183}