Skip to content

ADR-0077: SD-JWT RFC 9901 + BBS+ Selective Disclosure Alignment

  • Status: Accepted (2026-05-26)
  • Date: 2026-05-25
  • Extends: ADR-0030 (Verifiable Credentials v2), ADR-0011 (DID identity)
  • Tracks: trails.credentials (disclosure_format extension)

Context

ADR-0030 shipped selective disclosure in Wave 49 (M19 Phase 4) via Ed25519SelectiveDisclosure2020: a salted SHA-256 hash-based proof type that allows field-level redaction of Verifiable Credentials. The implementation is working, tested (18 tests covering tamper detection, wrong-key rejection, full and partial disclosure), and production-ready for the trails-ed25519 disclosure format.

Two new developments motivate this ADR:

RFC 9901 Finalization

RFC 9901 — Selective Disclosure for JSON Web Tokens (SD-JWT) — was finalized by the IETF in November 2025. SD-JWT is now the IETF-standardized mechanism for selective disclosure of JWT-based credentials. It has seen rapid adoption in:

  • EU Digital Identity Wallet (EUDIW / eIDAS 2.0)
  • OpenID for Verifiable Credentials (OID4VC) ecosystem
  • IETF OAUTH working group ecosystem (SD-JWT VC, RFC 9901 + draft-ietf-oauth-sd-jwt-vc)

The _sd digest format in RFC 9901 is:

BASE64URL(SHA-256(CONCAT(SALT, "~", CLAIM_VALUE)))

Our existing Ed25519SelectiveDisclosure2020 uses:

SHA256(salt || field_name || json(value))

These are structurally identical (salted hash of field content) but use different serialisation conventions. Migration from the Trails format to RFC 9901 is additive, not a redesign — the cryptographic primitive is the same; the JWT envelope and _sd array format differ.

BBS+ Cross-Presentation Unlinkability

W3C VCDM 2.0 specifies the bbs-2023 cryptosuite for BBS+ signatures, which provides cross-presentation unlinkability: a verifier cannot correlate two presentations of the same credential even if the holder discloses different subsets of fields. This is a privacy property that SD-JWT alone cannot achieve — SD-JWT presentations are linkable by the credential's JWT ID (jti) and issuer signature.

BBS+ unlinkability is specifically required for: - Health data (patient presenting to multiple providers) - Age-verification without identity linkage - EU Digital Identity Wallet high-privacy tier

The bbs Python library (bundled with cryptography extras) provides the BBS+ signature implementation conformant to bbs-2023.

Implementation note (Wave 51): This ADR is Accepted and M33 has shipped. The JWT library evaluation resolved in favour of a minimal RFC 9901 encoder/decoder implemented directly in trails.credentials._sd_jwt (under 200 lines), avoiding external dependency risk. The BBS_AVAILABLE flag gates optional BBS+ support when the trails[bbs] extra is installed.

Decision (Accepted — shipped Wave 51)

Extend trails.credentials with three disclosure formats selectable at credential issuance and derivation time. The existing Ed25519SelectiveDisclosure2020 format is preserved as the default — no breaking change for existing deployments.

Disclosure format parameter

# At issuance
vc = issue_credential(
    subject=patient_data,
    issuer_did=issuer_did,
    disclosure_format="trails-ed25519",  # default, existing behavior
    # disclosure_format="sd-jwt"         # RFC 9901 JWT envelope
    # disclosure_format="bbs+"           # W3C bbs-2023 cryptosuite
)

# At derivation (selective disclosure)
derived = derive_credential(
    vc,
    disclosed_fields=["name", "age"],
    disclosure_format="sd-jwt",          # must match issuance format
)

Format 1: trails-ed25519 (current, default)

No change. Ed25519SelectiveDisclosure2020 proof type. Backward-compatible. All existing tests continue to pass.

Format 2: sd-jwt (RFC 9901)

Credential structure:

{
  "_sd_alg": "sha-256",
  "_sd": [
    "BASE64URL(SHA-256(CONCAT(salt_1, '~', json(name))))",
    "BASE64URL(SHA-256(CONCAT(salt_2, '~', json(dob))))"
  ],
  "iss": "did:key:...",
  "iat": 1748131200,
  "exp": 1779667200,
  "cnf": { "jwk": {...} }   // optional key binding
}

Disclosed presentation (SD-JWT + disclosures):

<issuer-jwt>~<disclosure_1>~<disclosure_2>~[<key-binding-jwt>]

Each disclosure is BASE64URL(JSON(ARRAY[salt, claim_name, claim_value])).

Key binding (optional): When key_binding=True, the derived presentation includes a holder-signed key-binding JWT proving the holder controls the key. Required for high-assurance use cases.

Implementation:

class SDJWTCredential:
    """RFC 9901-compliant SD-JWT credential wrapper."""

    @classmethod
    def issue(cls, subject: dict, *, issuer_key, sd_fields: list[str]) -> "SDJWTCredential":
        """Issue credential with all sd_fields as selective disclosure fields."""

    def derive(
        self,
        disclosed_fields: list[str],
        *,
        holder_key=None,
        nonce: str | None = None,
    ) -> str:
        """
        Create RFC 9901 SD-JWT presentation string.

        Returns: "<issuer-jwt>~<disc_1>~...[~<kb-jwt>]"
        """

    def verify(self, presentation: str, *, issuer_public_key) -> VerificationResult:
        """Verify an SD-JWT presentation per RFC 9901 §6."""

