Blog

Revocation for SD-JWT VC, in PHP

Verifying an SD-JWT VC answers one question: did this issuer sign this credential, and is the holder the one presenting it. It does not answer whether the issuer still stands behind it. For that, SD-JWT VC points at a separate mechanism, and the one the ecosystem settled on is the Token Status List: the issuer publishes one signed bit array for many credentials, each credential carries an index into it, and a relying party fetches the list once and reads a couple of bits.

k2gl/token-status-list is that, in pure PHP. It was the obvious next package: k2gl/sd-jwt-vc already surfaced the status claim, but a PHP relying party had nothing to check it against — Packagist had no implementation at all.

The shape of it

A list is bits per status (1, 2, 4 or 8) packed from the least significant bit of each byte, then DEFLATE-compressed in the zlib format and base64url-encoded. That byte string travels inside a Status List Token, a JWT with typ: statuslist+jwt, sub equal to its own URI, iat, and optionally exp and a ttl for caching. A credential references it as

{"status": {"status_list": {"idx": 42, "uri": "https://example.com/statuslists/1"}}}

On the relying-party side the whole of the draft’s validation section is one call:

use K2gl\TokenStatusList\StatusListResolver;
use K2gl\TokenStatusList\StatusReference;

$resolver = new StatusListResolver($psr18Client, $psr17RequestFactory, $statusIssuerKey, cache: $psr16Cache);

$status = $resolver->check(StatusReference::fromClaim($credential->status()));
$status->isValid();      // 0x00
$status->isInvalid();    // 0x01 — revoked
$status->isSuspended();  // 0x02

The resolver sends Accept: application/statuslist+jwt, follows redirects a bounded number of times, refuses any other content type, verifies the token (allow-listed alg, no crit, signature, sub equal to the URI you asked for, time window), inflates the list under a size limit, and reads the index — an index past the end of the list is a rejection, not a VALID. With a PSR-16 cache it keeps the token for ttl seconds, bounded by exp, and verifies the cached copy again before trusting it. Historical resolution (?time=) is there too, with the check the draft asks for: a static host that ignores the query and serves today’s list is not allowed to pass it off as last month’s.

The issuer side is the same package: build a list, flip bits, sign it with any k2gl/dsse key.

Two things worth knowing

The test vectors reproduce exactly. The draft’s Appendix C gives four lists of a million entries each and their encoded form. They were produced with zlib at level 9, and PHP’s zlib produces the same bytes, so the test suite asserts the exact lst strings rather than a round trip. That is a stronger check than it sounds: any mistake in bit order — the classic one this format invites — would change the bytes.

gzuncompress() does not enforce its length limit. The function takes a max_length argument, and a status list is exactly the kind of untrusted, compressible input where you want one. It turned out to return the full output regardless (a 1000-byte payload came back whole against a limit of 999). The package inflates through inflate_add() in 4 KiB chunks instead and stops as soon as the output exceeds the limit, and while at it rejects a truncated stream and trailing bytes after the stream end. If you decompress untrusted zlib data in PHP anywhere else, check what your limit actually does.

With sd-jwt-vc

The two packages are wired at the seam: VerifiedSdJwtVc::status() returns the claim, StatusReference::fromClaim() takes it. The guide walks through both ends. Draft -19 of SD-JWT VC, which sd-jwt-vc 2.0 tracks, also pins the Status List Token to the JWT form — the one implemented here; the CWT form is deliberately out of scope.

composer require k2gl/token-status-list