Repository navigation
feat: revoke individual credentials via an IETF Token Status List - #36
Conversation
Phases 0-2 of src/oid4vc/REVOCATION_PLAN.md. Credentials issued by this service were bearer-forever: nothing emitted a status claim, no endpoint served one, and neither verification path checked revocation. Withdrawing one holder's access meant rotating the issuer key, which invalidates every credential ever issued for everyone. This lands the issuance half. Credentials now carry an IETF Token Status List pointer, and the machinery exists to flip a bit. The HTTP endpoint that serves the list does not exist yet (Phase 3). WHAT IS HERE - @sd-jwt/jwt-status-list promoted from a transitive dependency to a direct one, pinned exactly to the version yarn.lock already resolves, so the lockfile is untouched. - Oid4vcConfig.statusListBaseUrl / statusListUri. The URI is baked into every credential at issuance and a failed status fetch fails the entire verification, so statusListBaseUrl warns when OID4VC_BASE_URL carries no path segment - the realistic misconfiguration that would render every credential issued under it permanently unverifiable. - StatusListRecord: one Vault KV record per list, holding a fixed-size bitstring plus the next free index, pre-sized at 16384 entries (36 bytes once deflated). Size is a constant, not configuration. - Oid4vcStatusService: allocates entries at issuance, flips bits on revoke/reactivate, and serves the list as a signed statuslist+jwt. - The SD-JWT branch of the credential mapper reserves a bit and records the (list, index) pair on the local issuance session before returning anything signed. WHY IT IS SHAPED THIS WAY The status list token is signed with the manager transit key because Credo configures a single verifier for both a credential and its status list (SdJwtVcService.js:154). A list signed by any other key fails verification. This is a constraint, not a preference. The token carries no exp. @sd-jwt only checks expiry when the claim is present, and an expired list fails every credential pointing at it - an availability cliff bought for nothing, since the token is regenerated on every write. Every read-modify-write runs through one in-process queue, token building included. Building outside the queue lets a revocation land between reading the record and populating the cache, which would publish and then keep serving a token saying a revoked credential is live. The status claim is set after the claims spread so a same-named claim in issuanceMetadata cannot shadow the real revocation pointer, and it is kept out of the disclosure frame because status is reserved by SD-JWT VC and listing it in _sd makes the library throw. Allocation, persistence, then signing - in that order. A failure at either of the first two aborts issuance, because a credential whose entry was not recorded could never be revoked. The W3C JwtVc branch is deliberately untouched. Credo hardcodes 'Verifying credential status is not supported for JWT VCs', so a credentialStatus there would break verification rather than enable it. DO NOT DEPLOY THIS COMMIT ALONE Credentials issued from here point at a URL nothing serves until Phase 3. A failed status fetch throws, so any credential issued in between is permanently unverifiable. Related: credentials issued before this change carry no status claim at all, so they remain valid forever and cannot be revoked - @sd-jwt skips the check entirely when the claim is absent. Reissue at cutover if any of them matter. TESTS 24 suites / 190 tests before, 26 / 209 after. No existing test was changed. oid4vc-status.service.spec.ts signs with a real Ed25519 key from node's crypto, so the published token is verified the way a verifier would rather than merely inspected. oid4vc-issuer.service.spec.ts is new - the issuer service had no spec, so the mapper was untested in both branches. Writing those tests surfaced a pre-existing defect, recorded in the plan but deliberately not fixed here: the W3C JwtVc branch silently drops every custom claim, because Credo's W3cCredentialSubject only maps id and claims. The test pins the current behaviour rather than endorsing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Coverage report
Show new covered files 🐣
Show files with reduced coverage 🔻
Test suite run success342 tests passing in 29 suites. Report generated by 🧪jest coverage report action from 6d64e1a |
Phase 3 of the credential revocation plan (src/oid4vc/REVOCATION_PLAN.md). This is the half that makes the pointers embedded by PR 1 resolve: until now the app issued credentials referencing a URL nothing served. - GET credential/status/list/:listId - public, because verifiers dereference it and @sd-jwt fails verification outright if it cannot. - POST credential/status/revoke and .../reactivate - manager JWT, inherited from the global AuthGuard, addressed by issuance session id. THE LIST ROUTE WRITES ITS OWN RESPONSE It takes @res() and calls response.end(token) rather than returning a string. @sd-jwt's default status list fetcher compares Content-Type for strict equality with application/statuslist+jwt, but Express appends "; charset=utf-8" to any string body, and Nest sends any object - a Buffer included - through response.json(). Neither ordinary return path can produce an exact media type. This matters because Credo's own fetcher does not check the media type at all. Served the obvious way, the endpoint works for every internal caller and fails only for third-party verifiers - the audience the endpoint exists for. Caught by the e2e, which deliberately uses @sd-jwt's default fetcher rather than a stub. Nothing is written before the await, so a thrown NotFoundException still renders through the exception filter. TESTS 209 -> 216 unit tests, plus a new offline e2e suite. test/status-list.e2e-spec.ts stands alone: no Vault, no chain, no separately running server. The app really listens, Vault KV is a Map with JSON round-tripped writes, and Vault transit signing is a real Ed25519 key from node's crypto - so the credential and the status list are signed by the same key, which is what Credo requires. It issues a genuine SD-JWT VC, verifies it over real HTTP, revokes it, and asserts verification then fails with "Status is not valid" while a second credential still verifies. It needs no infrastructure, so it runs locally with a path filter - `yarn test:e2e status-list` - and in CI as part of the existing yarn test:e2e step, which already has LocalNet, Vault and the compose stack up. No second jest config: e2e specs live in test/ alongside app.e2e-spec.ts.
Phases 4 and 6 of the credential revocation plan (src/oid4vc/REVOCATION_PLAN.md), completing the stack. WHY THIS IS NOT AN OPTIMISATION CredentialAuthGuard verifies a credential on every wallet request, and every verification dereferences the status list. Without this change each wallet call makes a loopback HTTP request, and wallet authentication as a whole comes to depend on the process being able to reach itself at its own advertised hostname. In a container whose OID4VC_BASE_URL is an external name, that resolves somewhere else or nowhere, and every request 401s. Oid4vcStatusService now replaces Credo's status list fetcher at boot: URIs under our own statusListBaseUrl are answered from Vault directly, everything else is delegated to the original fetcher untouched. Since the manager is the only party checking status today, this is the enforcement path that actually runs; the HTTP endpoint exists for third-party verifiers. Two Credo internals were checked before relying on them, because either being false would have made the patch a silent no-op: - SdJwtVcService is registered with registerSingleton (SdJwtVcModule.js:24), so dependencyManager.resolve returns the instance agent.sdJwtVc.verify uses. - getBaseSdJwtConfig calls getStatusListFetcher afresh on every sign and verify, so replacing the method affects later verifications rather than being captured once at construction. getStatusListFetcher is private in the type declarations, so the patch casts. That is deliberate: Credo exposes no supported hook for supplying a fetcher, and the alternative is making wallet auth depend on server self-reachability. If a Credo upgrade breaks it, onModuleInit warns and the system falls back to HTTP rather than failing to boot. Only a single clean path segment is claimed as ours; a nested path or query string under our own base URL falls through to the network rather than being guessed at. DOCS README gains the three status endpoints, a Revocation section with curl examples, and the status list records under Storage. TRUST_MODEL no longer calls the mutable on-chain document a "revocation surface" - it is a key-rotation surface, and rotating invalidates every credential for every holder. Per-credential revocation is recorded as its own invariant, along with the gap that credentials predating the status claim can never be revoked. TESTS 216 -> 228. The new cases cover URI matching in both directions, a revocation being reflected immediately, autoInit=false leaving the fetcher untouched, and an unavailable agent warning rather than failing boot.
…able Two deviations from draft-ietf-oauth-status-list-13, found reviewing the branch against the spec. Section 13.3 requires the Status Issuer to prevent any unintended double allocation. Allocation and revocation were serialised only by an in-process promise chain, so a second Nest instance could hand out an index that was already taken, or lose a revocation outright. Vault KV-v2 supports compare-and-set, so: - `VaultService.kvReadVersioned` returns an entry with its KV version, and `kvWrite` takes an optional `cas`, raising `VaultCasConflictError` when the conditional write loses. An unrelated 400 still surfaces as a 400. - `StatusListRepository.load` / `saveIfUnchanged` expose that pair for status lists, and `Oid4vcStatusService.mutate` re-reads and re-applies on conflict. The in-process queue stays, but only to keep the common case off the retry path and to preserve the token-cache guarantee. It is no longer what makes the writes correct. Separately, the issuance session held a single `statusListId` / `statusListIndex` pair. A session that issues more than one credential — several `credential_configuration_ids` in the offer, or a repeated credential request — overwrote the first entry, leaving that credential live with nothing pointing at its bit. Replaced with a `statusEntries` array the mapper appends to; revocation now flips every entry the session holds. `ttl` and `exp` on the Status List Token (RECOMMENDED by sections 5.1 and 13.7) stay deferred, recorded in the plan's shortcuts table.
…ls-status-endpoint # Conflicts: # src/oid4vc/REVOCATION_PLAN.md
…-status-local-resolve # Conflicts: # src/oid4vc/REVOCATION_PLAN.md # src/oid4vc/status/oid4vc-status.service.ts
This comment was marked as outdated.
This comment was marked as outdated.
The PR description is being cut down. Its spec-conformance table and the reasons behind the in-process status fetch, the fixed OID4VC_BASE_URL and the lack of suspension now live in REVOCATION_PLAN.md.
PhearZero
left a comment
There was a problem hiding this comment.
We can move forward with the lookup by id instead of the discussed did:key index. We should be able to support both the sessionId or the vaultId for revoke/status and that should be a good staging point. We can always improve lookup performance and include a parser in the future for did:key/did:algo
The offer response returns two ids: the local (Vault) session `id` and `credoIssuanceSessionId`. `POST /credential/status/revoke` and `/reactivate` only took the local one, so a caller holding the Credo id could not revoke. The request body now takes exactly one of `sessionId` (local id, as before) or `credoIssuanceSessionId`. Sending both, or neither, returns 400 before any status bit changes; an unknown Credo id returns 404.
I have implemented the suggested changes. the body is now |
The plan's design notes already live in src/oid4vc/README.md and in code comments. Move the opt-in capacity and real-Vault check commands to the README testing section, record why there is no suspension status, and drop the dangling references to the plan.
Conflicts resolved: - package.json: keep both @sd-jwt/jwt-status-list and algosdk - src/vault/vault.service.ts: keep both VaultCasConflictError and TransitUserKey - test/app.e2e-spec.ts: take master's strict user detail assertion (account_type)
PhearZero
left a comment
There was a problem hiding this comment.
LGTM, tested with a headless wallet and it is working as expected. We can dive into the reverse lookup
Before merge
What this does
Demo script
Until now, a credential issued by this service stayed valid forever. The only way to withdraw one was to rotate the issuer key, which invalidates every credential for every user.
Each SD-JWT VC now points to one bit in a signed status list that this service publishes, following the IETF Token Status List draft. Revoking a credential flips its bit, and any verifier that checks the list rejects it. That includes third parties that never call this service, which is why a local revocation table would not be enough.
Verification sessions also report an
outcome(pending,verified,revokedorfailed), so callers can tell revocation apart from other failures.New routes
GET /v1/credential/status/list/:listIdPOST /v1/credential/status/revoke{ sessionId, reason? }or{ credoIssuanceSessionId, reason? }. Revokes every credential issued in that session. Both ids come back from offer creation; send exactly one, or get a 400.POST /v1/credential/status/reactivate{ sessionId }or{ credoIssuanceSessionId }.No new environment variables.
Coming in #41 (not merged yet)
#41 stops addressing credentials by
sessionId, because losing that id would leave a credential that can never be revoked.revokeandreactivatetake exactly one of:{ holderDidKey, credentialConfigurationId? }: every credential issued to that holder, optionally only those issued under one configuration.{ uri, idx }, copied from the credential'sstatus.status_listclaim: that credential only. Other credentials from the same offer keep their status.reason?still applies to revoke. AsessionIdbody is rejected with 400.GET /v1/credential/issuer/sessionsgains a?holderDidKey=filter.Revoking a holder with several sessions is not atomic. If it fails partway, sending the same request again finishes it.
These lookups only cover offers and credentials created after feat(oid4vc): revoke and reactivate by holder did:key #41 lands.
Before you deploy
OID4VC_BASE_URL. If that URL stops responding, orOID4VC_BASE_URLchanges, every credential that points at it fails verification. Once issuance starts, there is no rollback.jwt_vc_jsoncredentials get no status. Credo cannot check status for them, so that path is unchanged.Testing
28 unit suites (273 tests) and the 5 HTTP integration tests in
test/status-list.e2e-spec.tspass, as doyarn lintandyarn build. The real-Vault check (test/status-list-vault.e2e-spec.ts, against a disposable Vault 1.15.6 dev server) passes. An opt-in capacity test (1,000,001 allocations) is run separately and passed in its last recorded run.Design details
src/oid4vc/REVOCATION_PLAN.mdcovers the data model, concurrency, spec conformance, how to run each check, and what is deferred.