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() -> String + 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(mut self, salts: impl FnMut() -> String + Send + 'static) -> Self {
109        self.salts = Box::new(salts);
110        self
111    }
112
113    /// The claims.
114    pub fn claims(&self) -> &Map<String, Value> {
115        &self.claims
116    }
117
118    /// The paths to conceal.
119    pub fn concealed(&self) -> &[ClaimPath] {
120        &self.concealed
121    }
122
123    /// The payload and the disclosures, unsigned.
124    ///
125    /// # Errors
126    ///
127    /// [`SdJwtError::NoSuchClaim`] when a path selects nothing, [`SdJwtError::NotConcealable`]
128    /// for `_sd` or `...`, [`SdJwtError::Payload`] when the claims already use `_sd` or
129    /// `_sd_alg`, [`SdJwtError::TooDeep`] when they nest deeper than
130    /// [`MAX_DEPTH`](Self::MAX_DEPTH).
131    pub fn encode(mut self) -> Result<Encoded, SdJwtError> {
132        if self.claims.contains_key(HashAlgorithm::CLAIM) {
133            return Err(SdJwtError::Payload(
134                "the claims already carry `_sd_alg`".into(),
135            ));
136        }
137        if self
138            .claims
139            .values()
140            .any(|value| nests_deeper_than(value, Self::MAX_DEPTH - 1))
141        {
142            return Err(SdJwtError::TooDeep {
143                max: Self::MAX_DEPTH,
144            });
145        }
146        let mut matched = vec![false; self.concealed.len()];
147        let mut disclosures = Vec::new();
148        let mut payload = self.object(
149            self.claims.clone(),
150            &ClaimPath::root(),
151            &mut matched,
152            &mut disclosures,
153        )?;
154        if let Some(unmatched) = matched.iter().position(|m| !m) {
155            return Err(SdJwtError::NoSuchClaim(self.concealed[unmatched].clone()));
156        }
157        if !disclosures.is_empty() || payload.contains_key(SD_KEY) {
158            payload.insert(
159                HashAlgorithm::CLAIM.to_owned(),
160                Value::String(self.alg.name().to_owned()),
161            );
162        }
163        Ok(Encoded {
164            payload,
165            disclosures,
166        })
167    }
168
169    /// The SD-JWT: the payload signed under `header` with `signer`, with every disclosure.
170    ///
171    /// # Errors
172    ///
173    /// As [`encode`](Self::encode), or [`SdJwtError::Jws`] when signing fails.
174    pub async fn sign(self, header: &JwsHeader, signer: &dyn Signer) -> Result<SdJwt, SdJwtError> {
175        let encoded = self.encode()?;
176        let payload = serde_json::to_vec(&encoded.payload).expect("a JSON object serializes");
177        let jwt = encode_compact(header, &payload, signer).await?;
178        Ok(SdJwt::new(
179            crate::vc::Jwt::new(jwt).expect("a freshly encoded JWS decodes"),
180            encoded.disclosures,
181        ))
182    }
183
184    /// Whether `path` is to be concealed, marking the pattern that matched it.
185    fn is_concealed(&self, path: &ClaimPath, matched: &mut [bool]) -> bool {
186        let mut concealed = false;
187        for (i, pattern) in self.concealed.iter().enumerate() {
188            if pattern.matches(path) {
189                matched[i] = true;
190                concealed = true;
191            }
192        }
193        concealed
194    }
195
196    fn value(
197        &mut self,
198        value: Value,
199        path: &ClaimPath,
200        matched: &mut [bool],
201        disclosures: &mut Vec<Disclosure>,
202    ) -> Result<Value, SdJwtError> {
203        match value {
204            Value::Object(object) => self
205                .object(object, path, matched, disclosures)
206                .map(Value::Object),
207            Value::Array(array) => self
208                .array(array, path, matched, disclosures)
209                .map(Value::Array),
210            other => Ok(other),
211        }
212    }
213
214    fn object(
215        &mut self,
216        object: Map<String, Value>,
217        path: &ClaimPath,
218        matched: &mut [bool],
219        disclosures: &mut Vec<Disclosure>,
220    ) -> Result<Map<String, Value>, SdJwtError> {
221        if object.contains_key(SD_KEY) {
222            return Err(SdJwtError::Payload(format!(
223                "the object at {path} already carries `_sd`"
224            )));
225        }
226        let mut out = Map::new();
227        let mut digests = Vec::new();
228        for (name, value) in object {
229            let child = path.join(name.as_str());
230            let value = self.value(value, &child, matched, disclosures)?;
231            if !self.is_concealed(&child, matched) {
232                out.insert(name, value);
233                continue;
234            }
235            if name == SD_KEY || name == ELLIPSIS_KEY {
236                return Err(SdJwtError::NotConcealable {
237                    path: child,
238                    reason: format!("`{name}` is reserved"),
239                });
240            }
241            let disclosure = Disclosure::property((self.salts)(), name, value);
242            digests.push(disclosure.digest(self.alg));
243            disclosures.push(disclosure);
244        }
245        if !digests.is_empty() {
246            for _ in 0..self.decoys {
247                digests.push(self.alg.digest((self.salts)().as_bytes()));
248            }
249            digests.sort_unstable();
250            out.insert(
251                SD_KEY.to_owned(),
252                Value::Array(digests.into_iter().map(Value::String).collect()),
253            );
254        }
255        Ok(out)
256    }
257
258    fn array(
259        &mut self,
260        array: Vec<Value>,
261        path: &ClaimPath,
262        matched: &mut [bool],
263        disclosures: &mut Vec<Disclosure>,
264    ) -> Result<Vec<Value>, SdJwtError> {
265        let mut out = Vec::with_capacity(array.len());
266        for (i, element) in array.into_iter().enumerate() {
267            let child = path.join(i);
268            let element = self.value(element, &child, matched, disclosures)?;
269            if !self.is_concealed(&child, matched) {
270                out.push(element);
271                continue;
272            }
273            let disclosure = Disclosure::element((self.salts)(), element);
274            let mut placeholder = Map::new();
275            placeholder.insert(
276                ELLIPSIS_KEY.to_owned(),
277                Value::String(disclosure.digest(self.alg)),
278            );
279            disclosures.push(disclosure);
280            out.push(Value::Object(placeholder));
281        }
282        Ok(out)
283    }
284}
285
286impl fmt::Debug for SdJwtBuilder {
287    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
288        f.debug_struct("SdJwtBuilder")
289            .field("claims", &self.claims)
290            .field("concealed", &self.concealed)
291            .field("decoys", &self.decoys)
292            .field("alg", &self.alg)
293            .finish_non_exhaustive()
294    }
295}
296
297/// 128 bits of operating system randomness, base64url-encoded.
298pub(super) fn random_salt() -> String {
299    let mut bytes = [0u8; 16];
300    getrandom::fill(&mut bytes).expect("the operating system provides randomness");
301    base64url::encode(&bytes)
302}
303
304/// The output of [`SdJwtBuilder::encode`].
305#[derive(Debug, Clone, PartialEq, Eq)]
306#[non_exhaustive]
307pub struct Encoded {
308    /// The issuer-signed JWT payload, digests in place, with `_sd_alg` when anything is
309    /// concealed.
310    pub payload: Map<String, Value>,
311    /// The disclosures, in the order their claims were concealed.
312    pub disclosures: Vec<Disclosure>,
313}
314
315/// Whether `value` nests more than `limit` containers deep, descending no further than that:
316/// a value built in memory may nest far deeper than any parser of this crate reads, and the
317/// walk must not be what overflows the stack on it.
318fn nests_deeper_than(value: &Value, limit: usize) -> bool {
319    match value {
320        Value::Array(items) => {
321            limit == 0 || items.iter().any(|item| nests_deeper_than(item, limit - 1))
322        }
323        Value::Object(members) => {
324            limit == 0
325                || members
326                    .values()
327                    .any(|member| nests_deeper_than(member, limit - 1))
328        }
329        _ => false,
330    }
331}