Skip to content

feat(oid4vc): revoke and reactivate by holder did:key - #41

Merged
PhearZero merged 3 commits into
masterfrom
feat/revoke-by-did-key
Oct 26, 2026
Merged

PhearZero merged 3 commits into
masterfrom
feat/revoke-by-did-key

Conversation

@bwmx

@bwmx bwmx commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Adds holder did:key as a third way to address POST /credential/status/revoke and /reactivate, alongside the parent branch's sessionId and credoIssuanceSessionId.

{ "holderDidKey": "did:key:z6Mk...", "reason": "device reported stolen" }

It acts on every issuance session pinned to that wallet did:key, whole-session, the same way a session id does. The body takes exactly one of the three fields: anything else returns 400, and a holder with no sessions returns 404. GET /credential/issuer/sessions also gains a ?holderDidKey= filter.

How

  • Holder index. createOffer writes sessions/issuance/by-holder/<multibase>/<sessionId> before the session record. A failed write leaves at most an index entry that findByHolder skips, never a session that escapes a holder revocation. The did:key must match did:key:z<base58> before it becomes a Vault path segment.
  • One session at a time. A holder request runs the parent's per-session revoke/reactivate for each of the holder's sessions in turn. It is not atomic across sessions: re-sending the request finishes a part-way failure.
  • Unredeemed offers are fenced. A holder session with no credentials yet records the revoked status with no bits to flip. allocateForSession already refuses issuance from a revoked session, so the open offer cannot issue a valid credential after the holder is revoked. Reactivating the holder, or that offer by its own session id, lifts the fence.

Parent-branch behaviour

Unchanged. sessionId / credoIssuanceSessionId requests were run against the parent (625e72a) and this branch with the same 21 bodies (each id alone and with a reason, both, neither, null, '', a number, unknown ids, an over-long reason, reactivate after revoke). Status codes, flipped bits, returned entries and audit fields match. revokeBySessionId / reactivateBySessionId, the entities and the status list repository are untouched. The 400 message now lists all three fields.

Validation

Run on the merged tree (31669b9, master included):

  • Unit: 29 suites, 352 passed (opt-in capacity test skipped). Lint, format and build pass.
  • In-memory status-list HTTP e2e: 6 passed, including holder revoke and reactivate over HTTP.
  • Real-Vault status-list e2e passed on Vault 1.15.6. It revokes by session id, so it does not exercise the holder index against a live Vault.
  • A randomised concurrency check (400 runs, two service instances, jittered Vault calls) raced a holder revoke against issuance on a redeemed session and on an open offer, and against duplicate and opposite holder requests. Every credential issued during the revoke ended up revoked, nothing issued after it returned, and opposite requests always left a consistent final state (the loser gets 409).
  • Not run: the live-stack test/app.e2e-spec.ts and test/pq-accounts.e2e-spec.ts, and the capacity check.

