Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions auth/client/iam/openid4vp.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,17 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/nuts-foundation/nuts-node/http/client"
"github.com/nuts-foundation/nuts-node/vcr/credential"
"github.com/nuts-foundation/nuts-node/vdr/didsubject"
"github.com/piprate/json-gold/ld"
"maps"
"net/http"
"net/url"
"slices"
"time"

"github.com/nuts-foundation/nuts-node/http/client"
"github.com/nuts-foundation/nuts-node/vcr/credential"
"github.com/nuts-foundation/nuts-node/vdr/didsubject"
"github.com/piprate/json-gold/ld"

"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/auth/log"
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ require (
github.com/nats-io/nats-server/v2 v2.11.15
github.com/nats-io/nats.go v1.49.0
github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b
github.com/nuts-foundation/go-did v0.18.0
github.com/nuts-foundation/go-did v0.18.1
github.com/nuts-foundation/go-leia/v4 v4.3.0
github.com/nuts-foundation/go-stoabs v1.11.0
github.com/nuts-foundation/sqlite v1.0.0
Expand Down
8 changes: 8 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op h1:kpBdlEPbRvff0m
github.com/antithesishq/antithesis-sdk-go v0.6.0-default-no-op/go.mod h1:IUpT2DPAKh6i/YhSbt6Gl3v2yvUZjmKncl7U91fup7E=
github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ=
github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk=
github.com/avast/retry-go/v4 v4.6.1 h1:VkOLRubHdisGrHnTu89g08aQEWEgRU7LVEop3GbIcMk=
github.com/avast/retry-go/v4 v4.6.1/go.mod h1:V6oF8njAwxJ5gRo1Q7Cxab24xs5NCWZBeaHHBklR8mA=
github.com/avast/retry-go/v4 v4.7.0 h1:yjDs35SlGvKwRNSykujfjdMxMhMQQM0TnIjJaHB+Zio=
github.com/avast/retry-go/v4 v4.7.0/go.mod h1:ZMPDa3sY2bKgpLtap9JRUgk2yTAba7cgiFhqxY2Sg6Q=
github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k=
Expand Down Expand Up @@ -144,6 +146,8 @@ github.com/edsrzf/mmap-go v1.1.0 h1:6EUwBLQ/Mcr1EYLE4Tn1VdW1A4ckqCQWZBw8Hr0kjpQ=
github.com/edsrzf/mmap-go v1.1.0/go.mod h1:19H/e8pUPLicwkyNgOykDXkJ9F0MHE+Z52B8EIth78Q=
github.com/eknkc/basex v1.0.1 h1:TcyAkqh4oJXgV3WYyL4KEfCMk9W8oJCpmx1bo+jVgKY=
github.com/eknkc/basex v1.0.1/go.mod h1:k/F/exNEHFdbs3ZHuasoP2E7zeWwZblG84Y7Z59vQRo=
github.com/eko/gocache/lib/v4 v4.2.0 h1:MNykyi5Xw+5Wu3+PUrvtOCaKSZM1nUSVftbzmeC7Yuw=
github.com/eko/gocache/lib/v4 v4.2.0/go.mod h1:7ViVmbU+CzDHzRpmB4SXKyyzyuJ8A3UW3/cszpcqB4M=
github.com/eko/gocache/lib/v4 v4.2.3 h1:s78TFqEGAH3SbzP4N40D755JYT/aaGFKEPrsUtC1chU=
github.com/eko/gocache/lib/v4 v4.2.3/go.mod h1:Zus8mwmaPu1VYOzfomb+Dvx2wV7fT5jDRbHYtQM6MEY=
github.com/eko/gocache/store/go_cache/v4 v4.2.2 h1:tAI9nl6TLoJyKG1ujF0CS0n/IgTEMl+NivxtR5R3/hw=
Expand Down Expand Up @@ -404,6 +408,8 @@ github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b h1:80
github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b/go.mod h1:6YUioYirD6/8IahZkoS4Ypc8xbeJW76Xdk1QKcziNTM=
github.com/nuts-foundation/go-did v0.18.0 h1:IB0X8PrzDulpR1zAgDpaHfwoSjJpIhx5u1Tg8I2nnb8=
github.com/nuts-foundation/go-did v0.18.0/go.mod h1:4od1gAmCi9HjHTQGEvHC8pLeuXdXACxidAcdA52YScc=
github.com/nuts-foundation/go-did v0.18.1 h1:oMEF42ckWcOG3bD0KGu1RAKByQx4vryPJxl6L9zJyQA=
github.com/nuts-foundation/go-did v0.18.1/go.mod h1:4od1gAmCi9HjHTQGEvHC8pLeuXdXACxidAcdA52YScc=
github.com/nuts-foundation/go-leia/v4 v4.3.0 h1:R0qGISIeg2q/PCQTC9cuoBtA6cFu4WBV2DbmSOWKZyM=
github.com/nuts-foundation/go-leia/v4 v4.3.0/go.mod h1:Gw6bXqJLOAmHSiXJJYbVoj+Mowp/PoBRywO0ZPsVzA0=
github.com/nuts-foundation/go-stoabs v1.11.0 h1:q18jVruPdFcVhodDrnKuhq/24i0pUC/YXgzJS0glKUU=
Expand Down Expand Up @@ -779,6 +785,8 @@ gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXD
gorm.io/driver/sqlserver v1.6.3 h1:UR+nWCuphPnq7UxnL57PSrlYjuvs+sf1N59GgFX7uAI=
gorm.io/driver/sqlserver v1.6.3/go.mod h1:VZeNn7hqX1aXoN5TPAFGWvxWG90xtA8erGn2gQmpc6U=
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.30.2 h1:f7bevlVoVe4Byu3pmbWPVHnPsLoWaMjEb7/clyr9Ivs=
gorm.io/gorm v1.30.2/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
Expand Down
56 changes: 15 additions & 41 deletions vcr/holder/presenter.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"

"github.com/google/uuid"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/lestrrat-go/jwx/v2/jwt"
ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
Expand All @@ -37,8 +38,6 @@ import (
"github.com/nuts-foundation/nuts-node/vcr/signature/proof"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
"github.com/piprate/json-gold/ld"
"strings"
"time"
)

type presenter struct {
Expand Down Expand Up @@ -123,43 +122,18 @@ func (p presenter) buildPresentation(ctx context.Context, signerDID *did.DID, cr

// buildJWTPresentation builds a JWT presentation according to https://www.w3.org/TR/vc-data-model/#json-web-token
func (p presenter) buildJWTPresentation(ctx context.Context, subjectDID did.DID, credentials []vc.VerifiableCredential, options PresentationOptions, keyID string) (*vc.VerifiablePresentation, error) {
headers := map[string]interface{}{
jws.TypeKey: "JWT",
}
id := did.DIDURL{DID: subjectDID}
id.Fragment = strings.ToLower(uuid.NewString())
claims := map[string]interface{}{
jwt.SubjectKey: subjectDID.String(),
jwt.JwtIDKey: id.String(),
"vp": vc.VerifiablePresentation{
Context: append([]ssi.URI{VerifiableCredentialLDContextV1}, options.AdditionalContexts...),
Type: append([]ssi.URI{VerifiablePresentationLDType}, options.AdditionalTypes...),
Holder: options.Holder,
VerifiableCredential: credentials,
},
}
if options.ProofOptions.Nonce != nil {
claims["nonce"] = *options.ProofOptions.Nonce
}
if options.ProofOptions.Domain != nil {
claims[jwt.AudienceKey] = *options.ProofOptions.Domain
}
if options.ProofOptions.Created.IsZero() {
claims[jwt.NotBeforeKey] = time.Now().Unix()
} else {
claims[jwt.NotBeforeKey] = int(options.ProofOptions.Created.Unix())
}
if options.ProofOptions.Expires != nil {
claims[jwt.ExpirationKey] = int(options.ProofOptions.Expires.Unix())
}
for claimName, value := range options.ProofOptions.AdditionalProperties {
claims[claimName] = value
}
token, err := p.signer.SignJWT(ctx, claims, headers, keyID)
if err != nil {
return nil, fmt.Errorf("unable to sign JWT presentation: %w", err)
}
return vc.ParseVerifiablePresentation(token)
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,
Comment on lines +125 to +133
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.
}, func(ctx context.Context, claims map[string]interface{}, headers map[string]interface{}) (string, error) {
return p.signer.SignJWT(ctx, claims, headers, keyID)
})
}

func (p presenter) buildJSONLDPresentation(ctx context.Context, subjectDID did.DID, credentials []vc.VerifiableCredential, options PresentationOptions, keyID string) (*vc.VerifiablePresentation, error) {
Expand Down
33 changes: 30 additions & 3 deletions vcr/holder/presenter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ package holder

import (
"context"
"testing"
"time"

ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
Expand All @@ -39,8 +42,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"testing"
"time"
)

func TestPresenter_buildPresentation(t *testing.T) {
Expand Down Expand Up @@ -142,7 +143,7 @@ func TestPresenter_buildPresentation(t *testing.T) {
})
})
t.Run("JWT", func(t *testing.T) {
options := PresentationOptions{Format: JWTPresentationFormat}
options := PresentationOptions{Format: JWTPresentationFormat, ProofOptions: proof.ProofOptions{Created: time.Now()}}
t.Run("ok - one VC", func(t *testing.T) {
ctrl := gomock.NewController(t)

Expand All @@ -162,7 +163,33 @@ func TestPresenter_buildPresentation(t *testing.T) {
assert.NotNil(t, result.JWT())
nonce, _ := result.JWT().Get("nonce")
assert.Empty(t, nonce)

t.Run("#3957: Verifiable Presentation type is marshalled incorrectly in JWT format", func(t *testing.T) {
t.Run("make sure the fix is backwards compatible", func(t *testing.T) {
vp := vc.VerifiablePresentation{
Type: []ssi.URI{ssi.MustParseURI("VerifiablePresentation")},
}
t.Run("sanity check: regular JSON marshalling yields type: string", func(t *testing.T) {
data, err := vp.MarshalJSON()
require.NoError(t, err)
assert.Contains(t, string(data), `"type":"VerifiablePresentation"`)
})
})
vpAsMap := result.JWT().PrivateClaims()["vp"].(map[string]any)
t.Run("make sure type now marshals as array", func(t *testing.T) {
typeProp := vpAsMap["type"].([]any)
Comment on lines +178 to +180
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.
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.
t.Run("make sure the VP can be unmarshalled", func(t *testing.T) {
presentation, err := vc.ParseVerifiablePresentation(result.Raw())
require.NoError(t, err)
assert.Equal(t, result.ID.String(), presentation.ID.String())
assert.Len(t, presentation.Type, 1)
assert.Equal(t, "VerifiablePresentation", presentation.Type[0].String())
})
})
})

t.Run("ok - multiple VCs", func(t *testing.T) {
ctrl := gomock.NewController(t)

Expand Down
15 changes: 10 additions & 5 deletions vcr/pe/presentation_submission.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,14 @@ import (
"encoding/json"
"errors"
"fmt"
"strings"

"github.com/PaesslerAG/jsonpath"
"github.com/google/uuid"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/vcr/credential"
v2 "github.com/nuts-foundation/nuts-node/vcr/pe/schema/v2"
"strings"
)

// ParsePresentationSubmission validates the given JSON and parses it into a PresentationSubmission.
Expand Down Expand Up @@ -133,11 +134,15 @@ func (b *PresentationSubmissionBuilder) Build(format string) (PresentationSubmis
}

// the verifiableCredential property in Verifiable Presentations can be a single VC or an array of VCs when represented in JSON.
// go-did always marshals a single VC as a single VC for JSON-LD VPs. So we might need to fix the mapping paths.

// todo the check below actually depends on the format of the credential and not the format of the VP
// go-did always marshals a single VC as a single VC for JSON-LD VPs. So we need to fix the mapping paths.
if len(signInstruction.Mappings) == 1 {
signInstruction.Mappings[0].Path = "$.verifiableCredential"
if format == vc.JWTPresentationProofFormat {
// JWT VP always has an array of VCs
signInstruction.Mappings[0].Path = "$.verifiableCredential[0]"
} else {
// JSON-LD VP with single VC has single VC in verifiableCredential
signInstruction.Mappings[0].Path = "$.verifiableCredential"
}
}

// Just 1 VP, no nesting needed
Expand Down
7 changes: 4 additions & 3 deletions vcr/pe/presentation_submission_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,16 @@ package pe

import (
"encoding/json"
"testing"
"time"

ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/vcr/pe/test"
"github.com/nuts-foundation/nuts-node/vcr/signature/proof"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"testing"
"time"
)

func TestParsePresentationSubmission(t *testing.T) {
Expand Down Expand Up @@ -189,7 +190,7 @@ func TestPresentationSubmissionBuilder_Build(t *testing.T) {
assert.Len(t, signInstruction.VerifiableCredentials, 1)
assert.Equal(t, holder1, signInstruction.Holder)
require.Len(t, submission.DescriptorMap, 1)
assert.Equal(t, "$.verifiableCredential", submission.DescriptorMap[0].Path)
assert.Equal(t, "$.verifiableCredential[0]", submission.DescriptorMap[0].Path)
})
})
}
Expand Down
Loading