# k2gl/sd-jwt

> Selective Disclosure for JWTs (RFC 9901): issue, present, verify.

Issue and verify SD-JWTs — the credential format behind OpenID4VC and the EU Digital Identity Wallet.

## Install

```bash
composer require k2gl/sd-jwt
```

## Requirements

- PHP >=8.1
- k2gl/dsse ^1.3

## Documentation

# k2gl/sd-jwt

[![CI](https://img.shields.io/github/actions/workflow/status/k2gl/sd-jwt/ci.yml?branch=main&label=CI&logo=github)](https://github.com/k2gl/sd-jwt/actions)
[![Latest Stable Version](https://img.shields.io/packagist/v/k2gl/sd-jwt)](https://packagist.org/packages/k2gl/sd-jwt)
[![Total Downloads](https://img.shields.io/packagist/dt/k2gl/sd-jwt)](https://packagist.org/packages/k2gl/sd-jwt)
[![PHPStan](https://img.shields.io/badge/PHPStan-level%209-2a5ea7)](https://phpstan.org/)
[![License](https://img.shields.io/packagist/l/k2gl/sd-jwt)](https://packagist.org/packages/k2gl/sd-jwt)

Selective Disclosure for JWTs ([RFC 9901](https://www.rfc-editor.org/rfc/rfc9901)) in pure
PHP: issue SD-JWTs with selectively disclosable claims, build Holder presentations with
optional Key Binding, and verify both. This is the credential format at the core of
OpenID4VC/HAIP and the EU Digital Identity Wallet.

The test suite is driven by the worked examples embedded in RFC 9901 itself (Sections 4.2
and 5, Appendix A.5 key), verified byte for byte.

Fail-closed by design: every MUST-level rule of the RFC's verification algorithm — duplicate
digests, unreferenced Disclosures, reserved claim names, claim collisions, `alg: none`,
Key Binding mismatches — throws; nothing is silently accepted.

## Install

```bash
composer require k2gl/sd-jwt
```

Requires PHP 8.1+. Crypto is provided by [k2gl/dsse](https://github.com/k2gl/dsse) signers
and verifiers over `ext-openssl` (ES256/ES384/ES512, RS256) and `ext-sodium` (EdDSA).

## Usage

### Issue an SD-JWT

Wrap everything selectively disclosable in `Sd::hide()` — object properties, array
elements, or whole subtrees (nesting produces recursive Disclosures per Section 4.2.6).
`Sd::decoy()` inserts decoy digests.

```php
use K2gl\SdJwt\Jws\JwsSigner;
use K2gl\SdJwt\Sd;
use K2gl\SdJwt\SdJwtIssuer;

$issuer = new SdJwtIssuer(JwsSigner::es256FromPem($issuerPrivatePem));

$sdJwt = $issuer->issue(
    claims: [
        'iss' => 'https://issuer.example.com',
        'iat' => time(),
        'sub' => 'user_42',
        'cnf' => ['jwk' => $holderPublicJwk],       // enables Key Binding
        'given_name' => Sd::hide('John'),
        'address' => Sd::hide([
            'street_address' => Sd::hide('123 Main St'), // recursive
            'country' => 'US',
        ]),
        'nationalities' => [Sd::hide('US'), Sd::hide('DE'), Sd::decoy()],
    ],
    header: ['typ' => 'example+sd-jwt'],
);

$compact = $sdJwt->toCompact(); // <JWT>~<Disclosure>~...~
```

### Present selected claims (Holder)

Selection uses JSON Pointers into the fully disclosed payload; parent Disclosures that a
nested claim depends on are included automatically.

```php
use K2gl\SdJwt\Presentation;

$presentation = Presentation::of($compact);
$presentation->disclosablePaths();  // ['/given_name', '/address', '/address/street_address', ...]

// Without Key Binding:
$toVerifier = $presentation->disclose('/given_name', '/nationalities/0')->toCompact();

// With Key Binding (RFC 9901 Section 4.3):
$toVerifier = $presentation
    ->disclose('/given_name')
    ->withKeyBinding(
        JwsSigner::es256FromPem($holderPrivatePem),
        audience: 'https://verifier.example.org',
        nonce: $nonceFromVerifier,
    );
```

### Verify a presentation (Verifier)

```php
use K2gl\Dsse\PublicKey;
use K2gl\SdJwt\Exception\SdJwtException;
use K2gl\SdJwt\KeyBinding;
use K2gl\SdJwt\SdJwtVerifier;

$verifier = new SdJwtVerifier;

try {
    $verified = $verifier->verifyPresentation(
        $toVerifier,
        PublicKey::fromJwk($issuerPublicJwk),   // or PublicKey::fromPem(...)
        KeyBinding::required(
            audience: 'https://verifier.example.org',
            nonce: $nonceFromVerifier,
        ),
    );

    $claims = $verified->claims();  // the Processed SD-JWT Payload, digests resolved
} catch (SdJwtException $e) {
    // not verified — bad signature, tampered Disclosure, replayed or missing Key Binding, ...
}
```

Use `KeyBinding::notRequired()` for presentations without Holder binding, and
`$verifier->verify($sdJwt, $issuerKey)` on the Holder side after receiving an SD-JWT from
an Issuer.

## Scope

- Compact serialization (`<JWT>~<Disclosure>~...~[<KB-JWT>]`) — issue, present, verify.
- Object-property, array-element, and recursive Disclosures; decoy digests.
- Key Binding JWTs: creation and full Section 7.3 validation (`typ`, algorithm-to-`cnf`
  match, `iat` window, `aud`, `nonce`, `sd_hash`).
- `_sd_alg`: sha-256 (default), sha-384, sha-512.
- JOSE algorithms: ES256, ES384, ES512, EdDSA, RS256 (signing); verification takes any
  k2gl/dsse `Verifier`.

The JWS JSON serialization (RFC 9901 Section 8) is not implemented yet.

## Design

- The encoded Disclosure is authoritative: digests are computed over the base64url string
  exactly as received, never over re-serialized JSON, so foreign encodings survive
  verification untouched.
- Processing keeps the JSON object/array distinction (`stdClass` vs list) end to end;
  `->payload()` returns the lossless view, `->claims()` the convenient array one.
- The verifier is policy-explicit: allowed JOSE algorithms, allowed hash algorithms, clock,
  and leeway are constructor parameters with safe defaults; Key Binding is decided by the
  Verifier's policy, never by what the Holder sent.

## License

MIT © [Nick Harin](https://github.com/k2gl)

## API

### K2gl\SdJwt\Disclosure (class)

- `fromEncoded(string $encoded): self`
- `forProperty(string $salt, string $claimName, mixed $value): self`
- `forArrayElement(string $salt, mixed $value): self`
- `isArrayElement(): bool`
- `digest(string $hashAlgorithm = HashAlgorithm::DEFAULT): string`

### K2gl\SdJwt\Exception\InvalidSdJwtException (class)

_no public methods_

### K2gl\SdJwt\Exception\KeyBindingVerificationFailed (class)

_no public methods_

### K2gl\SdJwt\Exception\SdJwtException (class)

_no public methods_

### K2gl\SdJwt\Exception\SelectionException (class)

_no public methods_

### K2gl\SdJwt\Exception\SignatureVerificationFailed (class)

_no public methods_

### K2gl\SdJwt\Jws\JwsSigner (class)

- `es256FromPem(string $pem): self`
- `es384FromPem(string $pem): self`
- `es512FromPem(string $pem): self`
- `ed25519(string $secretKey): self`
- `rs256FromPem(string $pem): self`
- `withAlgorithm(Signer $signer, string $algorithm): self`
- `algorithm(): string`
- `sign(array|stdClass $payload, array $header = []): string`

### K2gl\SdJwt\KeyBinding (class)

- `required(string $audience, string $nonce, int $maxAgeSeconds = 600): self`
- `notRequired(): self`

### K2gl\SdJwt\Presentation (class)

- `of(SdJwt|string $sdJwt): self`
- `payload(): stdClass`
- `claims(): array`
- `disclosablePaths(): array`
- `disclose(string ...$paths): self`
- `discloseAll(): self`
- `toSdJwt(): SdJwt`
- `toCompact(): string`
- `withKeyBinding( JwsSigner $signer, string $audience, string $nonce, ?int $issuedAt = null, ): string`

### K2gl\SdJwt\Sd (class)

- `hide(mixed $value): self`
- `decoy(): self`

### K2gl\SdJwt\SdJwt (class)

- `parse(string $compact): self`
- `create(string $issuerSignedJwt, array $disclosures, ?string $keyBindingJwt = null): self`
- `hasKeyBinding(): bool`
- `withoutKeyBinding(): self`
- `header(): stdClass`
- `payload(): stdClass`
- `toCompact(): string`

### K2gl\SdJwt\SdJwtIssuer (class)

- `__construct( private readonly JwsSigner $signer, private readonly string $hashAlgorithm = HashAlgorithm::DEFAULT, ?Closure $saltGenerator = null, )`
- `issue(array|stdClass $claims, array $header = []): SdJwt`

### K2gl\SdJwt\SdJwtVerifier (class)

- `__construct( private readonly array $allowedAlgorithms = self::DEFAULT_ALGORITHMS, private readonly array $allowedHashAlgorithms = self::DEFAULT_HASH_ALGORITHMS, private readonly ?int $clock = null, private readonly int $clockLeewaySeconds = 0, )`
- `verify(SdJwt|string $sdJwt, Verifier $issuerKey): VerifiedSdJwt`
- `verifyPresentation( SdJwt|string $presentation, Verifier $issuerKey, KeyBinding $keyBinding, ): VerifiedSdJwt`

### K2gl\SdJwt\VerifiedSdJwt (class)

- `__construct( private readonly stdClass $payload, private readonly ?stdClass $keyBindingPayload, )`
- `payload(): stdClass`
- `claims(): array`
- `keyBindingPayload(): ?stdClass`

## Links

- GitHub: https://github.com/k2gl/sd-jwt
- Packagist: https://packagist.org/packages/k2gl/sd-jwt
