Skip to content

Bug: fix VP JWT marshalling flattening single-entry arrays incorrectly#3958

Open
reinkrul wants to merge 22 commits intomasterfrom
iss3957-vp-jwt-type-marshalling
Open

Bug: fix VP JWT marshalling flattening single-entry arrays incorrectly#3958
reinkrul wants to merge 22 commits intomasterfrom
iss3957-vp-jwt-type-marshalling

Conversation

@reinkrul
Copy link
Member

@reinkrul reinkrul commented Dec 18, 2025

We used JSON-LD marshalling semantics for JWT VPs, which is incorrect; single-entry arrays shouldn't be flattened to singular values.

Found during LSPxNuts hackaton.

This applied to the following fields:

  • type
  • verifiableCredential
  • @context

Fixes #3957

TODO:

@qltysh
Copy link

qltysh bot commented Dec 18, 2025

Qlty

Coverage Impact

⬇️ Merging this pull request will decrease total coverage on master by 0.03%.

Modified Files with Diff Coverage (2)

RatingFile% DiffUncovered Line #s
Coverage rating: B Coverage rating: B
vcr/holder/presenter.go100.0%
Coverage rating: B Coverage rating: B
vcr/pe/presentation_submission.go100.0%
Total100.0%
🚦 See full report on Qlty Cloud »

🛟 Help
  • Diff Coverage: Coverage for added or modified lines of code (excludes deleted files). Learn more.

  • Total Coverage: Coverage for the whole repository, calculated as the sum of all File Coverage. Learn more.

  • File Coverage: Covered Lines divided by Covered Lines plus Missed Lines. (Excludes non-executable lines including blank lines and comments.)

    • Indirect Changes: Changes to File Coverage for files that were not modified in this PR. Learn more.

@reinkrul reinkrul marked this pull request as draft December 18, 2025 13:29
@reinkrul reinkrul marked this pull request as ready for review December 24, 2025 13:18
@reinkrul reinkrul changed the title Fix VP JWT type marshalling being a []string Fix VP JWT marshalling flattening single-entry arrays incorrectly Dec 24, 2025
@reinkrul reinkrul changed the title Fix VP JWT marshalling flattening single-entry arrays incorrectly Bug: fix VP JWT marshalling flattening single-entry arrays incorrectly Dec 24, 2025
reinkrul and others added 9 commits February 12, 2026 14:42
…ngraded dependencies

- Update go-did to v0.18.1 which makes ExpiresAt optional (*time.Time),
  restoring the old behaviour of omitting the exp claim when no expiry is set
- Restore avast/retry-go/v4 v4.7.0, gorm.io/gorm v1.31.1, and
  eko/gocache/lib/v4 v4.2.3 which were accidentally downgraded in a merge conflict

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@reinkrul reinkrul requested review from Copilot and removed request for confiks March 26, 2026 13:05
@reinkrul
Copy link
Member Author

Backwards Compatibility Report

1. JWT VP type field: string → array ✅ Compatible

Before (master): "type": "VerifiablePresentation" — a string, because vc.VerifiablePresentation.MarshalJSON() applies Unplural on the type field.

After: "type": ["VerifiablePresentation"] — an array, because CreateJWTVerifiablePresentation (go-did) builds the vp claim as a raw map[string]interface{} instead of going through MarshalJSON.

Why compatible: go-did's ParseVerifiablePresentation applies marshal.Plural(typeKey) during parsing, normalising both forms to []URI. Old nodes receiving new JWTs handle the array form transparently.


2. JWT VP verifiableCredential: single string → array ✅ Compatible

Before (master): "verifiableCredential": "jwt_vc_string"MarshalJSON applies Unplural for a single VC.

After: "verifiableCredential": ["jwt_vc_string"] — go-did stores credentials as a slice in the raw map, so it's always an array.

The presentation submission path is always generated by the same node that creates the VP, so old/new nodes stay self-consistent:

Holder Verifier Submission path VP verifiableCredential Resolution
new old $.verifiableCredential[0] array index extracts the string ✅
old new $.verifiableCredential string JSONPath returns the string directly ✅
new new $.verifiableCredential[0] array
old old $.verifiableCredential string ✅ (unchanged)

Additionally, ParseVerifiablePresentation applies Plural(verifiableCredentialKey) during parsing, so both wire forms are normalised correctly.


3. JWT VP expiry ✅ Compatible

ExpiresAt in go-did v0.18.1 is *time.Time (optional). options.ProofOptions.Expires is passed directly — when nil, no exp claim is emitted, identical to master's behaviour.


4. Dependencies ✅ Up to date

All three previously-downgraded dependencies have been restored:

Dependency Was (bad merge) Now
avast/retry-go/v4 v4.6.1 v4.7.0
gorm.io/gorm v1.30.2 v1.31.1
eko/gocache/lib/v4 v4.2.0 v4.2.3

Additionally, go-did was upgraded from v0.18.0 → v0.18.1 to fix the mandatory ExpiresAt constraint.


