# k2gl/token-status-list

> Token Status List: publish and check revocation for JWTs and SD-JWT VCs.

The revocation mechanism behind SD-JWT VC — one signed bit array per issuer, a couple of bits per token.

## Install

```bash
composer require k2gl/token-status-list
```

## Requirements

- PHP >=8.1
- k2gl/dsse ^1.3

## Documentation

# k2gl/token-status-list

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

Token Status List
([draft-ietf-oauth-status-list](https://datatracker.ietf.org/doc/draft-ietf-oauth-status-list/))
in pure PHP: the revocation mechanism behind SD-JWT VC and the EU Digital Identity Wallet.
An issuer publishes one signed, compressed bit array for many tokens; each token carries a
`status` claim pointing at that list and an index into it. A relying party fetches the list
once, verifies it, and reads a couple of bits.

Both sides are covered: build and sign a Status List Token, and resolve and check the status
of a Referenced Token. Tracks **draft -21**. SD-JWT VC (draft -19) requires the Status List
Token of a credential's `status` claim to be in JWT format — exactly what this package
implements; [k2gl/sd-jwt-vc](https://github.com/k2gl/sd-jwt-vc) hands you the claim,
`StatusReference::fromClaim()` takes it from there. The test suite reproduces the draft's Appendix C
test vectors byte for byte — the 1-, 2-, 4- and 8-bit lists both decode to the listed
statuses and re-encode to the exact `lst` values.

## Install

```bash
composer require k2gl/token-status-list
```

Requires PHP 8.1+ with `ext-zlib`. Signatures come from
[k2gl/dsse](https://github.com/k2gl/dsse) (ECDSA P-256/384/521, Ed25519, RSA). Fetching
speaks PSR-18/PSR-17, so bring any HTTP client; caching is optional and PSR-16.

## Usage

### Check the status of a token (Relying Party)

```php
use K2gl\Dsse\PublicKey;
use K2gl\TokenStatusList\StatusListResolver;
use K2gl\TokenStatusList\StatusReference;

$resolver = new StatusListResolver(
    httpClient: $psr18Client,
    requestFactory: $psr17RequestFactory,
    key: PublicKey::fromPem($issuerPublicKeyPem),
    cache: $psr16Cache, // optional
);

// $payload is the verified Referenced Token's claims — e.g. VerifiedSdJwtVc::status()
$reference = StatusReference::fromClaim($payload->status);

$status = $resolver->check($reference);

$status->isValid();      // 0x00
$status->isInvalid();    // 0x01 — revoked
$status->isSuspended();  // 0x02
$status->value;          // the raw value, for application-specific statuses
```

`check()` is Section 8.3 end to end: GET the URI with `Accept: application/statuslist+jwt`
(following redirects), verify the token, require `sub` to equal the referenced URI, and
read the index — an index beyond the list is rejected, not reported as valid. With a
PSR-16 cache the token is reused for `ttl` seconds, bounded by `exp`, and every cached copy
is verified again before use.

Validate the Referenced Token itself first (signature, `exp`); the specification is explicit
that an expired token with a `VALID` status is still expired.

The key is a k2gl/dsse `Verifier`, or a `KeyResolver` when the key depends on the token —
`kid` against a trusted JWKS, `x5c` against your trust anchors. Key discovery is ecosystem
specific, so the package defines the seam and nothing more:

```php
use K2gl\TokenStatusList\KeyResolver;

final class IssuerKeys implements KeyResolver
{
    public function resolve(stdClass $header, stdClass $payload): Verifier
    {
        return $this->jwks->find($header->kid ?? null) ?? throw new TokenStatusListException('Unknown kid.');
    }
}
```

Historical status (Section 8.4): `$resolver->fetch($uri, at: $timestamp)` sends
`?time=…` and rejects a response that was not valid at that moment — a static host that
ignores the query does not pass off the current list as an old one.

### Publish a Status List (Status Issuer)

```php
use K2gl\Dsse\EcdsaP256Signer;
use K2gl\TokenStatusList\Status;
use K2gl\TokenStatusList\StatusList;
use K2gl\TokenStatusList\StatusListTokenIssuer;

$list = StatusList::create(size: 100_000, bits: 1);
$list->set(42, Status::invalid());

$issuer = new StatusListTokenIssuer(EcdsaP256Signer::fromPem($privateKeyPem, keyId: '12'));

$compact = $issuer->issue(
    uri: 'https://example.com/statuslists/1',
    statusList: $list,
    expiresAt: time() + 7 * 86400,
    ttl: 43200,
);
// serve $compact as application/statuslist+jwt at that URI
```

Use `bits: 2` when you need `SUSPENDED`, `4` or `8` for application-specific values. The
`alg` header is inferred for the ECDSA and Ed25519 signers of k2gl/dsse; pass it explicitly
for RSA (`new StatusListTokenIssuer($rsaSigner, 'RS256')`) or a KMS-backed signer.

The claim to put into each token you issue:

```php
use K2gl\TokenStatusList\StatusReference;

$claims['status'] = (new StatusReference('https://example.com/statuslists/1', index: 42))->toClaim();
// {"status_list": {"idx": 42, "uri": "https://example.com/statuslists/1"}}
```

### Verify a token you already have

```php
use K2gl\TokenStatusList\StatusListTokenVerifier;

$verifier = new StatusListTokenVerifier(
    allowedAlgorithms: ['ES256'],  // default: ES256/384/512, EdDSA, RS256/384/512
    clockLeewaySeconds: 60,
);

$token = $verifier->verify($compact, $issuerKey, expectedUri: $reference->uri);

$token->status($reference);   // Status
$token->statusList();         // StatusList — get(), count(), bits()
$token->freshUntil(time());   // when to fetch again, from ttl and exp
```

Rejected, fail-closed: a `typ` other than `statuslist+jwt`, an `alg` outside the allow list
(`none` included), a `crit` header, a bad signature, a missing `sub`/`iat`/`status_list`, a
non-positive `ttl`, a token issued in the future or already expired, an `lst` that is not one
complete ZLIB stream, and a list that inflates beyond a size limit (16 MiB by default —
`gzuncompress()` accepts a limit but does not enforce it, so inflation is bounded here).

## Scope

- Status List encoding (Section 4.1–4.2) and JWT-format Status List Tokens (Section 5.1,
  Section 8.3 validation rules), issue and verify.
- Fetching over PSR-18 with content negotiation, redirects, historical resolution, and
  PSR-16 caching per the `ttl`/`exp` guidance of Section 13.7.
- `aggregation_uri` is surfaced on the list; walking a Status List Aggregation (Section 9)
  is left to the application.
- CWT/CBOR representations (Section 4.3, 5.2, 6.3) are not implemented — no CBOR
  dependency; the JOSE side is what SD-JWT VC deployments use.
- MAC-protected tokens (Section 11.6) are not supported; asymmetric signatures only.

## License

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

## API

### K2gl\TokenStatusList\Exception\InvalidStatusListException (class)

_no public methods_

### K2gl\TokenStatusList\Exception\InvalidStatusListTokenException (class)

_no public methods_

### K2gl\TokenStatusList\Exception\StatusListFetchFailed (class)

_no public methods_

### K2gl\TokenStatusList\Exception\TokenStatusListException (class)

_no public methods_

### K2gl\TokenStatusList\KeyResolver (interface)

- `resolve(stdClass $header, stdClass $payload): Verifier`

### K2gl\TokenStatusList\Status (class)

- `of(int $value): self`
- `valid(): self`
- `invalid(): self`
- `suspended(): self`
- `isValid(): bool`
- `isInvalid(): bool`
- `isSuspended(): bool`
- `isApplicationSpecific(): bool`
- `name(): ?string`
- `equals(self $other): bool`

### K2gl\TokenStatusList\StatusList (class)

- `create(int $size, int $bits = 1, ?string $aggregationUri = null): self`
- `fromBytes(string $bytes, int $bits, ?string $aggregationUri = null): self`
- `decode( string $lst, int $bits, ?string $aggregationUri = null, int $maxBytes = self::DEFAULT_MAX_BYTES, ): self`
- `fromArray(array|stdClass $statusList, int $maxBytes = self::DEFAULT_MAX_BYTES): self`
- `bits(): int`
- `count(): int`
- `aggregationUri(): ?string`
- `get(int $index): Status`
- `set(int $index, Status|int $status): void`
- `bytes(): string`
- `encode(): string`
- `toArray(): array`
- `withAggregationUri(?string $aggregationUri): self`

### K2gl\TokenStatusList\StatusListResolver (class)

- `__construct( private readonly ClientInterface $httpClient, private readonly RequestFactoryInterface $requestFactory, private readonly Verifier|KeyResolver $key, private readonly StatusListTokenVerifier $verifier = new StatusListTokenVerifier, private readonly ?CacheInterface $cache = null, private readonly int $maxRedirects = 3, )`
- `check(StatusReference $reference): Status`
- `fetch(string $uri, ?int $at = null): StatusListToken`

### K2gl\TokenStatusList\StatusListToken (class)

- `__construct( private readonly string $compact, private readonly stdClass $header, private readonly stdClass $payload, private readonly string $subject, private readonly int $issuedAt, private readonly ?int $expiresAt, private readonly ?int $ttl, private readonly StatusList $statusList, )`
- `toCompact(): string`
- `subject(): string`
- `issuedAt(): int`
- `expiresAt(): ?int`
- `ttl(): ?int`
- `statusList(): StatusList`
- `header(): stdClass`
- `payload(): stdClass`
- `claim(string $name): mixed`
- `freshUntil(int $fetchedAt): ?int`
- `status(StatusReference $reference): Status`

### K2gl\TokenStatusList\StatusListTokenIssuer (class)

- `__construct(private readonly Signer $signer, ?string $algorithm = null)`
- `issue( string $uri, StatusList $statusList, ?int $issuedAt = null, ?int $expiresAt = null, ?int $ttl = null, array $claims = [], array $header = [], ): string`
- `algorithm(): string`

### K2gl\TokenStatusList\StatusListTokenVerifier (class)

- `__construct( private readonly array $allowedAlgorithms = self::DEFAULT_ALGORITHMS, private readonly ?int $clock = null, private readonly int $clockLeewaySeconds = 0, private readonly int $maxListBytes = StatusList::DEFAULT_MAX_BYTES, )`
- `verify(string $compact, Verifier|KeyResolver $key, ?string $expectedUri = null): StatusListToken`
- `now(): int`

### K2gl\TokenStatusList\StatusReference (class)

- `__construct( public readonly string $uri, public readonly int $index, )`
- `fromClaim(array|stdClass $status): self`
- `toClaim(): array`

## Links

- GitHub: https://github.com/k2gl/token-status-list
- Packagist: https://packagist.org/packages/k2gl/token-status-list
