authkestra_engine/token/sd_jwt.rs
1//! SD-JWT (Selective Disclosure for JWTs) issuance and verification, per
2//! `draft-ietf-oauth-selective-disclosure-jwt`.
3//!
4//! An SD-JWT lets an issuer mint a single signed token that carries some
5//! claims in the clear and others only as *digests* (`_sd[]`), plus a
6//! separate list of *Disclosures* — `[salt, claim_name, claim_value]`
7//! triples — that reveal what each digest stands for. A holder decides,
8//! per presentation, which Disclosures to forward alongside the JWT; a
9//! verifier can only recover the claims for the Disclosures it was handed,
10//! and can cryptographically prove every disclosed value was actually
11//! vouched for by the issuer (its digest is in `_sd[]`, which is inside
12//! the signed payload) without the issuer needing to mint one token per
13//! disclosure combination.
14//!
15//! # What this module does and does not implement
16//!
17//! In scope: issuing and verifying **flat, top-level, object-property**
18//! Disclosures, serialized in SD-JWT compact form (`<jwt>~<d1>~<d2>~`).
19//!
20//! Deliberately out of scope (spec features this module does not touch):
21//! - **Key Binding JWT (KB-JWT)** — holder proof-of-possession. This module
22//! verifies the issuer's signature and the Disclosure digests only; it
23//! has no notion of a holder key or a `~<kb-jwt>` suffix.
24//! - **Array-element and recursive/nested Disclosures** — only flat
25//! top-level object properties are supported, matching every consumer
26//! this crate has today.
27//! - **SD-JWT VC** (`vc+sd-jwt`) — no `vct`/type metadata handling.
28//!
29//! None of these are hard to misuse into thinking they're covered — there
30//! is simply no code path for them. A caller needing KB-JWT or nested
31//! disclosures needs to build that on top, not assume it's already here.
32//!
33//! # Security properties this module enforces (and why)
34//!
35//! - **`_sd_alg` is never silently defaulted to `sha-256` when present and
36//! unrecognized.** A verifier that treats an unknown digest algorithm as
37//! "must mean sha-256" is an algorithm-confusion bug: an attacker who
38//! controls (or can influence) the claimed `_sd_alg` could otherwise
39//! coax a verifier into hashing Disclosures with a weaker/attacker-
40//! favorable function while the verifier's logic still believes it's
41//! checking sha-256 digests. This module fails closed instead: an
42//! absent `_sd_alg` defaults to sha-256 (per spec, the assumed default),
43//! but a *present-and-different* value is rejected outright.
44//! - **A presented Disclosure whose digest is not found in `_sd[]` fails
45//! the whole verification**, not just that one claim. Accepting it would
46//! let a holder (or a network attacker who can append to the compact
47//! form) inject arbitrary claims the issuer never signed for — the
48//! entire point of `_sd[]` living inside the signed JWT payload is that
49//! only digests the issuer actually put there are trustworthy.
50//! - **Duplicate digests in `_sd[]` are rejected.** They serve no
51//! legitimate purpose (each Disclosure is independently salted, so two
52//! honestly-generated Disclosures never collide) and are a cheap way to
53//! smuggle a second, attacker-chosen Disclosure past the "digest found"
54//! check above once one legitimate Disclosure's digest becomes known.
55//! - **A disclosed claim can never shadow a registered top-level JWT claim
56//! (`iss`, `sub`, `aud`, `exp`, `iat`, `nbf`, `jti`, `scope`) or an
57//! already-present `extra` claim.** Selective disclosure is additive by
58//! design; letting a Disclosure silently overwrite `aud` or `exp` would
59//! let a holder forge the very claims the issuer's signature is supposed
60//! to pin down.
61
62use super::{Claims, TokenManager};
63use crate::auth::error::AuthError;
64use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
65use jsonwebtoken::Header;
66use rand::RngCore;
67use serde_json::Value;
68use sha2::{Digest, Sha256};
69use std::collections::{HashMap, HashSet};
70
71/// The only digest algorithm this module issues or accepts. Per
72/// `draft-ietf-oauth-selective-disclosure-jwt`, `_sd_alg` is OPTIONAL and
73/// `sha-256` is the assumed default when it's absent — but see the module
74/// docs above for why a *present* value that isn't this one is rejected,
75/// never coerced into this one.
76const SD_ALG_SHA256: &str = "sha-256";
77
78/// The SD-JWT mechanism's own bookkeeping keys, which a Disclosure is
79/// never allowed to introduce: they are what the verifier checks
80/// Disclosures *against*, so a Disclosure that could rewrite them would be
81/// grading its own homework.
82const SD_JWT_BOOKKEEPING_CLAIM_NAMES: &[&str] = &["_sd", "_sd_alg"];
83
84/// True for a claim name a Disclosure is never allowed to introduce: a
85/// registered top-level [`Claims`] field (forging one would let a holder
86/// rewrite the token's own identity/validity claims), or an SD-JWT
87/// bookkeeping key.
88///
89/// The registered-field half is read from [`super::NAMED_CLAIM_FIELDS`]
90/// rather than re-listed here. A second hand-maintained copy is exactly
91/// the drift #283 is about — a `Claims` field added in a minor release
92/// would otherwise have to be remembered in two places, and the one that
93/// got forgotten would fail silently.
94fn is_reserved_claim_name(name: &str) -> bool {
95 super::NAMED_CLAIM_FIELDS.contains(&name)
96 // `jti` is absent from NAMED_CLAIM_FIELDS because `take_jti` makes
97 // it a supported *issuance* override. That does not make it
98 // disclosable: nothing removes it on the verify side, so a
99 // Disclosure naming it would shadow the signed `jti`.
100 || name == "jti"
101 || SD_JWT_BOOKKEEPING_CLAIM_NAMES.contains(&name)
102}
103
104/// A claim an issuer wants to make selectively disclosable, instead of
105/// stamping it directly onto the JWT payload.
106///
107/// Handed to [`TokenManager::issue_sd_jwt`] in a batch; each one becomes
108/// one Disclosure (with its own fresh salt — see
109/// [`generate_disclosure_salt`]) and one digest in the issued token's
110/// `_sd[]`.
111#[derive(Debug, Clone, PartialEq, Eq)]
112#[non_exhaustive]
113pub struct DisclosableClaim {
114 /// The claim name, e.g. `"email"`. Must not collide with a reserved
115 /// name (see [`is_reserved_claim_name`]) — [`TokenManager::issue_sd_jwt`]
116 /// does not currently validate this at issuance time (that check is
117 /// enforced on the verify side, where it actually matters for
118 /// security); an issuer accidentally naming a Disclosure `"aud"`
119 /// simply produces a Disclosure no verifier using this module will
120 /// ever accept.
121 pub name: String,
122 /// The claim value. Any JSON value is accepted (object, array,
123 /// string, number, bool, null) — this module does not interpret it.
124 pub value: Value,
125}
126
127impl DisclosableClaim {
128 /// Convenience constructor so callers don't have to name the struct
129 /// fields at every call site.
130 ///
131 /// # Examples
132 ///
133 /// ```rust
134 /// # use authkestra_engine::token::sd_jwt::DisclosableClaim;
135 /// let claim = DisclosableClaim::new("email", "user@example.com");
136 /// assert_eq!(claim.name, "email");
137 /// ```
138 pub fn new(name: impl Into<String>, value: impl Into<Value>) -> Self {
139 Self {
140 name: name.into(),
141 value: value.into(),
142 }
143 }
144}
145
146/// The result of issuing an SD-JWT: the signed JWT, the SD-JWT compact
147/// serialization ready to hand to a holder, and the raw Disclosure strings
148/// (in case the caller wants to persist or selectively re-forward a subset
149/// later, e.g. to build a holder-controlled presentation).
150#[derive(Debug, Clone, PartialEq, Eq)]
151#[non_exhaustive]
152pub struct IssuedSdJwt {
153 /// The Issuer-signed JWT alone — three dot-separated segments, no `~`.
154 /// Useful for callers that want to store the JWT and Disclosures
155 /// separately rather than as one compact string.
156 pub jwt: String,
157 /// SD-JWT compact serialization: `<jwt>~<disclosure_1>~...~<disclosure_n>~`
158 /// (a trailing `~` and no Key Binding JWT segment, since KB-JWT is out
159 /// of scope for this module — see the module docs). If
160 /// `disclosable_claims` was empty, this equals `jwt` with no `~`
161 /// appended at all, matching a plain (non-SD) JWT.
162 pub compact: String,
163 /// The base64url-encoded Disclosure strings, in the same order as the
164 /// `disclosable_claims` they were built from.
165 pub disclosures: Vec<String>,
166}
167
168/// The result of verifying a presented SD-JWT compact form: the validated
169/// JWT claims (signature, `iss`/`aud`/`exp` already checked by
170/// [`TokenManager::validate_token`]) plus whatever claims the presented
171/// Disclosures actually proved out.
172///
173/// `disclosed_claims` only contains claims from Disclosures that were
174/// *both* presented *and* verified against `_sd[]` — a claim whose digest
175/// the issuer never signed for cannot appear here (see
176/// [`TokenManager::validate_sd_jwt`]'s rejection rules).
177#[derive(Debug, Clone)]
178#[non_exhaustive]
179pub struct VerifiedSdJwt {
180 /// The underlying JWT claims, already validated (signature, issuer,
181 /// audience, expiry) by [`TokenManager::validate_token`].
182 pub claims: Claims,
183 /// Claim name -> value, recovered from the presented Disclosures that
184 /// verified successfully.
185 pub disclosed_claims: HashMap<String, Value>,
186}
187
188/// Generates a fresh, cryptographically random salt for one Disclosure.
189///
190/// Per `draft-ietf-oauth-selective-disclosure-jwt` §5.2.1, each Disclosure
191/// needs its own salt with "sufficient entropy" — the spec's own examples
192/// use 128 bits. This uses the workspace's existing CSPRNG (`rand`, the
193/// same `rand::rng()` source already used for OAuth `state`/`nonce` and
194/// AES-GCM nonces elsewhere in this crate — see `auth::state::OAuth2State`)
195/// rather than pulling in a dedicated RNG dependency. Reusing a salt across
196/// Disclosures — e.g. deriving it from the claim name/value instead of
197/// generating it fresh — would let two verifiers who both learn the same
198/// claim name/value pair recognize they're looking at the same subject
199/// even without ever seeing the digest, defeating the unlinkability this
200/// mechanism exists to provide.
201fn generate_disclosure_salt() -> String {
202 let mut salt_bytes = [0u8; 16]; // 128 bits, matching the spec's own examples.
203 rand::rng().fill_bytes(&mut salt_bytes);
204 URL_SAFE_NO_PAD.encode(salt_bytes)
205}
206
207/// Base64url (no padding) of the SHA-256 digest of an encoded Disclosure
208/// string — the value that goes into `_sd[]`, per §5.2.1.
209fn disclosure_digest(encoded_disclosure: &str) -> String {
210 URL_SAFE_NO_PAD.encode(Sha256::digest(encoded_disclosure.as_bytes()))
211}
212
213/// Builds one Disclosure — `base64url(json([salt, name, value]))` — and its
214/// digest, from a [`DisclosableClaim`].
215fn encode_disclosure(claim: &DisclosableClaim) -> Result<(String, String), AuthError> {
216 let salt = generate_disclosure_salt();
217 let triple = serde_json::json!([salt, claim.name, claim.value]);
218 let bytes = serde_json::to_vec(&triple)
219 .map_err(|e| AuthError::Token(format!("failed to encode SD-JWT disclosure: {e}")))?;
220 let encoded = URL_SAFE_NO_PAD.encode(bytes);
221 let digest = disclosure_digest(&encoded);
222 Ok((encoded, digest))
223}
224
225/// Splits an SD-JWT compact form into its JWT segment and its Disclosure
226/// strings. Tolerates a plain (non-SD) JWT with no `~` at all — the whole
227/// input is then returned as the JWT with an empty Disclosure list — and a
228/// trailing `~` with nothing after it (an empty final segment from
229/// `split('~')`, filtered out).
230fn split_sd_jwt(compact: &str) -> (&str, Vec<String>) {
231 let mut parts = compact.split('~');
232 let jwt = parts.next().unwrap_or(compact);
233 let disclosures = parts
234 .filter(|segment| !segment.is_empty())
235 .map(str::to_owned)
236 .collect();
237 (jwt, disclosures)
238}
239
240/// Decodes one Disclosure string into its `(claim_name, claim_value)` pair,
241/// without checking it against any `_sd[]` digest set — that check is the
242/// caller's job (see [`verify_disclosures`]). Rejects anything that isn't
243/// valid base64url JSON, or whose decoded array isn't exactly the
244/// `[salt, name, value]` triple the spec requires (the salt itself is
245/// discarded here; its only job was to make the digest unguessable).
246fn decode_disclosure(encoded: &str) -> Result<(String, Value), AuthError> {
247 let bytes = URL_SAFE_NO_PAD
248 .decode(encoded)
249 .map_err(|e| AuthError::Token(format!("invalid SD-JWT disclosure encoding: {e}")))?;
250 let triple: Vec<Value> = serde_json::from_slice(&bytes)
251 .map_err(|e| AuthError::Token(format!("invalid SD-JWT disclosure JSON: {e}")))?;
252 if triple.len() != 3 {
253 return Err(AuthError::Token(
254 "SD-JWT disclosure must be a [salt, claim_name, claim_value] triple".to_string(),
255 ));
256 }
257 let mut fields = triple.into_iter();
258 let _salt = fields.next();
259 let name = fields
260 .next()
261 .and_then(|v| v.as_str().map(str::to_owned))
262 .ok_or_else(|| {
263 AuthError::Token("SD-JWT disclosure claim name must be a JSON string".to_string())
264 })?;
265 let value = fields.next().unwrap_or(Value::Null);
266 Ok((name, value))
267}
268
269/// Checks the presented Disclosures against the validated JWT's `_sd[]`/
270/// `_sd_alg`, per the security rules documented on the module itself.
271/// Returns the recovered `name -> value` map, or the first rejection
272/// reason encountered.
273fn verify_disclosures(
274 claims: &Claims,
275 disclosure_strings: &[String],
276) -> Result<HashMap<String, Value>, AuthError> {
277 if disclosure_strings.is_empty() {
278 return Ok(HashMap::new());
279 }
280
281 if let Some(alg_value) = claims.extra.get("_sd_alg") {
282 let alg = alg_value
283 .as_str()
284 .ok_or_else(|| AuthError::Token("_sd_alg claim must be a JSON string".to_string()))?;
285 if alg != SD_ALG_SHA256 {
286 tracing::warn!(
287 sd_alg = %alg,
288 "rejecting SD-JWT: unrecognized _sd_alg, refusing to default to sha-256"
289 );
290 return Err(AuthError::Token(format!(
291 "unsupported SD-JWT _sd_alg '{alg}': only '{SD_ALG_SHA256}' is supported, \
292 and an unrecognized value is rejected rather than assumed to mean sha-256"
293 )));
294 }
295 }
296
297 let sd_entries = claims
298 .extra
299 .get("_sd")
300 .and_then(Value::as_array)
301 .cloned()
302 .unwrap_or_default();
303
304 let mut known_digests: HashSet<String> = HashSet::with_capacity(sd_entries.len());
305 for entry in &sd_entries {
306 let digest = entry
307 .as_str()
308 .ok_or_else(|| AuthError::Token("_sd entries must be JSON strings".to_string()))?
309 .to_string();
310 if !known_digests.insert(digest.clone()) {
311 tracing::warn!(digest = %digest, "rejecting SD-JWT: duplicate digest in _sd[]");
312 return Err(AuthError::Token(format!(
313 "duplicate digest in SD-JWT _sd[]: {digest}"
314 )));
315 }
316 }
317
318 let mut disclosed = HashMap::with_capacity(disclosure_strings.len());
319 for encoded in disclosure_strings {
320 let digest = disclosure_digest(encoded);
321 if !known_digests.contains(&digest) {
322 tracing::warn!(
323 digest = %digest,
324 "rejecting SD-JWT: presented disclosure digest not found in _sd[]"
325 );
326 return Err(AuthError::Token(
327 "presented SD-JWT disclosure digest is not present in _sd[]".to_string(),
328 ));
329 }
330
331 let (name, value) = decode_disclosure(encoded)?;
332 if is_reserved_claim_name(&name) || claims.extra.contains_key(&name) {
333 tracing::warn!(
334 claim_name = %name,
335 "rejecting SD-JWT: disclosed claim shadows a registered or already-present claim"
336 );
337 return Err(AuthError::Token(format!(
338 "SD-JWT disclosure claim name '{name}' shadows a registered or already-present claim"
339 )));
340 }
341
342 disclosed.insert(name, value);
343 }
344
345 tracing::debug!(
346 disclosed_count = disclosed.len(),
347 "verified SD-JWT disclosures"
348 );
349 Ok(disclosed)
350}
351
352impl TokenManager {
353 /// Issues an SD-JWT: a JWT whose payload carries `_sd[]` digests (and
354 /// `_sd_alg`) for each of `disclosable_claims`, plus the matching
355 /// Disclosure strings, serialized to SD-JWT compact form.
356 ///
357 /// Works with whichever signing algorithm this `TokenManager` was
358 /// constructed with — HS256 ([`TokenManager::new`]), RS256
359 /// ([`TokenManager::new_asymmetric`]), or Ed25519
360 /// ([`TokenManager::new_ed25519`]) — since the SD-JWT mechanism only
361 /// concerns the *payload* (which claims are digested vs. plain), not
362 /// how the JWT itself gets signed.
363 ///
364 /// `sub`/`expires_in_secs`/`aud`/`scope` populate the same standard
365 /// claims as [`TokenManager::issue_client_token_with_extra`]; `extra`
366 /// is stamped the same way (including the `extra["jti"]` override —
367 /// see [`super::take_jti`]). If `disclosable_claims` is empty, the
368 /// result is a plain JWT: no `_sd`/`_sd_alg` claims are added, and
369 /// `compact == jwt` with no trailing `~`.
370 ///
371 /// Reusing a claim name across `disclosable_claims`, or clashing with
372 /// a key already in `extra`, is not rejected at issuance — each
373 /// becomes its own Disclosure/digest, and a verifier will happily
374 /// accept whichever ones it's shown. Callers that need "exactly one
375 /// value per name" are responsible for enforcing that themselves; nothing
376 /// about the wire format requires it.
377 ///
378 /// # Errors
379 ///
380 /// Returns [`AuthError::Token`] without minting anything if `extra`
381 /// carries a key that collides with a named `Claims` field — see
382 /// [`TokenManager::issue_user_token_with_extra`] for the full rule
383 /// (#283). This applies to `extra` only; `disclosable_claims` names
384 /// are still not validated at issuance, as described above.
385 ///
386 /// # Examples
387 ///
388 /// ```rust
389 /// # use authkestra_engine::token::sd_jwt::DisclosableClaim;
390 /// # use authkestra_engine::TokenManager;
391 /// # use std::collections::HashMap;
392 /// let manager = TokenManager::new(b"example-secret", Some("issuer".to_string()));
393 /// let issued = manager.issue_sd_jwt(
394 /// "user-1".to_string(),
395 /// 3600,
396 /// None,
397 /// None,
398 /// vec![DisclosableClaim::new("email", "user@example.com")],
399 /// HashMap::new(),
400 /// )?;
401 /// assert_eq!(issued.disclosures.len(), 1);
402 /// assert!(issued.compact.starts_with(&issued.jwt));
403 /// # Ok::<(), authkestra_engine::AuthError>(())
404 /// ```
405 #[tracing::instrument(skip(self, extra, disclosable_claims), fields(sub = %sub, disclosure_count = disclosable_claims.len()))]
406 pub fn issue_sd_jwt(
407 &self,
408 sub: String,
409 expires_in_secs: u64,
410 aud: Option<String>,
411 scope: Option<String>,
412 disclosable_claims: Vec<DisclosableClaim>,
413 mut extra: HashMap<String, Value>,
414 ) -> Result<IssuedSdJwt, AuthError> {
415 let now = chrono::Utc::now().timestamp() as usize;
416 let expiration = now + expires_in_secs as usize;
417 let jti = super::take_jti(&mut extra);
418 super::reject_named_claim_collisions(&extra)?;
419
420 let mut digests = Vec::with_capacity(disclosable_claims.len());
421 let mut disclosures = Vec::with_capacity(disclosable_claims.len());
422 for claim in &disclosable_claims {
423 let (encoded, digest) = encode_disclosure(claim)?;
424 digests.push(Value::String(digest));
425 disclosures.push(encoded);
426 }
427
428 if !disclosures.is_empty() {
429 tracing::debug!(
430 disclosure_count = disclosures.len(),
431 "stamping _sd/_sd_alg claims onto SD-JWT"
432 );
433 extra.insert("_sd".to_string(), Value::Array(digests));
434 extra.insert(
435 "_sd_alg".to_string(),
436 Value::String(SD_ALG_SHA256.to_string()),
437 );
438 }
439
440 let claims = Claims {
441 iss: self.issuer.clone(),
442 sub,
443 aud: aud.map(super::Audience::from),
444 exp: expiration,
445 iat: now,
446 nbf: Some(now),
447 jti: Some(jti),
448 scope,
449 identity: None,
450 extra,
451 };
452
453 let mut header = Header::new(self.alg);
454 if let Some(ref kid) = self.kid {
455 header.kid = Some(kid.clone());
456 }
457
458 let jwt = jsonwebtoken::encode(&header, &claims, &self.encoding_key)
459 .map_err(|e| AuthError::Token(e.to_string()))?;
460
461 let mut compact = jwt.clone();
462 for disclosure in &disclosures {
463 compact.push('~');
464 compact.push_str(disclosure);
465 }
466 if !disclosures.is_empty() {
467 compact.push('~');
468 }
469
470 tracing::info!("issued SD-JWT");
471 Ok(IssuedSdJwt {
472 jwt,
473 compact,
474 disclosures,
475 })
476 }
477
478 /// Verifies a presented SD-JWT compact form (`<jwt>~<d1>~...~`, or a
479 /// plain JWT with no `~` segments): validates the underlying JWT
480 /// exactly as [`TokenManager::validate_token`] does (signature,
481 /// issuer, audience, expiry), then checks every presented Disclosure
482 /// against the validated `_sd[]`/`_sd_alg`, per the module-level
483 /// security rules.
484 ///
485 /// Rejects the whole presentation — not just the offending claim — if
486 /// any Disclosure fails: digest not found in `_sd[]`, a duplicate
487 /// digest in `_sd[]`, an unrecognized (present-and-different)
488 /// `_sd_alg`, or a disclosed claim name that shadows a registered or
489 /// already-present claim. See the module docs for why each of these
490 /// has to fail closed rather than degrading gracefully.
491 ///
492 /// # Examples
493 ///
494 /// ```rust
495 /// # use authkestra_engine::token::sd_jwt::DisclosableClaim;
496 /// # use authkestra_engine::TokenManager;
497 /// # use std::collections::HashMap;
498 /// let manager = TokenManager::new(b"example-secret", Some("issuer".to_string()));
499 /// let issued = manager.issue_sd_jwt(
500 /// "user-1".to_string(),
501 /// 3600,
502 /// None,
503 /// None,
504 /// vec![DisclosableClaim::new("email", "user@example.com")],
505 /// HashMap::new(),
506 /// )?;
507 ///
508 /// // A holder can present the full compact form...
509 /// let verified = manager.validate_sd_jwt(&issued.compact, None)?;
510 /// assert_eq!(
511 /// verified.disclosed_claims.get("email"),
512 /// Some(&serde_json::Value::String("user@example.com".to_string()))
513 /// );
514 ///
515 /// // ...or withhold the Disclosure entirely and present the bare JWT.
516 /// let bare = manager.validate_sd_jwt(&issued.jwt, None)?;
517 /// assert!(bare.disclosed_claims.is_empty());
518 /// # Ok::<(), authkestra_engine::AuthError>(())
519 /// ```
520 #[tracing::instrument(skip(self, presented))]
521 pub fn validate_sd_jwt(
522 &self,
523 presented: &str,
524 expected_aud: Option<&str>,
525 ) -> Result<VerifiedSdJwt, AuthError> {
526 let (jwt, disclosure_strings) = split_sd_jwt(presented);
527 let claims = self.validate_token(jwt, expected_aud)?;
528 let disclosed_claims = verify_disclosures(&claims, &disclosure_strings)?;
529 Ok(VerifiedSdJwt {
530 claims,
531 disclosed_claims,
532 })
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539 use std::collections::HashMap;
540
541 /// Throwaway Ed25519 private key (PKCS#8 PEM), test-only. Same key used
542 /// in `token::mod`'s own test suite.
543 const TEST_ED25519_PRIVATE_KEY_PEM: &[u8] = b"-----BEGIN PRIVATE KEY-----
544MC4CAQAwBQYDK2VwBCIEIKIPR2jojpdobYr1M/pjIRuMONpZGYQ+y5yxSqKX9T9/
545-----END PRIVATE KEY-----";
546
547 fn hs256_manager() -> TokenManager {
548 TokenManager::new(b"sd-jwt-test-secret", Some("issuer".to_string()))
549 }
550
551 fn ed25519_manager() -> TokenManager {
552 TokenManager::new_ed25519(
553 TEST_ED25519_PRIVATE_KEY_PEM,
554 Some("issuer".to_string()),
555 Some("ed25519-kid".to_string()),
556 )
557 .expect("test Ed25519 key must construct a TokenManager")
558 }
559
560 fn sample_disclosures() -> Vec<DisclosableClaim> {
561 vec![
562 DisclosableClaim::new("email", Value::String("user@example.com".to_string())),
563 DisclosableClaim::new("is_over_18", Value::Bool(true)),
564 ]
565 }
566
567 /// Round trip: issue with disclosures, verify presenting all of them,
568 /// recover both claim values — on HS256.
569 #[test]
570 fn hs256_round_trip_issue_and_verify_all_disclosures() {
571 let manager = hs256_manager();
572 let issued = manager
573 .issue_sd_jwt(
574 "user-1".to_string(),
575 3600,
576 Some("client-1".to_string()),
577 None,
578 sample_disclosures(),
579 HashMap::new(),
580 )
581 .expect("issuance should succeed");
582
583 assert_eq!(issued.disclosures.len(), 2);
584 assert!(issued.compact.starts_with(&issued.jwt));
585 assert!(issued.compact.ends_with('~'));
586
587 let verified = manager
588 .validate_sd_jwt(&issued.compact, Some("client-1"))
589 .expect("verification should succeed");
590
591 assert_eq!(verified.claims.sub, "user-1");
592 assert_eq!(
593 verified.disclosed_claims.get("email"),
594 Some(&Value::String("user@example.com".to_string()))
595 );
596 assert_eq!(
597 verified.disclosed_claims.get("is_over_18"),
598 Some(&Value::Bool(true))
599 );
600 }
601
602 /// Same round trip, on the Ed25519 signer — proves the SD-JWT
603 /// mechanism composes with every signing algorithm this crate
604 /// supports, not just HS256.
605 #[test]
606 fn ed25519_round_trip_issue_and_verify_all_disclosures() {
607 let manager = ed25519_manager();
608 let issued = manager
609 .issue_sd_jwt(
610 "user-2".to_string(),
611 3600,
612 None,
613 None,
614 sample_disclosures(),
615 HashMap::new(),
616 )
617 .expect("issuance should succeed");
618
619 let verified = manager
620 .validate_sd_jwt(&issued.compact, None)
621 .expect("verification should succeed");
622
623 assert_eq!(verified.claims.sub, "user-2");
624 assert_eq!(verified.disclosed_claims.len(), 2);
625 }
626
627 /// A holder is allowed to withhold a Disclosure: presenting only one
628 /// of two issued Disclosures verifies fine, and only that one claim is
629 /// recovered.
630 #[test]
631 fn selective_presentation_of_a_subset_of_disclosures_succeeds() {
632 let manager = hs256_manager();
633 let issued = manager
634 .issue_sd_jwt(
635 "user-1".to_string(),
636 3600,
637 None,
638 None,
639 sample_disclosures(),
640 HashMap::new(),
641 )
642 .expect("issuance should succeed");
643
644 // Hand-build a presentation carrying only the first disclosure.
645 let partial = format!("{}~{}~", issued.jwt, issued.disclosures[0]);
646
647 let verified = manager
648 .validate_sd_jwt(&partial, None)
649 .expect("presenting a subset of disclosures should still verify");
650
651 assert_eq!(verified.disclosed_claims.len(), 1);
652 assert!(verified.disclosed_claims.contains_key("email"));
653 assert!(!verified.disclosed_claims.contains_key("is_over_18"));
654 }
655
656 /// Presenting zero disclosures (a bare JWT, no `~`) against a token
657 /// that does carry `_sd[]` must still verify — the standard claims are
658 /// unaffected, and `disclosed_claims` is simply empty.
659 #[test]
660 fn presenting_the_bare_jwt_with_no_disclosures_still_verifies() {
661 let manager = hs256_manager();
662 let issued = manager
663 .issue_sd_jwt(
664 "user-1".to_string(),
665 3600,
666 None,
667 None,
668 sample_disclosures(),
669 HashMap::new(),
670 )
671 .expect("issuance should succeed");
672
673 let verified = manager
674 .validate_sd_jwt(&issued.jwt, None)
675 .expect("bare JWT without disclosures should still verify");
676
677 assert_eq!(verified.claims.sub, "user-1");
678 assert!(verified.disclosed_claims.is_empty());
679 }
680
681 /// Issuing with an empty disclosure list produces a plain JWT: no
682 /// `_sd`/`_sd_alg` claims, and `compact == jwt` (no trailing `~`).
683 #[test]
684 fn issuing_with_no_disclosures_yields_a_plain_jwt() {
685 let manager = hs256_manager();
686 let issued = manager
687 .issue_sd_jwt(
688 "user-1".to_string(),
689 3600,
690 None,
691 None,
692 Vec::new(),
693 HashMap::new(),
694 )
695 .expect("issuance should succeed");
696
697 assert_eq!(issued.compact, issued.jwt);
698 assert!(!issued.compact.contains('~'));
699
700 let verified = manager
701 .validate_sd_jwt(&issued.compact, None)
702 .expect("plain JWT should still verify via validate_sd_jwt");
703 assert!(!verified.claims.extra.contains_key("_sd"));
704 assert!(!verified.claims.extra.contains_key("_sd_alg"));
705 }
706
707 /// Security rule: an unrecognized `_sd_alg` must be rejected outright,
708 /// never treated as though it meant sha-256. Constructed by hand since
709 /// `issue_sd_jwt` itself only ever stamps `"sha-256"`.
710 #[test]
711 fn unrecognized_sd_alg_is_rejected_not_defaulted() {
712 let manager = hs256_manager();
713 let mut extra = HashMap::new();
714 let (encoded, digest) = encode_disclosure(&DisclosableClaim::new(
715 "email",
716 Value::String("user@example.com".to_string()),
717 ))
718 .unwrap();
719 extra.insert("_sd".to_string(), serde_json::json!([digest]));
720 extra.insert("_sd_alg".to_string(), serde_json::json!("sha-1"));
721
722 let jwt = manager
723 .issue_client_token_with_extra("client-1", 3600, None, None, extra)
724 .expect("hand-built token should issue");
725 let presented = format!("{jwt}~{encoded}~");
726
727 let err = manager
728 .validate_sd_jwt(&presented, None)
729 .expect_err("an unrecognized _sd_alg must be rejected");
730 assert!(
731 err.to_string().contains("_sd_alg"),
732 "error should mention _sd_alg, got: {err}"
733 );
734 }
735
736 /// Security rule: a presented disclosure whose digest is absent from
737 /// `_sd[]` must be rejected — a holder cannot inject a claim the
738 /// issuer never signed for.
739 #[test]
740 fn disclosure_digest_not_in_sd_is_rejected() {
741 let manager = hs256_manager();
742 let issued = manager
743 .issue_sd_jwt(
744 "user-1".to_string(),
745 3600,
746 None,
747 None,
748 sample_disclosures(),
749 HashMap::new(),
750 )
751 .expect("issuance should succeed");
752
753 // Forge a disclosure for a claim the issuer never included.
754 let (forged_encoded, _forged_digest) = encode_disclosure(&DisclosableClaim::new(
755 "role",
756 Value::String("admin".to_string()),
757 ))
758 .unwrap();
759 let forged = format!("{}~{forged_encoded}~", issued.jwt);
760
761 let err = manager
762 .validate_sd_jwt(&forged, None)
763 .expect_err("a disclosure not backed by a digest in _sd[] must be rejected");
764 assert!(
765 err.to_string().contains("_sd[]") || err.to_string().contains("not present"),
766 "unexpected error message: {err}"
767 );
768 }
769
770 /// Security rule: duplicate digests inside `_sd[]` are rejected, even
771 /// before any disclosure is checked against them.
772 #[test]
773 fn duplicate_digest_in_sd_is_rejected() {
774 let manager = hs256_manager();
775 let (encoded, digest) = encode_disclosure(&DisclosableClaim::new(
776 "email",
777 Value::String("user@example.com".to_string()),
778 ))
779 .unwrap();
780
781 let mut extra = HashMap::new();
782 extra.insert(
783 "_sd".to_string(),
784 serde_json::json!([digest.clone(), digest]),
785 );
786 extra.insert("_sd_alg".to_string(), serde_json::json!("sha-256"));
787
788 let jwt = manager
789 .issue_client_token_with_extra("client-1", 3600, None, None, extra)
790 .expect("hand-built token should issue");
791 let presented = format!("{jwt}~{encoded}~");
792
793 let err = manager
794 .validate_sd_jwt(&presented, None)
795 .expect_err("duplicate digests in _sd[] must be rejected");
796 assert!(
797 err.to_string().contains("duplicate"),
798 "unexpected error message: {err}"
799 );
800 }
801
802 /// Security rule: a disclosed claim cannot shadow a registered
803 /// top-level claim (`sub`, in this case) — the token would otherwise
804 /// let a holder present a forged `sub` that a naive verifier merges
805 /// over the signed one.
806 #[test]
807 fn disclosed_claim_cannot_shadow_registered_claim_name() {
808 let manager = hs256_manager();
809 let (encoded, digest) = encode_disclosure(&DisclosableClaim::new(
810 "sub",
811 Value::String("attacker".to_string()),
812 ))
813 .unwrap();
814
815 let mut extra = HashMap::new();
816 extra.insert("_sd".to_string(), serde_json::json!([digest]));
817 extra.insert("_sd_alg".to_string(), serde_json::json!("sha-256"));
818
819 let jwt = manager
820 .issue_client_token_with_extra("client-1", 3600, None, None, extra)
821 .expect("hand-built token should issue");
822 let presented = format!("{jwt}~{encoded}~");
823
824 let err = manager
825 .validate_sd_jwt(&presented, None)
826 .expect_err("a disclosure named 'sub' must be rejected");
827 assert!(
828 err.to_string().contains("shadow"),
829 "unexpected error message: {err}"
830 );
831 }
832
833 /// Same shadowing rule, but against an already-present `extra` claim
834 /// rather than a registered top-level one.
835 #[test]
836 fn disclosed_claim_cannot_shadow_already_present_extra_claim() {
837 let manager = hs256_manager();
838 let (encoded, digest) = encode_disclosure(&DisclosableClaim::new(
839 "org_id",
840 Value::String("attacker-org".to_string()),
841 ))
842 .unwrap();
843
844 let mut extra = HashMap::new();
845 extra.insert("org_id".to_string(), serde_json::json!("real-org"));
846 extra.insert("_sd".to_string(), serde_json::json!([digest]));
847 extra.insert("_sd_alg".to_string(), serde_json::json!("sha-256"));
848
849 let jwt = manager
850 .issue_client_token_with_extra("client-1", 3600, None, None, extra)
851 .expect("hand-built token should issue");
852 let presented = format!("{jwt}~{encoded}~");
853
854 let err = manager
855 .validate_sd_jwt(&presented, None)
856 .expect_err("a disclosure shadowing an already-present extra claim must be rejected");
857 assert!(
858 err.to_string().contains("shadow"),
859 "unexpected error message: {err}"
860 );
861 }
862
863 /// A tampered disclosure (payload byte flipped after issuance) no
864 /// longer hashes to anything in `_sd[]`, so it's rejected the same way
865 /// an unbacked forged disclosure is — proving the digest check, not
866 /// just structural JSON validity, is what's enforced.
867 #[test]
868 fn tampered_disclosure_is_rejected() {
869 let manager = hs256_manager();
870 let issued = manager
871 .issue_sd_jwt(
872 "user-1".to_string(),
873 3600,
874 None,
875 None,
876 sample_disclosures(),
877 HashMap::new(),
878 )
879 .expect("issuance should succeed");
880
881 let mut tampered = issued.disclosures[0].clone();
882 let last = tampered.pop().unwrap();
883 let replacement = if last == 'A' { 'B' } else { 'A' };
884 tampered.push(replacement);
885
886 let presented = format!("{}~{tampered}~", issued.jwt);
887
888 let err = manager
889 .validate_sd_jwt(&presented, None)
890 .expect_err("a tampered disclosure must be rejected");
891 assert!(
892 err.to_string().contains("_sd[]")
893 || err.to_string().contains("not present")
894 || err.to_string().contains("disclosure"),
895 "unexpected error message: {err}"
896 );
897 }
898
899 /// A structurally invalid disclosure (not a 3-element array) is
900 /// rejected with a decoding error, distinct from — but still a hard
901 /// failure like — the digest-mismatch cases above. Built by hand with
902 /// a real backing digest so the failure is provably about shape, not
903 /// digest membership.
904 #[test]
905 fn malformed_disclosure_triple_is_rejected() {
906 let manager = hs256_manager();
907 let malformed_encoded =
908 URL_SAFE_NO_PAD.encode(serde_json::to_vec(&serde_json::json!(["salt-only"])).unwrap());
909 let digest = disclosure_digest(&malformed_encoded);
910
911 let mut extra = HashMap::new();
912 extra.insert("_sd".to_string(), serde_json::json!([digest]));
913 extra.insert("_sd_alg".to_string(), serde_json::json!("sha-256"));
914
915 let jwt = manager
916 .issue_client_token_with_extra("client-1", 3600, None, None, extra)
917 .expect("hand-built token should issue");
918 let presented = format!("{jwt}~{malformed_encoded}~");
919
920 let err = manager
921 .validate_sd_jwt(&presented, None)
922 .expect_err("a malformed disclosure triple must be rejected");
923 assert!(
924 err.to_string().contains("triple"),
925 "unexpected error message: {err}"
926 );
927 }
928
929 /// `_sd_alg` absent entirely still verifies (defaults to sha-256 per
930 /// spec) — proving the "reject unrecognized _sd_alg" rule only fires
931 /// when a *different* value is actually present, not merely absent.
932 #[test]
933 fn missing_sd_alg_defaults_to_sha256_and_still_verifies() {
934 let manager = hs256_manager();
935 let (encoded, digest) = encode_disclosure(&DisclosableClaim::new(
936 "email",
937 Value::String("user@example.com".to_string()),
938 ))
939 .unwrap();
940
941 let mut extra = HashMap::new();
942 extra.insert("_sd".to_string(), serde_json::json!([digest]));
943 // Deliberately no "_sd_alg" entry.
944
945 let jwt = manager
946 .issue_client_token_with_extra("client-1", 3600, None, None, extra)
947 .expect("hand-built token should issue");
948 let presented = format!("{jwt}~{encoded}~");
949
950 let verified = manager
951 .validate_sd_jwt(&presented, None)
952 .expect("a missing _sd_alg should default to sha-256, not be rejected");
953 assert_eq!(
954 verified.disclosed_claims.get("email"),
955 Some(&Value::String("user@example.com".to_string()))
956 );
957 }
958
959 /// The `extra["jti"]` override documented on `issue_client_token_with_extra`
960 /// composes correctly through `issue_sd_jwt` too — proves this method
961 /// didn't bypass the shared `take_jti` plumbing.
962 #[test]
963 fn issue_sd_jwt_honors_extra_jti_override() {
964 let manager = hs256_manager();
965 let mut extra = HashMap::new();
966 extra.insert("jti".to_string(), serde_json::json!("caller-supplied-id"));
967
968 let issued = manager
969 .issue_sd_jwt("user-1".to_string(), 3600, None, None, Vec::new(), extra)
970 .expect("issuance should succeed");
971
972 let verified = manager
973 .validate_sd_jwt(&issued.compact, None)
974 .expect("verification should succeed");
975 assert_eq!(verified.claims.jti, Some("caller-supplied-id".to_string()));
976 }
977
978 /// Two Disclosures for the same claim name/value must still get
979 /// distinct salts (and thus distinct digests/encodings) — the whole
980 /// point of per-disclosure salting is that identical claim data
981 /// doesn't produce a recognizably identical Disclosure across issuances.
982 #[test]
983 fn disclosure_salts_are_unique_across_issuances() {
984 let claim = DisclosableClaim::new("email", Value::String("user@example.com".to_string()));
985 let (first, first_digest) = encode_disclosure(&claim).unwrap();
986 let (second, second_digest) = encode_disclosure(&claim).unwrap();
987
988 assert_ne!(first, second, "salts must differ across issuances");
989 assert_ne!(first_digest, second_digest);
990 }
991}