ssi_sd_jwt/
disclosure.rs

1use crate::{utils::is_url_safe_base64_char, DecodeError};
2use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine};
3use serde_json::Value;
4use std::{
5    borrow::{Borrow, Cow},
6    fmt,
7};
8
9/// Invalid SD-JWT disclosure.
10#[derive(Debug, thiserror::Error)]
11#[error("invalid SD-JWT disclosure: `{0}`")]
12pub struct InvalidDisclosure<T>(pub T);
13
14/// Creates a static disclosure.
15#[macro_export]
16macro_rules! disclosure {
17    ($s:literal) => {
18        match $crate::Disclosure::from_str_const($s) {
19            Ok(d) => d,
20            Err(_) => panic!("invalid disclosure"),
21        }
22    };
23}
24
25/// Encoded disclosure.
26///
27/// An encoded disclosure is a url-safe base-64 string encoding (without
28/// padding) an array containing the disclosure's parameters.
29///
30/// See: <https://www.ietf.org/archive/id/draft-ietf-oauth-selective-disclosure-jwt-12.html#section-5>
31#[derive(PartialEq)]
32pub struct Disclosure([u8]);
33
34impl Disclosure {
35    /// Parses the given `disclosure` bytes.
36    ///
37    /// Returns an error if the input value is not a valid url-safe base64
38    /// string without padding.
39    pub fn new<T: ?Sized + AsRef<[u8]>>(disclosure: &T) -> Result<&Self, InvalidDisclosure<&T>> {
40        let bytes = disclosure.as_ref();
41        if bytes.iter().copied().all(is_url_safe_base64_char) {
42            Ok(unsafe { Self::new_unchecked(bytes) })
43        } else {
44            Err(InvalidDisclosure(disclosure))
45        }
46    }
47
48    /// Parses the given `disclosure` string.
49    ///
50    /// Returns an error if the input string is not a valid url-safe base64
51    /// string without padding.
52    ///
53    /// This function is limited to a `&str` input, but can be used in the const
54    /// context.
55    pub const fn from_str_const(disclosure: &str) -> Result<&Self, InvalidDisclosure<&str>> {
56        let bytes = disclosure.as_bytes();
57        let mut i = 0;
58
59        while i < bytes.len() {
60            if !is_url_safe_base64_char(bytes[i]) {
61                return Err(InvalidDisclosure(disclosure));
62            }
63
64            i += 1
65        }
66
67        Ok(unsafe { Self::new_unchecked(bytes) })
68    }
69
70    /// Creates a new disclosure out of the given `bytes` without validation.
71    ///
72    /// # Safety
73    ///
74    /// The input bytes **must** be a valid url-safe base64 string without
75    /// padding.
76    pub const unsafe fn new_unchecked(bytes: &[u8]) -> &Self {
77        std::mem::transmute(bytes)
78    }
79
80    /// Returns underlying bytes of the disclosure.
81    pub fn as_bytes(&self) -> &[u8] {
82        &self.0
83    }
84
85    /// Returns this disclosure as a string.
86    pub fn as_str(&self) -> &str {
87        unsafe {
88            // SAFETY: disclosures are url-safe base-64 strings.
89            std::str::from_utf8_unchecked(&self.0)
90        }
91    }
92}
93
94impl AsRef<[u8]> for Disclosure {
95    fn as_ref(&self) -> &[u8] {
96        self.as_bytes()
97    }
98}
99
100impl AsRef<str> for Disclosure {
101    fn as_ref(&self) -> &str {
102        self.as_str()
103    }
104}
105
106impl fmt::Display for Disclosure {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        self.as_str().fmt(f)
109    }
110}
111
112impl fmt::Debug for Disclosure {
113    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
114        self.as_str().fmt(f)
115    }
116}
117
118impl ToOwned for Disclosure {
119    type Owned = DisclosureBuf;
120
121    fn to_owned(&self) -> Self::Owned {
122        DisclosureBuf(self.as_bytes().to_owned())
123    }
124}
125
126/// Owned disclosure.
127pub struct DisclosureBuf(Vec<u8>);
128
129impl DisclosureBuf {
130    /// Creates a disclosure from its defining parts.
131    pub fn encode_from_parts(salt: &str, kind: &DisclosureDescription) -> Self {
132        Self(
133            BASE64_URL_SAFE_NO_PAD
134                .encode(kind.to_value(salt).to_string())
135                .into_bytes(),
136        )
137    }
138
139    /// Borrows the disclosure.
140    pub fn as_disclosure(&self) -> &Disclosure {
141        unsafe {
142            // SAFETY: `self.0` is a disclosure by construction.
143            Disclosure::new_unchecked(&self.0)
144        }
145    }
146}
147
148impl Borrow<Disclosure> for DisclosureBuf {
149    fn borrow(&self) -> &Disclosure {
150        self.as_disclosure()
151    }
152}
153
154impl fmt::Display for DisclosureBuf {
155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
156        self.as_disclosure().fmt(f)
157    }
158}
159
160impl fmt::Debug for DisclosureBuf {
161    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
162        self.as_disclosure().fmt(f)
163    }
164}
165
166/// Decoded disclosure.
167#[derive(Debug, Clone, PartialEq)]
168pub struct DecodedDisclosure<'a> {
169    /// Encoded disclosure.
170    pub encoded: Cow<'a, Disclosure>,
171
172    /// Salt.
173    pub salt: String,
174
175    /// Disclosure description.
176    pub desc: DisclosureDescription,
177}
178
179impl<'a> DecodedDisclosure<'a> {
180    /// Decodes the given encoded disclosure.
181    pub fn new(encoded: &'a (impl ?Sized + AsRef<[u8]>)) -> Result<Self, DecodeError> {
182        let base64 = encoded.as_ref();
183        let bytes = BASE64_URL_SAFE_NO_PAD
184            .decode(base64)
185            .map_err(|_| DecodeError::DisclosureMalformed)?;
186
187        let encoded = unsafe {
188            // SAFETY: by decoding `base64` we validated the disclosure.
189            Disclosure::new_unchecked(base64)
190        };
191
192        let json: serde_json::Value = serde_json::from_slice(&bytes)?;
193
194        match json {
195            serde_json::Value::Array(values) => match values.as_slice() {
196                [salt, name, value] => Ok(DecodedDisclosure {
197                    encoded: Cow::Borrowed(encoded),
198                    salt: salt
199                        .as_str()
200                        .ok_or(DecodeError::DisclosureMalformed)?
201                        .to_owned(),
202                    desc: DisclosureDescription::ObjectEntry {
203                        key: name
204                            .as_str()
205                            .ok_or(DecodeError::DisclosureMalformed)?
206                            .to_owned(),
207                        value: value.clone(),
208                    },
209                }),
210                [salt, value] => Ok(DecodedDisclosure {
211                    encoded: Cow::Borrowed(encoded),
212                    salt: salt
213                        .as_str()
214                        .ok_or(DecodeError::DisclosureMalformed)?
215                        .to_owned(),
216                    desc: DisclosureDescription::ArrayItem(value.clone()),
217                }),
218                _ => Err(DecodeError::DisclosureMalformed),
219            },
220            _ => Err(DecodeError::DisclosureMalformed),
221        }
222    }
223
224    /// Creates a decoded disclosure from its parts.
225    ///
226    /// The parts will be automatically encoded to populate the `encoded`
227    /// field.
228    pub fn from_parts(salt: String, kind: DisclosureDescription) -> Self {
229        Self {
230            encoded: Cow::Owned(DisclosureBuf::encode_from_parts(&salt, &kind)),
231            salt,
232            desc: kind,
233        }
234    }
235
236    /// Clones the encoded disclosure to fully owned the decoded disclosure.
237    pub fn into_owned(self) -> DecodedDisclosure<'static> {
238        DecodedDisclosure {
239            encoded: Cow::Owned(self.encoded.into_owned()),
240            salt: self.salt,
241            desc: self.desc,
242        }
243    }
244}
245
246/// Disclosure description.
247#[derive(Debug, Clone, PartialEq)]
248pub enum DisclosureDescription {
249    /// Object entry disclosure.
250    ObjectEntry {
251        /// Entry key.
252        key: String,
253
254        /// Entry value.
255        value: serde_json::Value,
256    },
257
258    /// Array item disclosure.
259    ArrayItem(serde_json::Value),
260}
261
262impl DisclosureDescription {
263    /// Turns this disclosure description into a JSON value.
264    pub fn to_value(&self, salt: &str) -> Value {
265        match self {
266            Self::ObjectEntry { key, value } => {
267                Value::Array(vec![salt.into(), key.to_owned().into(), value.clone()])
268            }
269            Self::ArrayItem(value) => Value::Array(vec![salt.into(), value.clone()]),
270        }
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277    use crate::SdAlg;
278
279    fn verify_sd_disclosures_array(
280        digest_algo: SdAlg,
281        disclosures: &[&str],
282        sd_claim: &[&str],
283    ) -> Result<serde_json::Value, DecodeError> {
284        let mut verfied_claims = serde_json::Map::new();
285
286        for disclosure in disclosures {
287            let disclosure_hash = digest_algo.hash(Disclosure::new(disclosure).unwrap());
288
289            if !disclosure_hash_exists_in_sd_claims(&disclosure_hash, sd_claim) {
290                continue;
291            }
292
293            let decoded = DecodedDisclosure::new(disclosure)?;
294
295            match decoded.desc {
296                DisclosureDescription::ObjectEntry { key: name, value } => {
297                    let orig = verfied_claims.insert(name, value);
298
299                    if orig.is_some() {
300                        return Err(DecodeError::DisclosureUsedMultipleTimes);
301                    }
302                }
303                DisclosureDescription::ArrayItem(_) => {
304                    return Err(DecodeError::ArrayDisclosureWhenExpectingProperty);
305                }
306            }
307        }
308
309        Ok(serde_json::Value::Object(verfied_claims))
310    }
311
312    fn disclosure_hash_exists_in_sd_claims(disclosure_hash: &str, sd_claim: &[&str]) -> bool {
313        for sd_claim_item in sd_claim {
314            if &disclosure_hash == sd_claim_item {
315                return true;
316            }
317        }
318
319        false
320    }
321
322    #[test]
323    fn test_verify_disclosures() {
324        const DISCLOSURES: [&str; 7] = [
325            "WyJyU0x1em5oaUxQQkRSWkUxQ1o4OEtRIiwgInN1YiIsICJqb2huX2RvZV80MiJd",
326            "WyJhYTFPYmdlUkJnODJudnpMYnRQTklRIiwgImdpdmVuX25hbWUiLCAiSm9obiJd",
327            "WyI2VWhsZU5HUmJtc0xDOFRndTh2OFdnIiwgImZhbWlseV9uYW1lIiwgIkRvZSJd",
328            "WyJ2S0t6alFSOWtsbFh2OWVkNUJ1ZHZRIiwgImVtYWlsIiwgImpvaG5kb2VAZXhhbXBsZS5jb20iXQ",
329            "WyJVZEVmXzY0SEN0T1BpZDRFZmhPQWNRIiwgInBob25lX251bWJlciIsICIrMS0yMDItNTU1LTAxMDEiXQ",
330            "WyJOYTNWb0ZGblZ3MjhqT0FyazdJTlZnIiwgImFkZHJlc3MiLCB7InN0cmVldF9hZGRyZXNzIjogIjEyMyBNYWluIFN0IiwgImxvY2FsaXR5IjogIkFueXRvd24iLCAicmVnaW9uIjogIkFueXN0YXRlIiwgImNvdW50cnkiOiAiVVMifV0",
331            "WyJkQW9mNHNlZTFGdDBXR2dHanVjZ2pRIiwgImJpcnRoZGF0ZSIsICIxOTQwLTAxLTAxIl0",
332        ];
333
334        const SD_CLAIM: [&str; 7] = [
335            "5nXy0Z3QiEba1V1lJzeKhAOGQXFlKLIWCLlhf_O-cmo",
336            "9gZhHAhV7LZnOFZq_q7Fh8rzdqrrNM-hRWsVOlW3nuw",
337            "S-JPBSkvqliFv1__thuXt3IzX5B_ZXm4W2qs4BoNFrA",
338            "bviw7pWAkbzI078ZNVa_eMZvk0tdPa5w2o9R3Zycjo4",
339            "o-LBCDrFF6tC9ew1vAlUmw6Y30CHZF5jOUFhpx5mogI",
340            "pzkHIM9sv7oZH6YKDsRqNgFGLpEKIj3c5G6UKaTsAjQ",
341            "rnAzCT6DTy4TsX9QCDv2wwAE4Ze20uRigtVNQkA52X0",
342        ];
343
344        let expected_claims: serde_json::Value = serde_json::json!({
345            "sub": "john_doe_42",
346            "given_name": "John",
347            "family_name": "Doe",
348            "email": "johndoe@example.com",
349            "phone_number": "+1-202-555-0101",
350            "address": {"street_address": "123 Main St", "locality": "Anytown", "region": "Anystate", "country": "US"},
351            "birthdate": "1940-01-01"
352        });
353
354        let verified_claims =
355            verify_sd_disclosures_array(SdAlg::Sha256, &DISCLOSURES, &SD_CLAIM).unwrap();
356
357        assert_eq!(verified_claims, expected_claims)
358    }
359
360    #[test]
361    fn test_verify_subset_of_disclosures() {
362        const DISCLOSURES: [&str; 2] = [
363            "WyJhYTFPYmdlUkJnODJudnpMYnRQTklRIiwgImdpdmVuX25hbWUiLCAiSm9obiJd",
364            "WyI2VWhsZU5HUmJtc0xDOFRndTh2OFdnIiwgImZhbWlseV9uYW1lIiwgIkRvZSJd",
365        ];
366
367        const SD_CLAIM: [&str; 7] = [
368            "5nXy0Z3QiEba1V1lJzeKhAOGQXFlKLIWCLlhf_O-cmo",
369            "9gZhHAhV7LZnOFZq_q7Fh8rzdqrrNM-hRWsVOlW3nuw",
370            "S-JPBSkvqliFv1__thuXt3IzX5B_ZXm4W2qs4BoNFrA",
371            "bviw7pWAkbzI078ZNVa_eMZvk0tdPa5w2o9R3Zycjo4",
372            "o-LBCDrFF6tC9ew1vAlUmw6Y30CHZF5jOUFhpx5mogI",
373            "pzkHIM9sv7oZH6YKDsRqNgFGLpEKIj3c5G6UKaTsAjQ",
374            "rnAzCT6DTy4TsX9QCDv2wwAE4Ze20uRigtVNQkA52X0",
375        ];
376
377        let expected_claims: serde_json::Value = serde_json::json!({
378            "given_name": "John",
379            "family_name": "Doe",
380        });
381
382        let verified_claims =
383            verify_sd_disclosures_array(SdAlg::Sha256, &DISCLOSURES, &SD_CLAIM).unwrap();
384
385        assert_eq!(verified_claims, expected_claims)
386    }
387
388    #[test]
389    fn decode_array_disclosure() {
390        assert_eq!(
391            DecodedDisclosure::from_parts(
392                "nPuoQnkRFq3BIeAm7AnXFA".to_owned(),
393                DisclosureDescription::ArrayItem(serde_json::json!("DE"))
394            ),
395            DecodedDisclosure::new("WyJuUHVvUW5rUkZxM0JJZUFtN0FuWEZBIiwiREUiXQ").unwrap()
396        )
397    }
398}