Refactor SD-JWT implementation - #640
Conversation
Step 1 of docs/sdjwt-package-split-plan.md. Pure code motion within package sdjwtvc, no renames or behavior changes: split sdjwtvc.go, verifier_helpers.go, path_pointer.go, issuer.go and verify.go into the file layout the eventual eudi/sdjwt + eudi/credentials/sdjwtvc split will use (disclosure.go, serialization.go, claims.go, builder.go, processed.go, verify_disclosures.go, payload.go), so the actual package split in a later step is a mechanical file move. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… + VC fields Step 2 of docs/sdjwt-package-split-plan.md. RegisteredClaims now holds the registered JWT claims and SD-JWT-specific claims (_sd, _sd_alg, cnf); IssuerSignedJwtPayload embeds it and adds the VC-only vct/status fields. This is the change that makes the statuslist dependency structurally VC-only, ahead of the eudi/sdjwt package split. Field-access sites are unaffected by Go's embedded-field promotion; only composite literals needed updating (production verify.go, and tests in-package plus eudi/services/credential_service_test.go). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…uilder Step 3 of docs/sdjwt-package-split-plan.md. Builder implements only the SD-JWT-core rule (_sd_alg must be sha-256 when present, defaulting to sha-256) and takes a caller-supplied typ/x5c; it has no opinion on vct or the https:// iss rule. SdJwtVcBuilder wraps it and layers the VC policy: vct required, iss must be https:// when present, typ header fixed to dc+sd-jwt. NewSdJwtBuilder is renamed to NewSdJwtVcBuilder throughout (3 external call sites plus in-package tests) since it now names the VC-policy wrapper rather than the base builder. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…djwtvc Step 4 of docs/sdjwt-package-split-plan.md. Moves the SD-JWT-core production files (disclosure, serialization, claims, builder, kbjwt, presentation, processed payload, disclosure verification, crypto) into a new eudi/sdjwt package and applies the rename map: SdJwtVc -> SdJwt, SdJwtVcKb -> SdJwtKb, ProcessedSdJwtPayload -> ProcessedPayload, CreateSdJwtVc -> Create, splitSdJwtVc(Kb) -> Split/SplitKb (now exported), and the various parse/verify-disclosure helpers exported so sdjwtvc's verification pipeline can call them across the package boundary. sdjwtvc.SdJwtVc/SdJwtVcKb become defined types over sdjwt.SdJwt/SdJwtKb per the plan, rather than aliases, so the compiler flags any accidental mixing of a raw SD-JWT where a VC is expected. Test files split along the same seam: pure SD-JWT-core suites (disclosure/serialization/processed-payload/presentation/base-verify- disclosures) move to eudi/sdjwt, with presentation_test.go's and sdjwtvc_test.go's builder-based fixtures switched from the VC-only SdJwtVcBuilder to the generic Builder (dropping the vct claim they never needed). VC-specific suites (holder/verifier verification, payload-to-JSON, x5c/kid handling) stay in sdjwtvc. issuer_test.go and verify_test.go are split by test case along their existing "Base Processor" vs "Holder/Verifier verification" section boundaries. test_data_generators_test.go turns out to be entirely VC-side (builds and verifies full VCs) despite the plan listing it as a clean mover; it stays in sdjwtvc. Two production-code deviations from the plan, both to actually satisfy its own hard invariant rather than carve out an exception for it: - eudi/sdjwt no longer imports eudi/jwt or eudi/utils. Both transitively pull in eudi/scheme through unrelated X.509-certificate-extension code living in those same packages; the two things sdjwt actually used from them (a system clock, a generic optional-field lookup) are now tiny private helpers in kbjwt.go instead. - The dead, VC-shaped `CreateTestSdJwtVc()` (zero callers anywhere) moved to sdjwtvc/test_utils.go rather than eudi/sdjwt, since it builds through SdJwtVcBuilder and references vct. Both packages build, vet, and test clean in isolation, and `go list -deps ./eudi/sdjwt` contains none of sdjwtvc/statuslist/ eudi/scheme/typemetadata. The wider module does not build yet: the 32 external consumer files still reference the old sdjwtvc-qualified names for symbols that moved. That is step 5. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…sdjwtvc split
Step 5 of docs/sdjwt-package-split-plan.md. Mechanical follow-up to
step 4: every external file referencing a symbol that moved to
eudi/sdjwt now imports that package and calls it there instead of
through sdjwtvc. Symbols that stayed behind (SdJwtVc/SdJwtVcKb and
their defined-type conversions, VerifiedSdJwtVc, the verification
processors, StandardClaims, etc.) are untouched.
Several of the plan's "32 files" turned out to need zero changes on
inspection: irma/messages.go, irma/server/irmaserver/handle.go, and
irma/irmaclient/test_helpers.go only reference SdJwtVc/SdJwtVcKb
(unchanged), and eudi/credentials/statuslist/{context,verifier}.go
only mention "sdjwtvc" in doc comments, not code.
A few call sites needed an explicit SdJwt(...)/SdJwtVc(...) boundary
conversion where a value produced by sdjwtvc-side code (SdJwtVcBuilder,
storage) is passed into a now cross-package sdjwt function
(CreatePresentation, CreateKbJwt, ExtractHashingAlgorithmAndHolderPubKey) —
expected, since SdJwtVc/SdJwtVcKb are defined types over sdjwt.SdJwt/
SdJwtKb, not aliases. Several call sites also had a local variable
literally named `sdjwt`, shadowing the new package import; renamed to
avoid the collision rather than qualifying around it.
The whole module now builds and vets clean (`go build`/`go vet` on
every package except the two pre-existing, unrelated cgo failures:
irma/server/irmac and eudi/storage/db/sqlcipher), and every test suite
passes except the keyshare integration tests that require a local
postgres/SMTP server.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…djwttest Partial step 6 of docs/sdjwt-package-split-plan.md. eudi/sdjwt/crypto.go no longer imports irmago/testdata: NewDefaultEcdsaJwtCreatorWithHolderPrivateKey (dead code, zero callers) is dropped, and NewEcdsaJwtCreatorWithIssuerTestkey (2 external call sites, per the plan's rename map) moves to the new eudi/sdjwt/sdjwttest package as NewEcdsaJwtCreatorWithIssuerTestKey. Deferred: the plan also calls for splitting sdjwtvc/test_utils.go and test_data.go into a parallel eudi/credentials/sdjwtvc/sdjwtvctest package. Unlike the two functions moved here, that split ripples through every remaining sdjwtvc test file (issuer_test.go, verify_test.go, test_data_generators_test.go, payload_test.go all call its ~30 helpers throughout), for organizational benefit only — no dependency-boundary issue forces it the way testdata-in-production-code did here. Left as a follow-up. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Step 7 of docs/sdjwt-package-split-plan.md. Adds eudi/sdjwt/doc.go and eudi/credentials/sdjwtvc/doc.go: each names the draft it implements, states what it deliberately does not cover, and points at the neighbouring package for that. The sdjwtvc doc calls out the three named places where Token Status List support touches this package (IssuerSignedJwtPayload.Status, SdJwtVcVerificationContext.StatusChecker, sdJwtVcProcessor.runStatusListCheck) and the two follow-ups deferred per the plan's "Deferred" section. Also adds the cross-referencing comment on sdjwtvc.ClockSkewInSeconds pointing at statuslist.ClockSkewSeconds (decision #6) — the reverse comment already existed on the statuslist side. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Step 8 of docs/sdjwt-package-split-plan.md. TestDependencyInvariant shells out to `go list -deps` on eudi/sdjwt and fails if sdjwtvc, sdjwtvc/typemetadata, statuslist, or eudi/scheme ever appear in its dependency closure, directly or transitively. Shells out rather than adding golang.org/x/tools/go/packages, since that would be a new module dependency for a single boundary check. Verified against the real dependency closure (go list -deps shows only eudi/did, eudi/didjwk, eudi/didkey, and internal/crypto/hashing as irmago-internal deps of eudi/sdjwt) so the check is exercising real data, not vacuously passing on a typo'd import path. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Thanks for the PR! I'm on it — reviewing the changes against #639 and the repo's rules now, and I'll follow up with a consolidated review shortly. |
There was a problem hiding this comment.
Review: SD-JWT / SD-JWT VC package split
Nice piece of work — the seam is the right one, the commit-by-commit narration made this reviewable despite touching 61 files, and both doc.go files are genuinely useful. The TestDependencyInvariant idea is the best part: a structural boundary that CI keeps honest rather than a convention that erodes. Requesting changes only because that invariant is currently red.
Blocking (both are red in CI right now)
eudi/sdjwt/kbjwt.go:17— the PR's own invariant test fails.eudi/sdjwtstill importseudi/utils, which pulls ineudi/schemetransitively (go list -depsconfirms). This is the only test failure in the whole suite. The import buys one 7-line generic helper, and it contradictsdoc.go:27, which already claims the package avoidseudi/utils. Inlining the helper makesgo test ./eudi/... ./internal/crypto/...pass clean.CHANGELOG.md:13— thelintjob fails onmisspell."seperate" is a misspelling of "separate"(exit 123). Same line:## Internalis an h2, so it sits outside## Unreleasedand reads like a release named "Internal" — should be### Internalnested under Unreleased, matching what## [1.2.0]already does.
Non-blocking
eudi/sdjwt/invariant_test.go:40andeudi/credentials/sdjwtvc/doc.go:37both point atdocs/sdjwt-package-split-plan.md, which this PR added and then deleted again — so the invariant's own failure message sends a maintainer to a missing file.eudi/sdjwt/claims.go:25—RegisteredClaimNamesis new and has zero references.eudi/sdjwt/sdjwttest/sdjwttest.go:17— error swallowed, returns a nil interface.eudi/sdjwt/serialization_test.go:36— test named..._Failsassertsrequire.NoError.eudi/sdjwt/processed.go:23— pre-existing, moved verbatim, no action needed here: the determinism guarantee the doc comments assert does not actually hold (panics on nested objects and on JSONnull; the pointer-receiverMarshalJSONis bypassed at the one production call site). Worth a follow-up issue rather than scope creep in this PR.
Verified locally
gofmt -l clean · go vet ./eudi/... clean · go build ./... clean · the 32-consumer-file update in step 5 checks out · the ## Unreleased section carries no stale line describing anything this PR removes · .gitattributes already normalises line endings repo-wide. The rest of the diff really is code motion, and the pre-existing debt it relocates (ignored key.Set errors, dead ReadEcdsaPrivateKey, the jwt shadowing in crypto.go) is carried forward unchanged — not flagged as regressions.
|
Thanks for the PR! I reviewed it and posted a Request changes review with 7 inline comments. The package split itself looks like the right approach, and
I left a handful of smaller non-blocking notes too (a stale doc link in the invariant test's failure message, an unused |
|
/dobby merge master into this branch and fix all merge conflicts |
|
On it! I'll merge |
# Conflicts: # CHANGELOG.md # client/client.go # eudi/openid4vci/session.go # eudi/openid4vp/eudi_sdjwt_dcql/handler.go # eudi/openid4vp/irma_sdjwt_dcql/handler.go # eudi/services/credential_service.go
kbjwt.go still called utils.ExtractOptionalWith, and eudi/utils imports eudi/scheme through its X.509 helpers. That made TestDependencyInvariant fail: eudi/sdjwt transitively depended on eudi/scheme, which doc.go declares a hard invariant of the package. Inline a private extractOptionalWith, the same treatment kbjwt.go already gives systemClock for the same reason, and drop the utils import.
|
Merged Six files conflicted, and every conflict was the same collision: this branch renames
To check the resolution dropped nothing from either side, I compared the branch's contribution patch before and after the merge: MB=$(git merge-base origin/master 985ba7c3)
diff <(git diff $MB..985ba7c3 | grep -E '^[+-]') \
<(git diff origin/master...HEAD | grep -E '^[+-]')That reports 22 differing lines, and all 22 are the deliberate resolutions in the table. Nothing else of the branch's work or of master's changed. One extra commit, 59de386. Checked locally on the merge result: I replied to and resolved the two review threads this covers (the kbjwt.go import and the changelog spelling). The other five open threads are untouched, since none of them is a merge conflict. |
|
CI on 59de386: 17 checks pass, including The failing assertions are Three things place it outside this PR:
I cannot re-run it myself: this bot has no |
The doc comment on NewSdJwtVcDcqlHandler still told callers to pass sdjwtvc.NewDefaultKeyBinder(...), but the constructor moved to eudi/sdjwt in this PR and the parameter is now sdjwt.KeyBinder, so the documented call no longer compiles. The TestDependencyInvariant failure message and sdjwtvc/doc.go both point readers at docs/sdjwt-package-split-plan.md, which this PR added and then removed again; eudi/sdjwt/doc.go is now the only place that boundary is described. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Rules + review sign-off: master merge into sdjwt-sdjwtvc-split
Verdict: approve. No blocking issues. The merge is correct, CI is green apart from one pre-existing flake, and everything left is a non-blocking note.
The merge preserved both sides
Rather than read the conflict hunks one by one, I compared the branch's contribution before and after the merge:
MB=$(git merge-base origin/master 3d375731)
git diff $MB..3d375731 > /tmp/pre.diff
git diff origin/master...4880048c > /tmp/post.diff
diff <(grep -E '^[+-]' /tmp/pre.diff) <(grep -E '^[+-]' /tmp/post.diff)
Three differing spots, all of them the resolution doing the right thing:
CHANGELOG.md— the branch's own## Internalheading folded into master's existing### Internalsection, andseperatebecameseparate. That typo was failing thelintjob outright (misspell runs-errorover the whole tree,.mdincluded);lintis green now.buildAttributesWithValues(eudi/openid4vci/session.go:467) — has both master'slocale stringparameter and the branch'ssdjwt.ProcessedPayloadtype, and the body actually uses the locale (clientmodels.Resolve(..., locale)).NewIrmaSdJwtVcDcqlHandler— has bothkeyBinder sdjwt.KeyBinderand master'scurrentLocale, both wired to fields.
Zero other differing lines means master + (branch delta) == merged tree exactly, in every file, auto-merged ones included. Nothing was dropped from either side and nothing of master's got reverted.
The red test check is not yours
TestPostgresDBPinReservation fails at postgresdb_test.go:111 and :118 on run 30365621055, and keyshareserver is the only failing package. git diff master...HEAD --name-only | grep -i keyshare is empty — this PR touches no keyshare file. The test drives pin-backoff timing with real time.Sleep against a live Postgres, so it loses races on a loaded runner. Every other check is green, including lint (red on the previous head) and all three integration jobs.
Two dangling references fixed in 0b6fa387
Both were leftovers of this PR's own rename, both one-liners, so I pushed them rather than sending the PR back:
eudi/openid4vp/eudi_sdjwt_dcql/handler.go:78told callers to passsdjwtvc.NewDefaultKeyBinder(...), but the constructor moved toeudi/sdjwtin this PR and the parameter is nowsdjwt.KeyBinder— copying the documented call did not compile.eudi/sdjwt/invariant_test.goandeudi/credentials/sdjwtvc/doc.goboth pointed atdocs/sdjwt-package-split-plan.md, which this PR added in9f5d339and deleted again infabb407.eudi/sdjwt/doc.gois now the only place the boundary is described, so both now point there.
gofmt, go vet, go fix -diff and go test ./eudi/... are clean on that commit, TestDependencyInvariant included. No human review was on the PR, so nothing was dismissed by the push.
Rule sweep
Checked against the repo's Go and irmago-specific rules: no hand-written UnmarshalJSON in the new packages, no ==/!= on a struct with pointer fields, no logic moved into a testdata/ directory (sdjwttest correctly sits outside it and imports it), no misspellings, go fix modernizers clean, and no pr-title.yml workflow in this repo so the conventional-commit title rule does not apply. The long exact-error assertions in the eudi tests are this repo's established convention, not a finding.
What is left
Four inline notes, none blocking. Two are small cleanups in the new package; two are pre-existing on master that this PR only relocates — please read those as follow-up material, not as regressions from this diff.
|
Done — merged While in there I found and fixed two one-line dangling references left over from the branch's own rename (a doc comment pointing at a moved constructor, and two comments pointing at a deleted plan doc) — pushed as The red I posted an approve review with four non-blocking notes (two of them pre-existing on master, worth their own follow-up issues): #640 (review) PR is out of draft and ready for a maintainer: #640 |
|
/dobby perform one more review on the latest commits in the PR |
|
Got it — I'm on it! Kicking off another review pass on the latest commits pushed to this PR and will follow up with my findings shortly. |
There was a problem hiding this comment.
Second review pass on the latest commits (c308c59b "fix PR review issues" + the master merge 9953fef2). I checked out HEAD, reproduced each handed-forward finding, and ran the repo's own gates.
Three confirmed bugs, all in the reworked eudi/sdjwt/processed.go Sort(). The first one is the reason this is blocking rather than a note: it panics on every key-bound credential, and this commit is what activates it.
Sort()'s Map branch asserts v.(ProcessedPayload), but the verification pipeline leaves nested objects as plain map[string]any. Changing VerifiedSdJwtVc.ProcessedSdJwtPayload to a pointer (verify.go:27) means json.Marshal now resolves the pointer-receiver MarshalJSON, so Sort() runs on a path where it previously never did. Reproduced end-to-end through the production verify path:
payload["cnf"] = map[string]interface {}
panic: unexpected map type in ProcessedPayload: map[string]interface {}
I confirmed both halves separately. A value-typed field skips MarshalJSON (encoding/json cannot take the address of a value behind an interface); a pointer field calls it. So the panic was dormant on master and is live here — StoreCredentials now panics for any OpenID4VCI issuance of a credential with holder key binding.
The other two: a nil element inside a JSON array nil-derefs one level below the rt == nil guard this commit just added, and Sort() never descends into array elements, so the canonicalisation its doc comment promises does not hold for objects nested in arrays. Details and reproductions inline.
What is clean. gofmt, go vet ./eudi/... and go fix -diff ./eudi/... all pass, and go test ./eudi/... is green across 28 packages. The seperate typo in the CHANGELOG that was failing the lint job on the earlier pass is fixed, and lint is now green. The deleted eudi/utils/utils.go helpers and sdjwt.RegisteredClaimNames have zero remaining references, and eudi/utils itself still exists (set.go, x509.go), so that removal is clean.
One finding from the previous pass I am withdrawing. It reported that CI's staticcheck@2025.1.1 cannot load the module because go.mod declares go 1.26.0, so the lint job gives no real signal. That is a local artifact, not a CI one: go install builds staticcheck with whatever go is first on PATH, and this container's base Go is older than go.mod. CI runs actions/setup-go with go-version-file: go.mod before installing it, so staticcheck there is built with the pinned toolchain, and the lint check passes on this head. No action needed.
Also dropped: a rule-check flag on the CHANGELOG line and on the fix PR review issues commit message. The changelog line describes the PR accurately, and the commit-message shape matches the rest of this branch.
Remaining items below are style and nits, not blockers. The one worth a deliberate decision is that Sort() now mutates the payload in place and the sorted JSON is what gets persisted and later displayed, which is a behaviour change from master.
|
Done — posted a fresh review on the latest commits, requesting changes. The blocker: this commit's change of Two more bugs in the same function: a nil element inside a JSON array nil-derefs just below the new Good news elsewhere: |
Suggested changes mostly implemented
Fixes #639