Overall verdict: the PR is backwards compatible.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes incorrect JWT Verifiable Presentation (VP) JSON marshalling semantics (single-entry arrays being flattened) by upgrading go-did and aligning Nuts-node VP creation/submission logic with JWT VP expectations.

Changes:

  • Bump github.com/nuts-foundation/go-did from v0.18.0 to v0.18.1 (fixing JWT VP marshalling semantics).
  • Switch JWT VP creation in presenter to go-did’s JWT VP creation helper and adjust PEX submission mapping path for JWT single-VC cases.
  • Add/extend tests for issue #3957 to validate JWT VP type is encoded as an array.

Reviewed changes

Copilot reviewed 6 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
vcr/pe/presentation_submission.go Adjusts single-VC mapping path depending on VP format (JWT uses array indexing).
vcr/pe/presentation_submission_test.go Updates expected JWT descriptor map path to $.verifiableCredential[0].
vcr/holder/presenter.go Replaces manual JWT VP claim construction/signing with vc.CreateJWTVerifiablePresentation.
vcr/holder/presenter_test.go Adds regression test for JWT VP type array marshalling; updates JWT test options.
auth/client/iam/openid4vp.go Import reordering only.
go.mod Updates github.com/nuts-foundation/go-did to v0.18.1.
go.sum Adds checksums for updated/affected dependencies.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +125 to +133
return vc.CreateJWTVerifiablePresentation(ctx, subjectDID.URI(), credentials, vc.PresentationOptions{
AdditionalContexts: options.AdditionalContexts,
AdditionalTypes: options.AdditionalTypes,
AdditionalProofProperties: options.ProofOptions.AdditionalProperties,
Holder: options.Holder,
Nonce: options.ProofOptions.Nonce,
Audience: options.ProofOptions.Domain,
IssuedAt: &options.ProofOptions.Created,
ExpiresAt: options.ProofOptions.Expires,
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

In JWT mode this always passes a non-nil IssuedAt pointer, even when options.ProofOptions.Created is the zero time. Previously, when Created was zero, the code defaulted the JWT time claim(s) to time.Now(); now a zero Created could produce an invalid/negative Unix timestamp (or an unexpectedly old value) in the JWT. Consider only setting IssuedAt when Created is non-zero (otherwise pass nil or set it to time.Now() first) to preserve the prior behavior and avoid generating invalid tokens.

Copilot uses AI. Check for mistakes.
Comment on lines +178 to +180
vpAsMap := result.JWT().PrivateClaims()["vp"].(map[string]any)
t.Run("make sure type now marshalls as array", func(t *testing.T) {
typeProp := vpAsMap["type"].([]any)
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

These direct type assertions (to map[string]any and []any) will panic if the JWT claims shape changes or if the regression reappears (e.g., type becomes a string again). Prefer using the comma-ok form or require.IsType/require.Contains to fail the test with a clear assertion message instead of a panic.

Suggested change
vpAsMap := result.JWT().PrivateClaims()["vp"].(map[string]any)
t.Run("make sure type now marshalls as array", func(t *testing.T) {
typeProp := vpAsMap["type"].([]any)
vpClaim, ok := result.JWT().PrivateClaims()["vp"]
require.True(t, ok, "vp claim must be present in JWT private claims")
vpAsMap, ok := vpClaim.(map[string]any)
require.True(t, ok, "vp claim must be a map[string]any, got %T", vpClaim)
t.Run("make sure type now marshalls as array", func(t *testing.T) {
typeVal, ok := vpAsMap["type"]
require.True(t, ok, "vp.type must be present")
typeProp, ok := typeVal.([]any)
require.True(t, ok, "vp.type must be a []any, got %T", typeVal)

Copilot uses AI. Check for mistakes.
t.Run("make sure type now marshalls as array", func(t *testing.T) {
typeProp := vpAsMap["type"].([]any)
assert.Equal(t, []any{"VerifiablePresentation"}, typeProp)
})
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

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

The PR description mentions the flattening bug also affected @context and verifiableCredential in JWT VPs, but this new regression test only asserts the type field. Consider extending this test to also assert that vp.@context and vp.verifiableCredential are encoded as arrays (even for a single entry), so the full intended fix is covered by automated tests.

Suggested change
})
})
t.Run("make sure @context now marshalls as array", func(t *testing.T) {
contextProp, ok := vpAsMap["@context"].([]any)
require.True(t, ok, "@context must be encoded as an array")
assert.GreaterOrEqual(t, len(contextProp), 1, "@context array must have at least one entry")
})
t.Run("make sure verifiableCredential now marshalls as array", func(t *testing.T) {
vcProp, ok := vpAsMap["verifiableCredential"].([]any)
require.True(t, ok, "verifiableCredential must be encoded as an array")
assert.Len(t, vcProp, 1, "single VC presentation must still use an array")
})

Copilot uses AI. Check for mistakes.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
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.

Verifiable Presentation type is marshalled incorrectly in JWT format

2 participants