Format 3: bbs+ (opt-in, W3C bbs-2023 cryptosuite)

Available only when cryptography and bbs extras are installed (trails[bbs]). Falls back to trails-ed25519 with a UserWarning if dependencies are absent.

Unlinkability guarantee: Two presentations derived from the same BBS+ credential with different disclosed subsets are computationally indistinguishable. No shared identifier, no correlatable signature, no linkage vector beyond the disclosed claims themselves.

class BBSCredential:
    """W3C bbs-2023 cryptosuite credential wrapper."""

    @classmethod
    def issue(cls, subject: dict, *, issuer_key) -> "BBSCredential":
        """Issue BBS+ signed credential."""

    def derive(
        self,
        disclosed_fields: list[str],
        *,
        nonce: str | None = None,
    ) -> BBSPresentation:
        """Derive unlinkable presentation disclosing only specified fields."""

    def verify(self, presentation: "BBSPresentation", *, issuer_public_key) -> VerificationResult:
        """Verify BBS+ presentation."""

Migration path from trails-ed25519 to sd-jwt

The _sd digest in RFC 9901 uses BASE64URL(SHA-256(SALT + "~" + CLAIM_VALUE)) vs. our SHA256(salt || field_name || json(value)). The primitives are equivalent but the serialisation differs. Migration is additive:

  1. New credentials issued with disclosure_format="sd-jwt" use RFC 9901 format natively.
  2. Existing trails-ed25519 credentials remain verifiable with the existing verify_selective() function — no migration of issued credentials is required.
  3. A migration utility trails cred migrate-format --from trails-ed25519 --to sd-jwt <vc_file> will re-issue the credential in the new format with the same subject and issuer (requires issuer private key). Provided for convenience; not required.

Library evaluation (resolved)

Library RFC 9901 compliant Key binding bbs-2023 License
sd-jwt (PyPI) Partial (pre-RFC draft) No No Apache-2.0
python-sd-jwt Yes (post-RFC update pending) Yes No Apache-2.0
sd-jwt-vc Yes Yes No MIT

Resolution (Wave 51): A minimal RFC 9901 encoder/decoder was implemented directly in trails.credentials._sd_jwt (under 200 lines). No external SD-JWT library dependency is required. This eliminates ecosystem-maturity risk and keeps trails.credentials dependency-light.

Consequences

Positive

  • Standards alignment. RFC 9901 is the IETF standard for selective disclosure in the JWT ecosystem. Trails credentials become interoperable with EU Digital Identity Wallet, OID4VC, and the broader OAUTH ecosystem.
  • Unlinkability path. BBS+ provides a cryptographic unlinkability guarantee not achievable with salted hashes or SD-JWT alone — required for high-privacy deployments.
  • Backward compatible. trails-ed25519 remains the default. No existing credential is invalidated. No API break.
  • Additive migration. The RFC 9901 _sd digest is structurally compatible with the existing salted-hash implementation — the core cryptographic operation is reused.

Negative

  • JWT library risk (resolved). The Python SD-JWT ecosystem is immature. Library evaluation concluded that a custom minimal implementation is the right choice — shipped in Wave 51.
  • BBS+ dependency weight. The bbs Python library is a large optional dependency. trails[bbs] must remain optional; BBS+ must never be in the base install.
  • Three formats to maintain. Supporting three disclosure formats increases test surface and maintenance burden. Mitigated by a shared abstract SelectiveDisclosureCredential base class.

Non-consequences

  • ADR-0030 (Verifiable Credentials v2) is extended, not superseded. The W3C VC data model is unchanged; only the proof mechanism gains new options.
  • ADR-0011 (DIDs) unchanged. DID-based issuer identity works identically across all three formats.
  • The Cedar credential-gated policy integration (ADR-0030 Phase 3) is unchanged — credential type registration is format-agnostic.

Revisit conditions

  • If RFC 9901 is amended by a follow-on RFC that changes the _sd digest format, evaluate impact on existing issued credentials.
  • If the W3C bbs-2023 cryptosuite reaches W3C Recommendation status (currently a Working Draft), upgrade the implementation to the final spec.
  • If EU EUDIW mandates a specific SD-JWT profile (e.g., SD-JWT VC per draft-ietf-oauth-sd-jwt-vc), align the sd-jwt format implementation with that profile.
  • Library evaluation (Wave 51) concluded: build over adopt. Minimal RFC 9901 encoder/decoder implemented in trails.credentials._sd_jwt. Decision documented here.

References

  1. IETF. (2025). RFC 9901: Selective Disclosure for JSON Web Tokens (SD-JWT). Internet Engineering Task Force, November 2025.

  2. Terbu, O., & Fett, D. (2025). CSD-JWT: Compact and Selective Disclosure for Verifiable Credentials. arXiv:2506.00262.

  3. W3C. (2025). Verifiable Credential Data Model 2.0: BBS Cryptosuite (bbs-2023). W3C Working Draft. https://www.w3.org/TR/vc-data-model-2.0/

  4. Rateau, T., & Singh, J. (2024). SD-JWT and BBS+: Unlinkability Analysis for Digital Identity Wallets. Worldline Engineering Blog, May 2024.