From 1190f1883b5854a6e9941f75fb0e4b610ba0eb08 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Fri, 24 Nov 2023 17:20:38 +0100 Subject: [PATCH] PEX: Provide ParseEnvelope to correctly parse PEX VP envelopes --- vcr/pe/presentation_definition.go | 13 ++- vcr/pe/presentation_submission.go | 20 +--- vcr/pe/presentation_submission_test.go | 48 ++++----- vcr/pe/util.go | 137 +++++++++++++++++++++++++ vcr/pe/util_test.go | 80 +++++++++++++++ vcr/test/test.go | 61 +++++++++++ 6 files changed, 307 insertions(+), 52 deletions(-) create mode 100644 vcr/pe/util.go create mode 100644 vcr/pe/util_test.go create mode 100644 vcr/test/test.go diff --git a/vcr/pe/presentation_definition.go b/vcr/pe/presentation_definition.go index 89ed2db436..19705eadbe 100644 --- a/vcr/pe/presentation_definition.go +++ b/vcr/pe/presentation_definition.go @@ -468,14 +468,17 @@ func vcEqual(a, b vc.VerifiableCredential) bool { return string(aJSON) == string(bJSON) } -func remarshalToMap(v interface{}) (map[string]interface{}, error) { - asJSON, err := json.Marshal(v) +func remarshal(src interface{}, dst interface{}) error { + asJSON, err := json.Marshal(src) if err != nil { - return nil, err + return err } + return json.Unmarshal(asJSON, &dst) +} + +func remarshalToMap(v interface{}) (map[string]interface{}, error) { var result map[string]interface{} - err = json.Unmarshal(asJSON, &result) - if err != nil { + if err := remarshal(v, &result); err != nil { return nil, err } return result, nil diff --git a/vcr/pe/presentation_submission.go b/vcr/pe/presentation_submission.go index 2b4fd863f6..3fa9a2aaf9 100644 --- a/vcr/pe/presentation_submission.go +++ b/vcr/pe/presentation_submission.go @@ -260,29 +260,15 @@ func resolveCredential(path []string, mapping InputDescriptorMappingObject, valu // The credentials will be returned as map with the InputDescriptor.Id as key. // The Presentation Definitions are passed in the envelope, as specified by the PEX specification. // It assumes credentials of the presentations only map in 1 way to the input descriptors. -func (s PresentationSubmission) Validate(envelope interface{}, definition PresentationDefinition) (map[string]vc.VerifiableCredential, error) { - actualCredentials, err := s.Resolve(envelope) +func (s PresentationSubmission) Validate(envelope Envelope, definition PresentationDefinition) (map[string]vc.VerifiableCredential, error) { + actualCredentials, err := s.Resolve(envelope.Interface) if err != nil { return nil, fmt.Errorf("resolve credentials from presentation submission: %w", err) } // Create a new presentation submission: the submission being validated should have the same input descriptor mapping. - // First, create a new submission - var presentations []vc.VerifiablePresentation - envelopeJSON, _ := json.Marshal(envelope) - if _, ok := envelope.([]interface{}); ok { - if err := json.Unmarshal(envelopeJSON, &presentations); err != nil { - return nil, fmt.Errorf("unable to unmarshal envelope: %w", err) - } - } else { - var presentation vc.VerifiablePresentation - if err := json.Unmarshal(envelopeJSON, &presentation); err != nil { - return nil, fmt.Errorf("unable to unmarshal envelope: %w", err) - } - presentations = []vc.VerifiablePresentation{presentation} - } submissionBuilder := definition.PresentationSubmissionBuilder() - for _, presentation := range presentations { + for _, presentation := range envelope.Presentations { signer, err := credential.PresentationSigner(presentation) if err != nil { return nil, fmt.Errorf("unable to derive presentation signer: %w", err) diff --git a/vcr/pe/presentation_submission_test.go b/vcr/pe/presentation_submission_test.go index 1f7116ce43..25d52d3872 100644 --- a/vcr/pe/presentation_submission_test.go +++ b/vcr/pe/presentation_submission_test.go @@ -212,7 +212,7 @@ func TestPresentationSubmission_Resolve(t *testing.T) { var submission PresentationSubmission require.NoError(t, json.Unmarshal([]byte(submissionJSON), &submission)) - credentials, err := submission.Resolve(remarshalToInterface(vp)) + credentials, err := submission.Resolve(toEnvelope(t, vp).Interface) require.NoError(t, err) assert.Len(t, credentials, 1) @@ -241,7 +241,7 @@ func TestPresentationSubmission_Resolve(t *testing.T) { var submission PresentationSubmission require.NoError(t, json.Unmarshal([]byte(submissionJSON), &submission)) - credentials, err := submission.Resolve(remarshalToInterface(vp)) + credentials, err := submission.Resolve(toEnvelope(t, vp).Interface) require.NoError(t, err) assert.Len(t, credentials, 2) @@ -284,7 +284,7 @@ func TestPresentationSubmission_Resolve(t *testing.T) { var submission PresentationSubmission require.NoError(t, json.Unmarshal([]byte(submissionJSON), &submission)) - credentials, err := submission.Resolve(remarshalToInterface([]vc.VerifiablePresentation{vp1, vp2})) + credentials, err := submission.Resolve(toEnvelope(t, []interface{}{vp1, vp2}).Interface) require.NoError(t, err) assert.Len(t, credentials, 2) @@ -309,7 +309,7 @@ func TestPresentationSubmission_Resolve(t *testing.T) { var submission PresentationSubmission require.NoError(t, json.Unmarshal([]byte(submissionJSON), &submission)) - credentials, err := submission.Resolve(remarshalToInterface(vp)) + credentials, err := submission.Resolve(toEnvelope(t, vp).Interface) require.EqualError(t, err, "unable to resolve credential for input descriptor '1': path '$.verifiableCredential' does not reference a credential") assert.Nil(t, credentials) @@ -332,7 +332,7 @@ func TestPresentationSubmission_Resolve(t *testing.T) { var submission PresentationSubmission require.NoError(t, json.Unmarshal([]byte(submissionJSON), &submission)) - credentials, err := submission.Resolve(remarshalToInterface(vp)) + credentials, err := submission.Resolve(toEnvelope(t, vp).Interface) require.ErrorContains(t, err, "unable to resolve credential for input descriptor '1': invalid JSON-LD credential at path") assert.Nil(t, credentials) @@ -355,7 +355,7 @@ func TestPresentationSubmission_Resolve(t *testing.T) { var submission PresentationSubmission require.NoError(t, json.Unmarshal([]byte(submissionJSON), &submission)) - credentials, err := submission.Resolve(remarshalToInterface(vp)) + credentials, err := submission.Resolve(toEnvelope(t, vp).Interface) require.ErrorContains(t, err, "unable to resolve credential for input descriptor '1': invalid JSON-LD presentation at path") assert.Nil(t, credentials) @@ -378,7 +378,7 @@ func TestPresentationSubmission_Resolve(t *testing.T) { var submission PresentationSubmission require.NoError(t, json.Unmarshal([]byte(submissionJSON), &submission)) - credentials, err := submission.Resolve(remarshalToInterface(vp)) + credentials, err := submission.Resolve(toEnvelope(t, vp).Interface) assert.EqualError(t, err, "unable to resolve credential for input descriptor '1': value of Go type 'string' at path '$.verifiableCredential.expirationDate' can't be decoded using format 'ldp_vc'") assert.Nil(t, credentials) @@ -426,7 +426,7 @@ func TestPresentationSubmission_Validate(t *testing.T) { }, } - credentials, err := submission.Validate(remarshalToInterface(vp), definition) + credentials, err := submission.Validate(toEnvelope(t, vp), definition) require.NoError(t, err) require.Len(t, credentials, 1) @@ -501,7 +501,7 @@ func TestPresentationSubmission_Validate(t *testing.T) { }, } - credentials, err := submission.Validate(remarshalToInterface([]vc.VerifiablePresentation{vp, secondVP}), definition) + credentials, err := submission.Validate(toEnvelope(t, []vc.VerifiablePresentation{vp, secondVP}), definition) require.NoError(t, err) require.Len(t, credentials, 2) @@ -529,7 +529,7 @@ func TestPresentationSubmission_Validate(t *testing.T) { }, } - credentials, err := PresentationSubmission{}.Validate(remarshalToInterface([]vc.VerifiablePresentation{vp}), definition) + credentials, err := PresentationSubmission{}.Validate(toEnvelope(t, []vc.VerifiablePresentation{vp}), definition) assert.EqualError(t, err, "presentation submission doesn't match presentation definition") assert.Empty(t, credentials) @@ -590,23 +590,11 @@ func TestPresentationSubmission_Validate(t *testing.T) { }, } - credentials, err := submission.Validate(remarshalToInterface(vp), definition) + credentials, err := submission.Validate(toEnvelope(t, vp), definition) assert.EqualError(t, err, "incorrect mapping for input descriptor: 2") assert.Empty(t, credentials) }) - t.Run("envelope contains an invalid presentation", func(t *testing.T) { - credentials, err := PresentationSubmission{}.Validate(map[string]interface{}{"id": true}, PresentationDefinition{}) - - assert.EqualError(t, err, "unable to unmarshal envelope: json: cannot unmarshal bool into Go struct field Alias.id of type string") - assert.Empty(t, credentials) - }) - t.Run("envelope contains an invalid presentation (envelope is array)", func(t *testing.T) { - credentials, err := PresentationSubmission{}.Validate([]interface{}{map[string]interface{}{"id": true}}, PresentationDefinition{}) - - assert.EqualError(t, err, "unable to unmarshal envelope: json: cannot unmarshal bool into Go struct field Alias.id of type string") - assert.Empty(t, credentials) - }) t.Run("submission contains mappings for non-existing input descriptors", func(t *testing.T) { definition := PresentationDefinition{ InputDescriptors: []*InputDescriptor{ @@ -651,23 +639,23 @@ func TestPresentationSubmission_Validate(t *testing.T) { }, } - credentials, err := submission.Validate(remarshalToInterface(vp), definition) + credentials, err := submission.Validate(toEnvelope(t, vp), definition) assert.EqualError(t, err, "expected 1 credentials, got 2") assert.Empty(t, credentials) }) t.Run("unable to derive presentation signer", func(t *testing.T) { vp = vc.VerifiablePresentation{} - credentials, err := PresentationSubmission{}.Validate(remarshalToInterface(vp), PresentationDefinition{}) + credentials, err := PresentationSubmission{}.Validate(toEnvelope(t, vp), PresentationDefinition{}) assert.EqualError(t, err, "unable to derive presentation signer: presentation should have exactly 1 proof, got 0") assert.Empty(t, credentials) }) } -func remarshalToInterface(input interface{}) interface{} { - bytes, _ := json.Marshal(input) - var result interface{} - _ = json.Unmarshal(bytes, &result) - return result +func toEnvelope(t *testing.T, presentations interface{}) Envelope { + vpBytes, _ := json.Marshal(presentations) + envelope, err := ParseEnvelope(vpBytes) + require.NoError(t, err) + return *envelope } diff --git a/vcr/pe/util.go b/vcr/pe/util.go new file mode 100644 index 0000000000..28c7dd5b5e --- /dev/null +++ b/vcr/pe/util.go @@ -0,0 +1,137 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package pe + +import ( + "encoding/json" + "fmt" + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/nuts-foundation/go-did/vc" +) + +type Envelope struct { + Interface interface{} + Presentations []vc.VerifiablePresentation +} + +// ParseEnvelope parses a Presentation Exchange envelope, which is a JSON type that encompasses zero or more Verifiable Presentations. +// It returns the envelope as interface{} for use in PresentationSubmission.Validate() and PresentationSubmission.Resolve(). +// It also returns the parsed Verifiable Presentations. +// Parsing is complicated since a Presentation Submission has multiple ways to reference a Verifiable Credential: +// - single VP as JSON object: $.verifiableCredential +// - multiple VPs as JSON array (with path_nested): $[0] -> $.verifiableCredential +// And since JWT VPs aren't JSON objects, we need to parse them separately. +func ParseEnvelope(envelopeBytes []byte) (*Envelope, error) { + jsonArray := tryParseJSONArray(envelopeBytes) + if jsonArray != nil { + // Array of Verifiable Presentations + asInterface, presentations, err := parseJSONArrayEnvelope(jsonArray) + if err != nil { + return nil, err + } + return &Envelope{ + Interface: asInterface, + Presentations: presentations, + }, nil + } + // Single Verifiable Presentation + asInterface, presentation, err := parseJSONObjectOrStringEnvelope(envelopeBytes) + if err != nil { + return nil, err + } + return &Envelope{ + Interface: asInterface, + Presentations: []vc.VerifiablePresentation{*presentation}, + }, nil +} + +// parseEnvelopeEntry parses a single Verifiable Presentation in a Presentation Exchange envelope. +// It takes into account custom unmarshalling required for JWT VPs. +func parseJSONArrayEnvelope(arr []interface{}) (interface{}, []vc.VerifiablePresentation, error) { + var presentations []vc.VerifiablePresentation + var asInterfaces []interface{} + for _, entry := range arr { + // Each entry can be a VP as JWT (string) or JSON (object) + var entryBytes []byte + switch entry.(type) { + case string: + // JWT + entryBytes = []byte(entry.(string)) + default: + var err error + entryBytes, err = json.Marshal(entry) + if err != nil { + return nil, nil, err + } + } + asInterface, presentation, err := parseJSONObjectOrStringEnvelope(entryBytes) + if err != nil { + return nil, nil, err + } + asInterfaces = append(asInterfaces, asInterface) + presentations = append(presentations, *presentation) + } + return asInterfaces, presentations, nil +} + +// parseJSONObjectOrStringEnvelope parses a single Verifiable Presentation in a Presentation Exchange envelope. +// It takes into account custom unmarshalling required for JWT VPs (since they're JSON strings, not objects). +func parseJSONObjectOrStringEnvelope(envelopeBytes []byte) (interface{}, *vc.VerifiablePresentation, error) { + presentation, err := vc.ParseVerifiablePresentation(string(envelopeBytes)) + if err != nil { + return nil, nil, fmt.Errorf("unable to parse PEX envelope as verifiable presentation: %w", err) + } + // TODO: This should be part of go-did library; we need to decode a JWT VP (and maybe later VC) and get the properties + // (in this case as map) without losing original cardinality. + // Part of https://github.com/nuts-foundation/go-did/issues/85 + if presentation.Format() == vc.JWTPresentationProofFormat { + token, err := jwt.Parse(envelopeBytes, jwt.WithVerify(false), jwt.WithValidate(false)) + if err != nil { + return nil, nil, fmt.Errorf("unable to parse PEX envelope as JWT verifiable presentation: %w", err) + } + asMap := make(map[string]interface{}) + // use the 'vp' claim as base Verifiable Presentation properties + innerVPAsMap, _ := token.PrivateClaims()["vp"].(map[string]interface{}) + for key, value := range innerVPAsMap { + asMap[key] = value + } + if jti, ok := token.Get(jwt.JwtIDKey); ok { + asMap["id"] = jti + } + return asMap, presentation, nil + } + // For other formats, we can just parse the JSON to get the interface{} for JSON Path to work on + var asMap interface{} + if err := json.Unmarshal(envelopeBytes, &asMap); err != nil { + // Can't actually fail? + return nil, nil, err + } + return asMap, presentation, nil +} + +// tryParseJSONArray tries to parse the given bytes as a JSON array. +// It returns the array as []interface{} if the bytes are a JSON array, or nil otherwise. +func tryParseJSONArray(bytes []byte) []interface{} { + var asInterface interface{} + if err := json.Unmarshal(bytes, &asInterface); err != nil { + return nil + } + arr, _ := asInterface.([]interface{}) + return arr +} diff --git a/vcr/pe/util_test.go b/vcr/pe/util_test.go new file mode 100644 index 0000000000..87bafef08c --- /dev/null +++ b/vcr/pe/util_test.go @@ -0,0 +1,80 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package pe + +import ( + "encoding/json" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/vcr/credential" + "github.com/nuts-foundation/nuts-node/vcr/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" +) + +func TestParseEnvelope(t *testing.T) { + t.Run("JWT", func(t *testing.T) { + presentation := test.CreateJWTPresentation(t, did.MustParseDID("did:example:1"), credential.ValidNutsOrganizationCredential(t)) + envelope, err := ParseEnvelope([]byte(presentation.Raw())) + require.NoError(t, err) + require.Equal(t, presentation.ID.String(), envelope.Interface.(map[string]interface{})["id"]) + require.Len(t, envelope.Presentations, 1) + }) + t.Run("invalid JWT", func(t *testing.T) { + envelope, err := ParseEnvelope([]byte(`eyINVALID`)) + assert.EqualError(t, err, "unable to parse PEX envelope as verifiable presentation: invalid JWT") + assert.Nil(t, envelope) + }) + t.Run("JSON object", func(t *testing.T) { + envelope, err := ParseEnvelope([]byte(`{"id": "value"}`)) + require.NoError(t, err) + require.Equal(t, map[string]interface{}{"id": "value"}, envelope.Interface) + require.Len(t, envelope.Presentations, 1) + }) + t.Run("invalid VP as JSON object", func(t *testing.T) { + envelope, err := ParseEnvelope([]byte(`{"id": true}`)) + assert.ErrorContains(t, err, "unable to parse PEX envelope as verifiable presentation") + assert.Nil(t, envelope) + }) + t.Run("JSON array with objects", func(t *testing.T) { + envelope, err := ParseEnvelope([]byte(`[{"id": "value"}]`)) + require.NoError(t, err) + require.Equal(t, []interface{}{map[string]interface{}{"id": "value"}}, envelope.Interface) + require.Len(t, envelope.Presentations, 1) + }) + t.Run("JSON array with JWTs", func(t *testing.T) { + presentation := test.CreateJWTPresentation(t, did.MustParseDID("did:example:1"), credential.ValidNutsOrganizationCredential(t)) + presentations := []string{presentation.Raw(), presentation.Raw()} + listJSON, _ := json.Marshal(presentations) + envelope, err := ParseEnvelope(listJSON) + require.NoError(t, err) + require.Len(t, envelope.Interface, 2) + require.Len(t, envelope.Presentations, 2) + }) + t.Run("invalid VPs list as JSON", func(t *testing.T) { + envelope, err := ParseEnvelope([]byte(`[{"id": true}]`)) + assert.ErrorContains(t, err, "unable to parse PEX envelope as verifiable presentation") + assert.Nil(t, envelope) + }) + t.Run("invalid format", func(t *testing.T) { + envelope, err := ParseEnvelope([]byte(`true`)) + assert.EqualError(t, err, "unable to parse PEX envelope as verifiable presentation: invalid JWT") + assert.Nil(t, envelope) + }) +} diff --git a/vcr/test/test.go b/vcr/test/test.go new file mode 100644 index 0000000000..9699576e45 --- /dev/null +++ b/vcr/test/test.go @@ -0,0 +1,61 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package test + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "github.com/google/uuid" + "github.com/lestrrat-go/jwx/v2/jwa" + "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" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +// CreateJWTPresentation creates a JWT presentation with the given subject DID and credentials. +func CreateJWTPresentation(t *testing.T, subjectDID did.DID, credentials ...vc.VerifiableCredential) vc.VerifiablePresentation { + headers := jws.NewHeaders() + require.NoError(t, headers.Set(jws.TypeKey, "JWT")) + claims := map[string]interface{}{ + jwt.IssuerKey: subjectDID.String(), + jwt.SubjectKey: subjectDID.String(), + jwt.JwtIDKey: subjectDID.String() + "#" + uuid.NewString(), + jwt.NotBeforeKey: time.Now().Unix(), + jwt.ExpirationKey: time.Now().Add(time.Hour).Unix(), + "vp": vc.VerifiablePresentation{ + Type: []ssi.URI{vc.VerifiablePresentationTypeV1URI()}, + VerifiableCredential: credentials, + }, + } + unsignedToken := jwt.New() + for k, v := range claims { + require.NoError(t, unsignedToken.Set(k, v)) + } + privateKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + token, _ := jwt.Sign(unsignedToken, jwt.WithKey(jwa.ES256, privateKey, jws.WithProtectedHeaders(headers))) + result, err := vc.ParseVerifiablePresentation(string(token)) + require.NoError(t, err) + return *result +}