Known limits

  • did:key values are checked against the base58 shape only; the key type is not decoded. Decoding (via Credo's DidKey) is left for a follow-up that also covers the existing check in create-credential-offer.dto.ts.
  • An offer created after a holder's sessions are listed is not covered by that revocation.

src/oid4vc/README.md and SEQUENCE.md are updated to match.

@bwmx
bwmx added this pull request to stack #42 September 16, 2026 10:28
@bwmx
bwmx requested a balanced review from Copilot September 16, 2026 10:28

This comment was marked as outdated.

@github-actions

github-actions Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Coverage report

St.❔
Category Percentage Covered / Total
🟡 Statements
76.55% (+2.27% 🔼)
2184/2853
🟡 Branches
60.56% (+4.09% 🔼)
473/781
🟡 Functions
72.3% (+3.85% 🔼)
308/426
🟡 Lines
76.92% (+2.2% 🔼)
2013/2617
Show new covered files 🐣
St.❔
File Statements Branches Functions Lines
🟢
... / holder-status.repository.ts
94.74% 100% 100% 93.33%
🔴
... / holder-status.entity.ts
0% 100% 100% 0%
Show files with reduced coverage 🔻
St.❔
File Statements Branches Functions Lines
🟢
... / vault-repository.ts
96.88% (-3.13% 🔻)
100%
93.75% (-6.25% 🔻)
96.08% (-3.92% 🔻)
🟢
... / oid4vc-status.service.ts
97.04% (-2.96% 🔻)
94.34% (-5.66% 🔻)
95.52% (-4.48% 🔻)
97.4% (-2.6% 🔻)

Test suite run success

384 tests passing in 29 suites.

Report generated by 🧪jest coverage report action from 23b1749

This comment was marked as outdated.

@bwmx
bwmx force-pushed the feat/revoke-by-did-key branch from 0f61987 to 92c3e50 Compare September 29, 2026 12:32
@bwmx bwmx changed the title feat(oid4vc): address credential status by holder did:key or status e… feat(oid4vc): address credential status by holder did:key or status entry Sep 29, 2026
@bwmx
bwmx force-pushed the feat/revoke-by-did-key branch from c71baf9 to e0177e7 Compare October 1, 2026 10:08
@bwmx
bwmx marked this pull request as ready for review October 1, 2026 10:31
@bwmx
bwmx requested a balanced review from Copilot October 1, 2026 10:31

This comment was marked as off-topic.

Revoke and reactivate accept `holderDidKey` as a third way to address
credentials, alongside the issuance session's `sessionId` or
`credoIssuanceSessionId`. It acts on every issuance session pinned to that
wallet did:key, whole-session, exactly as a session id does. Exactly one of
the three is accepted; anything else is a 400, and a holder with no sessions
is a 404.

Sessions are changed one at a time, not atomically; re-sending the request
finishes a part-way failure. A holder's unredeemed offers get the status
too: the session records a revoked status change with no bits to flip,
which allocateForSession already refuses, so the offer cannot issue a valid
credential after the holder is revoked. Reactivating the holder, or that
offer by its own session id, lifts it.

Offer creation writes `sessions/issuance/by-holder/<multibase>/<sessionId>`
before the session record, so a failed write leaves at most an index entry
that findByHolder skips, never a session that escapes a holder revocation.
`GET /credential/issuer/sessions` gains a `?holderDidKey=` filter backed by
the same index. The did:key is checked against `did:key:z<base58>` before it
becomes a Vault path segment; the key type is not decoded.
@bwmx
bwmx force-pushed the feat/revoke-by-did-key branch from e0177e7 to 14c1c5b Compare October 1, 2026 12:46
@bwmx bwmx changed the title feat(oid4vc): address credential status by holder did:key or status entry feat(oid4vc): revoke and reactivate by holder did:key Oct 1, 2026
@bwmx
bwmx removed this pull request from stack #42 October 1, 2026 15:10
@bwmx
bwmx changed the base branch from feat/credentials-status-and-revoke to master October 2, 2026 12:41
@bwmx
bwmx marked this pull request as draft October 2, 2026 15:06
@georgeartem

Copy link
Copy Markdown

Have a fix based on CoPilot recommendations locally but need push access.

@bwmx

bwmx commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

Have a fix based on CoPilot recommendations locally but need push access.

Can you create a PR or is that not possible? I don't have relevant permissions RN. If you fork I could even look there in the meantime.

We're just finalising this one currently so any fixes are great!

@georgeartem

georgeartem commented Oct 5, 2026 via email

Copy link
Copy Markdown

@PhearZero

Copy link
Copy Markdown
Collaborator

A couple things to note after looking at this:

  1. Sessions created before this branch are not in the index, we need to backfill previous credentials
  2. Rollback on failures since we are writing to multiple places at once, the transaction must be atomic (all or none)

We also have a few places that can fixed up that are less critical:

  1. The block on unredeemed offers only works for SD-JWT. I don't think they are used heavily but the JwtVcJson, JwtVcJsonLd and LdpVc branches never check statusChange or even that a local session exists
  2. Each blocked redemption attempt burns a status-list slot. Fix: check the block before allocating, as well as during the session update.
  3. What the endpoints return no longer reflects what was changed. Sessions that were blocked but had issued nothing return nothing, so a holder with only open offers gets [] even though the change worked. Callers counting the result will under-report.

Revoking or reactivating by holderDidKey changed one session at a time, so
a failure part-way left some sessions changed and the rest untouched. Each
holder-wide change is now one transaction, coordinated through a holder
record at intermezzo/oid4vc/holders/records/<multibase>:

- The holder record is written first. It blocks redemption for every offer
  the holder has, including offers created later, and records the change so
  that a re-sent request resumes it.
- Each session then records the change and what it replaced. A failure at
  this step restores everything; no status bit has moved yet.
- Commit is a compare-and-set on the holder record, after which each status
  list flips all of the holder's bits in one write. A failure or crash from
  there leaves the change pending, still blocking issuance, and re-sending
  the request finishes it. A committed change is never aborted.

Concurrent requests for the same holder drive the same change without
undoing or overwriting each other's work, and a request that finds the
change already finished undoes any late claims instead of completing them.

Holder-wide changes leave changes made by sessionId alone. A credential
revoked by sessionId keeps its own reason and time, and stays revoked when
the holder is reactivated. An unfinished sessionId change blocks a
holder-wide change (409), and a sessionId change waits only while an
unfinished, committed holder change owns its session.

Sessions created before the holder index existed are indexed once per
Vault, by a backfill that starts at boot; holder lookups wait for it.

Redemption is now refused before a status entry is allocated when the
session or its holder is revoked or changing status, so a blocked
redemption no longer uses up an entry. W3C credentials, which carry no
status claim, get the same check: it is their only control. createOffer
deletes its local writes if saving the session fails.
@bwmx
bwmx marked this pull request as ready for review October 26, 2026 14:47
@PhearZero
PhearZero self-requested a review October 26, 2026 16:53

@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

@PhearZero
PhearZero merged commit a4e9f7c into master Oct 26, 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.

4 participants