Skip to main content

identity_kit/sd_jwt_vc/
builder.rs

1//! Issuing an SD-JWT: choosing which claims are selectively disclosable (RFC 9901 §4.2).
2
3use core::fmt;
4
5use serde_json::{Map, Value};
6
7use super::sd_jwt::{ELLIPSIS_KEY, SD_KEY};
8use super::{ClaimPath, Disclosure, HashAlgorithm, SdJwt, SdJwtError};
9use crate::jose::{JwsHeader, Signer, base64url, encode_compact};
10
11/// Turns a claims set into an SD-JWT payload and its disclosures: the claims at the paths
12/// given to [`conceal`](Self::conceal) are replaced by digests, children before parents, so
13/// that concealing a claim and a claim inside it yields recursive disclosures.
14///
15/// Salts are 128 bits from the operating system's random number generator unless
16/// [`with_salt_generator`](Self::with_salt_generator) substitutes a source; decoy digests,
17/// when asked for, are added to every `_sd` array, which is then sorted.
18///
19/// # Examples
20///
21/// The structure of RFC 9901 §6.3: `address` is selectively disclosable, and so is each of its
22/// members.
23///
24/// ```
25/// use identity_kit::sd_jwt_vc::SdJwtBuilder;
26/// use serde_json::json;
27///
28/// let claims = json!({
29///   "sub": "6c5c0a49-b589-431d-bae7-219122a9ec2c",
30///   "address": {
31///     "street_address": "Schulstr. 12",
32///     "locality": "Schulpforta",
33///     "region": "Sachsen-Anhalt",
34///     "country": "DE"
35///   }
36/// });
37/// let encoded = SdJwtBuilder::new(claims.as_object().unwrap().clone())
38///     .conceal(["address"])
39///     .conceal(["address", "street_address"])
40///     .conceal(["address", "locality"])
41///     .conceal(["address", "region"])
42///     .conceal(["address", "country"])
43///     .encode()?;
44/// assert_eq!(encoded.disclosures.len(), 5);
45/// assert_eq!(encoded.payload["_sd"].as_array().unwrap().len(), 1);
46/// assert_eq!(encoded.payload["_sd_alg"], "sha-256");
47/// assert!(!encoded.payload.contains_key("address"));
48/// let address = encoded.disclosures.iter().find(|d| d.name() == Some("address")).unwrap();
49/// assert_eq!(address.value()["_sd"].as_array().unwrap().len(), 4);
50/// # Ok::<(), identity_kit::sd_jwt_vc::SdJwtError>(())
51/// ```
52pub struct SdJwtBuilder {
53    claims: Map<String, Value>,
54    concealed: Vec<ClaimPath>,
55    decoys: usize,
56    alg: HashAlgorithm,
57    salts: Box<dyn FnMut() -> Result<String, SdJwtError> + Send>,
58}
59
60impl SdJwtBuilder {
61    /// The deepest nesting of objects and arrays [`encode`](Self::encode) accepts, counting the
62    /// claims set itself: a claim value may sit inside 63 more containers.
63    ///
64    /// The parsers of this crate read at most 128 nested containers (`serde_json`'s default),
65    /// and concealment, disclosures and the payload add a few around a claim value, so a
66    /// claims set within this limit always processes back; one beyond it might not.
67    /// [`SdJwt::process`](super::SdJwt::process) enforces the same limit on the claims it
68    /// reassembles from the payload and the disclosures.
69    pub const MAX_DEPTH: usize = 64;
70
71    /// A builder over `claims`, nothing concealed yet.
72    pub fn new(claims: Map<String, Value>) -> Self {
73        Self {
74            claims,
75            concealed: Vec::new(),
76            decoys: 0,
77            alg: HashAlgorithm::default(),
78            salts: Box::new(random_salt),
79        }
80    }
81
82    /// This builder concealing the claim at `path`: an object property or an array element,
83    /// with `()` in the path standing for every element of an array.
84    #[must_use]
85    pub fn conceal(mut self, path: impl Into<ClaimPath>) -> Self {
86        self.concealed.push(path.into());
87        self
88    }
89
90    /// This builder adding `count` decoy digests to every `_sd` array.
91    #[must_use]
92    pub fn with_decoys(mut self, count: usize) -> Self {
93        self.decoys = count;
94        self
95    }
96
97    /// This builder hashing with `alg`.
98    #[must_use]
99    pub fn with_hash_algorithm(mut self, alg: HashAlgorithm) -> Self {
100        self.alg = alg;
101        self
102    }
103
104    /// This builder taking its salts from `salts`, one call per disclosure and decoy, in
105    /// document order with the members of an object or array before the object or array
106    /// itself.
107    #[must_use]
108    pub fn with_salt_generator(
109        mut self,
110        mut salts: impl FnMut() -> String + Send + 'static,
111    ) -> Self {
112        self.salts = Box::new(move || Ok(salts()));
113        self
114    }
115
116    /// The claims.
117    pub fn claims(&self) -> &Map<String, Value> {
118        &self.claims
119    }
120
121    /// The paths to conceal.
122    pub fn concealed(&self) -> &[ClaimPath] {
123        &self.concealed
124    }
125
126    /// The payload and the disclosures, unsigned.
127    ///
128    /// # Errors
129    ///
130    /// [`SdJwtError::NoSuchClaim`] when a path selects nothing, [`SdJwtError::NotConcealable`]
131    /// for `_sd` or `...`, [`SdJwtError::Payload`] when the claims already use `_sd` or
132    /// `_sd_alg`, [`SdJwtError::TooDeep`] when they nest deeper than
133    /// [`MAX_DEPTH`](Self::MAX_DEPTH), [`SdJwtError::Randomness`] when the operating system
134    /// provides no randomness for a salt.
135    pub fn encode(mut self) -> Result<Encoded, SdJwtError> {
136        if self.claims.contains_key(HashAlgorithm::CLAIM) {
137            return Err(SdJwtError::payload("the claims already carry `_sd_alg`"));
138        }
139        if self
140            .claims
141            .values()
142            .any(|value| nests_deeper_than(value, Self::MAX_DEPTH - 1))
143        {
144            return Err(SdJwtError::TooDeep {
145                max: Self::MAX_DEPTH,
146            });
147        }
148        let mut matched = vec![false; self.concealed.len()];
149        let mut disclosures = Vec::new();
150        let mut payload = self.object(
151            self.claims.clone(),
152            &ClaimPath::root(),
153            &mut matched,
154            &mut disclosures,
155        )?;
156        if let Some(unmatched) = matched.iter().position(|m| !m) {
157            return Err(SdJwtError::NoSuchClaim(self.concealed[unmatched].clone()));
158        }
159        if !disclosures.is_empty() || payload.contains_key(SD_KEY) {
160            payload.insert(
161                HashAlgorithm::CLAIM.to_owned(),
162                Value::String(self.alg.name().to_owned()),
163            );
164        }
165        Ok(Encoded {
166            payload,
167            disclosures,
168        })
169    }
170
171    /// The SD-JWT: the payload signed under `header` with `signer`, with every disclosure.
172    ///
173    /// # Errors
174    ///
175    /// As [`encode`](Self::encode), or [`SdJwtError::Jws`] when signing fails.
176    ///
177    /// # Panics
178    ///
179    /// Never: the payload is a JSON object, and the JWS one this function has just encoded.
180    pub async fn sign(self, header: &JwsHeader, signer: &dyn Signer) -> Result<SdJwt, SdJwtError> {
181        let encoded = self.encode()?;
182        let payload = serde_json::to_vec(&encoded.payload).expect("a JSON object serializes");
183        let jwt = encode_compact(header, &payload, signer).await?;
184        Ok(SdJwt::new(
185            crate::vc::Jwt::new(jwt).expect("a freshly encoded JWS decodes"),
186            encoded.disclosures,
187        ))
188    }
189
190    /// Whether `path` is to be concealed, marking the pattern that matched it.
191    fn is_concealed(&self, path: &ClaimPath, matched: &mut [bool]) -> bool {
192        let mut concealed = false;
193        for (i, pattern) in self.concealed.iter().enumerate() {
194            if pattern.matches(path) {
195                matched[i] = true;
196                concealed = true;
197            }
198        }
199        concealed
200    }
201
202    fn value(
203        &mut self,
204        value: Value,
205        path: &ClaimPath,
206        matched: &mut [bool],
207        disclosures: &mut Vec<Disclosure>,
208    ) -> Result<Value, SdJwtError> {
209        match value {
210            Value::Object(object) => self
211                .object(object, path, matched, disclosures)
212                .map(Value::Object),
213            Value::Array(array) => self
214                .array(array, path, matched, disclosures)
215                .map(Value::Array),
216            other => Ok(other),
217        }
218    }
219
220    fn object(
221        &mut self,
222        object: Map<String, Value>,
223        path: &ClaimPath,
224        matched: &mut [bool],
225        disclosures: &mut Vec<Disclosure>,
226    ) -> Result<Map<String, Value>, SdJwtError> {
227        if object.contains_key(SD_KEY) {
228            return Err(SdJwtError::payload(format!(
229                "the object at {path} already carries `_sd`"
230            )));
231        }
232        let mut out = Map::new();
233        let mut digests = Vec::new();
234        for (name, value) in object {
235            let child = path.join(name.as_str());
236            let value = self.value(value, &child, matched, disclosures)?;
237            if !self.is_concealed(&child, matched) {
238                out.insert(name, value);
239                continue;
240            }
241            if name == SD_KEY || name == ELLIPSIS_KEY {
242                return Err(SdJwtError::NotConcealable {
243                    path: child,
244                    reason: format!("`{name}` is reserved"),
245                });
246            }
247            let disclosure = Disclosure::property((self.salts)()?, name, value);
248            digests.push(disclosure.digest(self.alg));
249            disclosures.push(disclosure);
250        }
251        if !digests.is_empty() {
252            for _ in 0..self.decoys {
253                digests.push(self.alg.digest((self.salts)()?.as_bytes()));
254            }
255            digests.sort_unstable();
256            out.insert(
257                SD_KEY.to_owned(),
258                Value::Array(digests.into_iter().map(Value::String).collect()),
259            );
260        }
261        Ok(out)
262    }
263
264    fn array(
265        &mut self,
266        array: Vec<Value>,
267        path: &ClaimPath,
268        matched: &mut [bool],
269        disclosures: &mut Vec<Disclosure>,
270    ) -> Result<Vec<Value>, SdJwtError> {
271        let mut out = Vec::with_capacity(array.len());
272        for (i, element) in array.into_iter().enumerate() {
273            let child = path.join(i);
274            let element = self.value(element, &child, matched, disclosures)?;
275            if !self.is_concealed(&child, matched) {
276                out.push(element);
277                continue;
278            }
279            let disclosure = Disclosure::element((self.salts)()?, element);
280            let mut placeholder = Map::new();
281            placeholder.insert(
282                ELLIPSIS_KEY.to_owned(),
283                Value::String(disclosure.digest(self.alg)),
284            );
285            disclosures.push(disclosure);
286            out.push(Value::Object(placeholder));
287        }
288        Ok(out)
289    }
290}
291
292impl fmt::Debug for SdJwtBuilder {
293    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
294        f.debug_struct("SdJwtBuilder")
295            .field("claims", &self.claims)
296            .field("concealed", &self.concealed)
297            .field("decoys", &self.decoys)
298            .field("alg", &self.alg)
299            .finish_non_exhaustive()
300    }
301}
302
303/// 128 bits of operating system randomness, base64url-encoded.
304fn random_salt() -> Result<String, SdJwtError> {
305    let mut bytes = [0u8; 16];
306    getrandom::fill(&mut bytes).map_err(|e| SdJwtError::Randomness(Box::new(e)))?;
307    Ok(base64url::encode(&bytes))
308}
309
310/// The output of [`SdJwtBuilder::encode`].
311#[derive(Debug, Clone, PartialEq, Eq)]
312#[non_exhaustive]
313pub struct Encoded {
314    /// The issuer-signed JWT payload, digests in place, with `_sd_alg` when anything is
315    /// concealed.
316    pub payload: Map<String, Value>,
317    /// The disclosures, in the order their claims were concealed.
318    pub disclosures: Vec<Disclosure>,
319}
320
321/// Whether `value` nests more than `limit` containers deep, descending no further than that:
322/// a value built in memory may nest far deeper than any parser of this crate reads, and the
323/// walk must not be what overflows the stack on it.
324fn nests_deeper_than(value: &Value, limit: usize) -> bool {
325    match value {
326        Value::Array(items) => {
327            limit == 0 || items.iter().any(|item| nests_deeper_than(item, limit - 1))
328        }
329        Value::Object(members) => {
330            limit == 0
331                || members
332                    .values()
333                    .any(|member| nests_deeper_than(member, limit - 1))
334        }
335        _ => false,
336    }
337}
338
339#[cfg(test)]
340mod tests {
341    use std::error::Error as _;
342
343    use serde_json::json;
344
345    use super::*;
346
347    /// A builder whose salts come from a random number generator that fails.
348    fn without_randomness(claims: &Value) -> SdJwtBuilder {
349        let mut builder = SdJwtBuilder::new(claims.as_object().unwrap().clone());
350        builder.salts = Box::new(|| {
351            Err(SdJwtError::Randomness(Box::new(std::io::Error::other(
352                "no entropy",
353            ))))
354        });
355        builder
356    }
357
358    #[test]
359    fn a_failing_random_number_generator_is_an_error() {
360        let claims = json!({"given_name": "Erika", "nationalities": ["DE"]});
361        for builder in [
362            without_randomness(&claims).conceal(["given_name"]),
363            without_randomness(&claims).conceal(crate::claim_path!["nationalities", 0]),
364        ] {
365            let error = builder.encode().unwrap_err();
366            assert!(matches!(error, SdJwtError::Randomness(_)), "{error:?}");
367            assert_eq!(error.to_string(), "cannot generate a salt: no entropy");
368            assert!(error.source().unwrap().is::<std::io::Error>());
369        }
370        // Without a disclosure there is no salt to draw, nor a decoy.
371        let encoded = without_randomness(&claims).with_decoys(2).encode().unwrap();
372        assert_eq!(Value::Object(encoded.payload), claims);
373    }
374
375    #[test]
376    fn decoys_draw_salts_too() {
377        let mut calls = 0;
378        let mut builder = SdJwtBuilder::new(json!({"a": 1}).as_object().unwrap().clone())
379            .conceal(["a"])
380            .with_decoys(1);
381        builder.salts = Box::new(move || {
382            calls += 1;
383            if calls == 1 {
384                Ok("salt".to_owned())
385            } else {
386                Err(SdJwtError::Randomness(Box::new(std::io::Error::other(
387                    "no entropy",
388                ))))
389            }
390        });
391        assert!(matches!(builder.encode(), Err(SdJwtError::Randomness(_))));
392    }
393}