Attesto.SdJwt (Attesto v1.12.0)

Copy Markdown View Source

Selective Disclosure for JWTs (SD-JWT), draft-ietf-oauth-selective-disclosure-jwt.

An SD-JWT lets an issuer sign a set of claims where individual claims are selectively disclosable: the signed JWT carries only the SHA-256 digest of each hidden claim (in the _sd array, or as an array element {"...": digest}), and the cleartext claim travels separately as a Disclosure. The holder chooses which Disclosures to release when presenting the credential, so a verifier learns only the disclosed subset while the issuer's single signature still covers all of them.

The wire form is a ~-separated list:

<Issuer-signed JWT>~<Disclosure 1>~...~<Disclosure N>~<optional Key Binding JWT>

A Disclosure is base64url(UTF-8(JSON(array))) with no padding, where the array is [salt, claim_name, claim_value] for an object property or [salt, value] for an array element. Its digest is base64url(SHA-256(ASCII(disclosure))) — computed over the exact disclosure string, so interop does not depend on how any party serialises JSON.

This module is the base SD-JWT mechanism. Attesto.SdJwtVc layers the IETF SD-JWT VC profile (vct, cnf, vc+sd-jwt typing) on top.

What is implemented

  • issue/2 — sign an SD-JWT making chosen top-level claims selectively disclosable. (Nested/recursive and array-element issuance is a planned extension; the wire format it would produce is already a superset this module can verify.)
  • verify/3 — the full recursive processing model from the spec: verify the issuer signature, then resolve _sd digests and array {"...": digest} elements at every depth, rejecting a presentation whose Disclosures do not all reference a digest (spec §7.3) or whose digests collide.
  • verify_key_binding/3 — verify a holder Key Binding JWT over a verified presentation (nonce/aud/sd_hash), for the OID4VP verifier path.

Like the rest of attesto core this module is conn-free and fail-closed.

Summary

Types

A parsed, verified SD-JWT presentation.

Functions

The Disclosure string for an array element ([salt, value]).

The digest of a Disclosure under sd_alg (default sha-256): base64url(SHA-256(ASCII(disclosure))).

A fresh salt for a Disclosure: 128-bit CSPRNG value, base64url, no padding.

Issue an SD-JWT, making the chosen top-level claims selectively disclosable.

The Disclosure string for an object property ([salt, name, value]).

Verify an SD-JWT presentation and reconstruct the disclosed claims.

Verify a holder Key Binding JWT (kb+jwt) over an already-verified presentation.

Types

key_binding_input()

@type key_binding_input() :: %{
  :key_binding_jwt => String.t() | nil,
  :issuer_jwt => String.t(),
  :disclosures => [String.t()],
  optional(term()) => term()
}

verified()

@type verified() :: %{
  claims: map(),
  key_binding_jwt: String.t() | nil,
  issuer_jwt: String.t(),
  disclosures: [String.t()]
}

A parsed, verified SD-JWT presentation.

verify_error()

@type verify_error() ::
  :malformed
  | :invalid_signature
  | :unsupported_alg
  | :invalid_typ
  | :invalid_disclosure
  | :unused_disclosure
  | :duplicate_digest
  | :unsupported_sd_alg
  | :reserved_claim_name

Functions

array_disclosure(salt, value)

@spec array_disclosure(String.t(), term()) :: String.t()

The Disclosure string for an array element ([salt, value]).

digest(disclosure, sd_alg \\ "sha-256")

@spec digest(String.t(), String.t()) :: String.t()

The digest of a Disclosure under sd_alg (default sha-256): base64url(SHA-256(ASCII(disclosure))).

generate_salt()

@spec generate_salt() :: String.t()

A fresh salt for a Disclosure: 128-bit CSPRNG value, base64url, no padding.

issue(claims, opts)

@spec issue(
  map(),
  keyword()
) :: String.t()

Issue an SD-JWT, making the chosen top-level claims selectively disclosable.

claims is the full claim set. Options:

  • :disclosable - the list of top-level claim names to hide behind _sd digests (each becomes a Disclosure). Every other claim is signed in the clear. Defaults to [] (a plain, fully-visible JWT with an empty _sd).
  • :pem - the issuer signing key (PEM). REQUIRED.
  • :alg - the JWS algorithm; inferred from the key when omitted.
  • :typ - the JOSE typ header (e.g. "vc+sd-jwt"); omitted when nil.
  • :kid - the JOSE kid header; omitted when nil.
  • :sd_alg - the hashing algorithm name for _sd_alg. Default sha-256.

Returns the combined Issuance string (<JWT>~<D1>~...~<DN>~, trailing separator, no Key Binding JWT).

object_disclosure(salt, name, value)

@spec object_disclosure(String.t(), String.t(), term()) :: String.t()

The Disclosure string for an object property ([salt, name, value]).

name must be a string; value is any JSON-encodable term. Returns the base64url(no-pad) of the UTF-8 JSON array.

verify(combined, jwks, opts \\ [])

@spec verify(String.t(), map() | [map()] | list(), keyword()) ::
  {:ok, verified()} | {:error, verify_error()}

Verify an SD-JWT presentation and reconstruct the disclosed claims.

combined is the ~-separated presentation. jwks is the issuer's JWK Set (%{"keys" => [...]}, a single JWK map, or a list) used to verify the Issuer-signed JWT. Options:

Returns {:ok, %{claims:, key_binding_jwt:, issuer_jwt:}} where claims is the payload with every _sd/array digest resolved from the presented Disclosures (and the _sd/_sd_alg machinery removed). The Key Binding JWT, if present, is returned UNVERIFIED (see verify_key_binding/3) - reconstructing claims and checking holder binding are separate steps. Rejects a presentation in which any Disclosure is unused (spec §7.3) or any digest is duplicated.

verify_key_binding(verified, holder_jwk, opts)

@spec verify_key_binding(key_binding_input(), map(), keyword()) ::
  :ok | {:error, atom()}

Verify a holder Key Binding JWT (kb+jwt) over an already-verified presentation.

verified is the map verify/3 returned; holder_jwk is the key the issuer bound the credential to (the cnf key). Options:

  • :nonce - the expected nonce (REQUIRED).
  • :audience - the expected aud (REQUIRED) - this verifier's identifier.
  • :now / :max_age_seconds - freshness bounds on iat (default 300s).

Verifies the KB-JWT signature under holder_jwk, its typ (kb+jwt), the nonce/aud, and that sd_hash equals base64url(SHA-256(<Issuer JWT and presented Disclosures,~-joined, trailing~>)) (spec §4.3) - so the holder signed over exactly the presentation the verifier reconstructed claims from.