feat(client): tell the app when a credential's revocation status changes - #653
Conversation
|
Thanks for the PR! I'm on it — reviewing the new |
There was a problem hiding this comment.
Reviewed the diff, ran the repo's lint gates locally, and reproduced the one blocking finding. Requesting changes on that one; the rest are small.
The design reads right, and the write-up is honest about its trade-offs. This closes a real gap: the status sweep used to be storage-only, so an SD-JWT VC revocation never reached a foregrounded app at all. Coalescing to a single CredentialsChanged and deriving the idemix signal from the existing Revoked callback — rather than touching irmaclient — both look like the right calls, and the "only a change fires it" invariant is pinned from three angles.
Blocking — a status change can be committed and then never signalled. Inline at client/client.go:310. Reproduced deterministically at the service layer (cancellation landing after a writeback commits, which is what a slow later group or a mid-sweep shutdown does): changed=1, err=<nil>, ctx.Err()=context canceled, persisted=StatusInvalid, and the next sweep on a clean ctx returns changed=0. The revocation is in the database, the app is never told, and no later sweep will tell it. Worth noting err comes back nil, so the caller cannot see that the sweep was cut short either.
Two findings that have no line inside a diff hunk to anchor to:
client/client.go:274—SetLocale's doc comment still says the backfill "signalsClientHandler.UpdateAttributeson completion". That method is no longer onClientHandler; the backfill now firesCredentialsChanged(client.go:259).- The follow-up link in the PR description,
docs/plans/cancellable-background-jobs.md, 404s:docs/on this branch holds onlykeyshareFlow.puml, and there is nodocs/plans/directory. Either the file was left out of the commit or the link should point somewhere else.
Checked and clean: gofmt -d, go vet over the changed packages, go fix -diff (the modernizer gate), and misspell -error over the changed prose — all four steps of the lint job pass. Unit tests pass for client and eudi/..., including under -race. internal/sessiontest was not run here (no docker daemon available), so the two new integration tests are unverified from my side.
|
Reviewed this and posted a Changes requested review with 5 inline comments plus 2 body notes: #653 (review) The design is sound and this closes a real gap, but there's one blocking bug I reproduced deterministically rather than just flagging on read: in Smaller findings:
Verification-wise: |
|
/dobby fix your comments and check again until you consider it good enough. Then approve the PR. |
|
On it — I'll go through and address the outstanding review comments on this PR, then re-check my changes and iterate until things look solid. |
A change written back before ctx was cancelled was dropped instead of signalled: the writeback is committed, so the next sweep reads it back as a re-confirmation and the app is never told about the revocation at all. RefreshStatuses now signals on any change it recorded, whatever the ctx ended up as. The service half: a fetch aborted by cancellation is no longer treated as a fail-soft per-URI failure, so a cancellation landing in the last group returns the cancellation rather than nil. Also validates the now-required handler in client.New, so a nil one is a constructor error instead of a panic on a background goroutine with no recover, and fixes SetLocale's comment, which still named UpdateAttributes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CredentialsChanged is not fired by deletion or OpenID4VCI issuance, and the logo-backfill CHANGELOG entry still named UpdateAttributes, which this branch renames. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Approving. This clears my own changes-requested from the last cycle. I fixed the five findings on the branch rather than handing them back, in two commits, and replied on each thread.
91129fb is the blocking one. RefreshStatuses now signals on any change it recorded, whatever ctx ended up as. The writeback is already committed by the time the old guard dropped the callback, so the next sweep read the new status back as a re-confirmation and the app never heard about the revocation. TestRefreshStatusesNotifiesOnChangeCommittedBeforeCancellation drives that through a real Client, with a store that cancels the sweep's own ctx the moment a writeback commits; under the old guard it fails, expected 1, got 0. Its counterpart keeps the cancel-before-entry case, which stays silent because nothing was written back.
The service side went with it: a fetch aborted by cancellation is no longer a fail-soft per-URI skip, so cancellation landing in the last group's fetch returns the cancellation instead of nil. The same commit adds the nil-handler check in New and fixes SetLocale's comment, which still named UpdateAttributes.
a55d5b1 is documentation only. CredentialsChanged now lists what fires it, and says deletion and OpenID4VCI issuance are silent. The logo-backfill CHANGELOG entry names CredentialsChanged(), so the two ## Unreleased entries agree on the method that ships. The wiring is unchanged: an app does not need waking for a change it made itself.
Two things I did not change, both stated on their threads. The overlapping-sweep nit stays: a mutex would make a UI refresh wait behind a scheduled sweep on a client with no HTTP timeout, and moving change detection into the store would make this PR's LastKnownStatus column redundant. Both cost more than the symptom, which is one extra list re-read. And docs/plans/cancellable-background-jobs.md is still missing, so the link in the description 404s. That one is yours: the file is in no commit, and the write-up is your reading of five job families, not something for me to invent. Either add it or drop the link.
Verification at a55d5b1: all 18 checks green. The test job ran the two integration tests I could not run last cycle and both pass, the idemix one on all three databases (TestRevocation{Postgres,MySQL,SQLServer}/NotifiesApp) and the SD-JWT VC one against the real status-list agent. Locally, client and eudi/services pass under -race, and each of the three new tests fails when I disable the code it covers. gofmt, go vet, go fix -diff, ineffassign and misspell are clean. The keyshare and sessiontest failures I see locally are pre-existing: no postgres and no docker daemon in my environment, and keyshare/tasks fails the same way on master.
Merge timing is yours, and irmamobile still has to ship in lockstep for the client.ClientHandler change.
The entry said only issuance and the logo backfill previously woke the app, so a revocation went unnoticed. That holds for an SD-JWT VC, whose status sweep was storage-only, but not for idemix: client.New passed the app's own handler straight to NewIrmaClient, so irmaclient fired Revoked(cred) at the app directly (irma/irmaclient/revocation.go:197). It also contradicted this file's own Changed entry, which tells the app to fold what it did on Revoked into CredentialsChanged. Name both paths instead. The idemix contribution here is deduplicating a signal that already arrived, not adding a missing one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Approving. Every finding from my last cycle is closed on this HEAD, and all 16 checks are green.
I re-verified the four fixes against 85021d34 rather than taking the replies at face value:
RefreshStatuses(client/client.go:319) signals onchanged > 0with noctxguard left. Re-ranTestRefreshStatusesNotifiesOnChangeCommittedBeforeCancellation: thecancellingStorereally does cancel from inside a committed writeback, so the test drives the state the old code got wrong.Newrejects a nil handler before it touches storage, withTestNewRejectsNilHandleron it.ClientHandler.CredentialsChanged's doc now lists idemix issuance, a status move and a finished logo, and names deletion and OpenID4VCI issuance as silent. That matches what actually fires in-tree.- The
## Unreleasedlogo-backfill line namesCredentialsChanged(), so the two entries agree.SetLocale's doc comment came along with it, and thedocs/plans/...link is gone from the description.
The overlapping-sweep nit stays where the last round left it — declined, for a reason I agree with: a mutex around a sweep that fetches over a timeout-less common.HTTPClient trades one redundant re-read for a bridge goroutine that can block for as long as a socket stays open.
Checks I ran here: gofmt -d, go vet, go fix -diff ./... and misspell -error (all four lint steps) are clean, and client, eudi/... and internal/testhelpers unit tests pass. go test -c builds internal/sessiontest under its default !local_tests tag, so both new integration tests compile; the docker-backed runs are CI's, and CI's test plus both integration-test-* jobs are green on this sha.
Not a finding for this PR: go test -race ./irma/irmaclient/ fails on TestKeyshareChangePin — a package-level write in irma.NewHTTPTransport (irma/transport.go:79) racing the scheme auto-updater. It reproduces on master (d5a83aa5) in two runs out of three, CI does not pass -race, and nothing in this diff sits on either side of it. Pre-existing.
One non-blocking note inline.
|
Done — I re-verified all four fixes and the one declined item against HEAD Two things to flag:
PR: #653 |
What
For an SD-JWT VC, the wallet never told the app about a revocation. It wrote the
new status to the database and stopped there, so a credential revoked while the
app sat idle stayed rendered as valid until the user happened to do something that
re-read the list. For an idemix credential the signal did exist —
Revoked(cred)— but it repeated on every rediscovery.
This adds the missing signal, and takes the opportunity to give
clientits ownapp-facing callback interface instead of borrowing
irmaclient's.client.ClientHandler—client.Newnow takes this instead ofirmaclient.ClientHandler. It carries only what the app acts on:CredentialsChanged,ReportError, and the enrollment and change-pin results.An unexported
irmaHandleradapter implements theirmaclientsurface andforwards, so
IrmaClient's vocabulary stops leaking into the app.CredentialsChanged()— one signal for "the credentials you are showingare out of date", replacing
UpdateAttributesandRevoked(cred). It nowfires for revocations too, on both credential technologies.
Why one callback and not a revocation-specific one
The first cut had a separate
RevocationStatusUpdated(). It was collapsedbecause the app re-requests the whole credential list either way — a second
callback carried no information the app acted on differently.
Revoked(cred)'scredential identity is dropped for the same reason;
IrmaClientstill fires itinternally, so re-widening later is a one-line change in the adapter if a
per-credential notification is ever wanted.
The trade-off worth knowing: the app can no longer tell why its credentials
changed. Issuance, deletion, revocation, and a logo download are one signal.
Only a change fires it
A signal on every check would have the app re-reading its whole credential list
on a timer, forever. Both paths report transitions, not state:
ListInstancesWithStatusReferencenow also selectslast_known_status, so the sweep can compare.RefreshStatusesreturns(changed int, err error)— the number of batches whose status moved, sinceonly one representative per batch is ever checked — and
client.Clientdecidesthat
> 0is worth waking the app for. A re-confirmation, and a sweep whosefetches all failed, both count zero.
IrmaClientreports revocation as state: a revoked credential'switness never advances (gabi returns
ErrorRevokedbefore touching it), so itsstaleness only grows and the periodic witness-update job rediscovers the same
revocation every few tens of seconds. The adapter reports each credential once.
No irmaclient behaviour changes
irma/irmaclientis untouched apart from a counter on its test mock. The idemixsignal is derived entirely from the existing
Revoked(cred)callback, whichalready fires on exactly the right event — the adapter maps it and dedupes it.
Breaking — irmamobile must ship in lockstep
client.Newtakes aclient.ClientHandler. The app must:UpdateAttributes→CredentialsChanged;Revoked(cred)intoCredentialsChanged(it no longerreceives which credential);
UpdateConfiguration.Client.RefreshStatuses(ctx) errorkeeps its signature.Internal, no app impact:
services.RevocationService.RefreshStatusesreturns(changed int, err error), anddb.CredentialStatusInstancecarriesLastKnownStatus.Tests
client/status_change_notification_test.go— the wiring, against a realClientwith a seeded status-bearing batch and a real status server: a changewakes the app, a re-confirmation does not, a known revocation is not
re-reported, and a caller who cancels gets no callback. Plus a unit test that
the adapter reports each idemix revocation once.
internal/sessiontest/revocation_test.go—TestRevocation*/NotifiesApp,a real idemix revocation at a revocation-enabled server, driven through
client.Client. Needs the docker-compose postgres, like its 22 siblings.internal/sessiontest/openid4vci_statuslist_test.go— the SD-JWT counterpart,a real credential revoked at the status-list agent. In the opt-in status-list
group (needs the full docker stack), verified by hand.
eudi/services/credential_status_refresh_test.go—changedcounts: zero onre-confirmation, zero on failure, one per batch rather than per row.
Every notification path was mutation-checked — each assertion fails when the
code under it is disabled.
Follow-up
Client.Closecan still close storage while a background job is running againstit, because no wallet job can be cancelled. A
jobCtxfixing this for the statussweep alone was written and reverted — it buys an inconsistent contract for one of
five job families. Doing it properly means covering all five, and picking up two
existing TODOs (
InitJobs,IrmaClient.Close) and the fact thatcommon.HTTPClienthas no timeout along the way.