Skip to content

feat: revoke individual credentials via an IETF Token Status List - #36

Merged
PhearZero merged 20 commits into
masterfrom
feat/credentials-status-and-revoke
Oct 1, 2026
Merged

PhearZero merged 20 commits into
masterfrom
feat/credentials-status-and-revoke

Conversation

@bwmx

@bwmx bwmx commented Sep 3, 2026 •

Copy link
Copy Markdown
Contributor

Before merge

  • Review the deferred work
  • Delete planning docs that are no longer needed

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, revoked or failed), so callers can tell revocation apart from other failures.

New routes

Route Auth Notes
GET /v1/credential/status/list/:listId public The signed status list. It has to be public, because verifiers fail a credential whose list they cannot fetch.
POST /v1/credential/status/revoke manager JWT Body { 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 manager JWT Body { 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.

  • revoke and reactivate take exactly one of:

    • { holderDidKey, credentialConfigurationId? }: every credential issued to that holder, optionally only those issued under one configuration.
    • { uri, idx }, copied from the credential's status.status_list claim: that credential only. Other credentials from the same offer keep their status.

    reason? still applies to revoke. A sessionId body is rejected with 400.

  • GET /v1/credential/issuer/sessions gains 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

  • The status list URL has to stay up. Every credential carries its list URL, which is built from OID4VC_BASE_URL. If that URL stops responding, or OID4VC_BASE_URL changes, every credential that points at it fails verification. Once issuance starts, there is no rollback.
  • Existing credentials need reissuing. Wallet authentication now requires a status reference, so device-attestation credentials issued before this PR are rejected.
  • W3C jwt_vc_json credentials 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.ts pass, as do yarn lint and yarn 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.md covers the data model, concurrency, spec conformance, how to run each check, and what is deferred.

bwmx and others added 2 commits September 3, 2026 17:11
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>
@github-actions

github-actions Bot commented Sep 3, 2026 •

Copy link
Copy Markdown

Coverage report

St.❔
Category Percentage Covered / Total
🟡 Statements
74.28% (+6.54% 🔼)
1958/2636
🔴 Branches
56.47% (+9.69% 🔼)
397/703
🟡 Functions
68.45% (+8.26% 🔼)
256/374
🟡 Lines
74.72% (+6.53% 🔼)
1815/2429
Show new covered files 🐣
St.❔
File Statements Branches Functions Lines
🟢
... / status-list.entity.ts
100% 100% 100% 100%
🟢
... / status-list.repository.ts
100% 100% 100% 100%
🟢
... / oid4vc-status.service.ts
100% 100% 100% 100%
🟢
... / oid4vc-status.controller.ts
100% 100% 100% 100%
🟢
... / status-change.dto.ts
100% 100% 100% 100%
Show files with reduced coverage 🔻
St.❔
File Statements Branches Functions Lines
🟢
... / vault.repository.ts
93.62% (-0.05% 🔻)
82.61% (+1.66% 🔼)
100%
97.59% (-0.98% 🔻)

Test suite run success

342 tests passing in 29 suites.

Report generated by 🧪jest coverage report action from 6d64e1a

@bwmx bwmx changed the title feat: credentials status and revoke feat: issue SD-JWT VCs with a revocable status list entry Sep 3, 2026
bwmx added 9 commits September 3, 2026 18:55
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
@bwmx bwmx changed the title feat: issue SD-JWT VCs with a revocable status list entry feat: revoke individual credentials via an IETF Token Status List Sep 7, 2026
@bwmx
bwmx marked this pull request as ready for review September 7, 2026 11:53
@bwmx
bwmx marked this pull request as draft September 7, 2026 12:22
@bwmx

This comment was marked as outdated.

This comment was marked as off-topic.

@bwmx
bwmx added this pull request to stack #42 September 16, 2026 10:28
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
PhearZero self-requested a review September 29, 2026 18:40

@PhearZero PhearZero left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.
@bwmx

bwmx commented Sep 30, 2026

Copy link
Copy Markdown
Contributor Author

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

I have implemented the suggested changes.

the body is now { sessionId, reason? } or { credoIssuanceSessionId, reason? } for /revoke routes, /reactivate accepts either { sessionId } or { credoIssuanceSessionId }

@bwmx
bwmx marked this pull request as ready for review September 30, 2026 13:09
@PhearZero
PhearZero self-requested a review October 1, 2026 14:54
@bwmx
bwmx removed this pull request from stack #42 October 1, 2026 15:10
bwmx added 2 commits October 1, 2026 22:21
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 PhearZero left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, tested with a headless wallet and it is working as expected. We can dive into the reverse lookup

@PhearZero
PhearZero merged commit a8cffe9 into master Oct 1, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants