From 96c8c8532d39cbab771b2c336f8937b4360e9830 Mon Sep 17 00:00:00 2001 From: Rein Krul Date: Wed, 27 Sep 2023 12:43:33 +0200 Subject: [PATCH] IAM: Handle /token requests with vp_token bearer grant type --- auth/api/iam/api.go | 58 +++++++-- auth/api/iam/api_test.go | 26 +++- auth/api/iam/openid4vp_test.go | 4 +- auth/api/iam/s2s_vptoken.go | 162 ++++++++++++++++++++----- auth/api/iam/s2s_vptoken_test.go | 167 +++++++++++++++++++++++++ cmd/root.go | 2 +- vcr/pe/matcher_test.go | 67 ++--------- vcr/pe/submission.go | 55 +++++++++ vcr/pe/submission_test.go | 201 +++++++++++++++++++++++++++++++ vcr/pe/test.go | 55 +++++++++ vcr/test/test.go | 24 ++++ 11 files changed, 711 insertions(+), 110 deletions(-) create mode 100644 vcr/pe/submission.go create mode 100644 vcr/pe/submission_test.go create mode 100644 vcr/pe/test.go create mode 100644 vcr/test/test.go diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 109a21089c..c4fd61a5f1 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -28,6 +28,7 @@ import ( "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/auth/log" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vdr" @@ -48,25 +49,27 @@ var assets embed.FS // Wrapper handles OAuth2 flows. type Wrapper struct { - vcr vcr.VCR - vdr vdr.VDR - auth auth.AuthenticationServices - sessions *SessionManager - templates *template.Template + vcr vcr.VCR + vdr vdr.VDR + auth auth.AuthenticationServices + privateKeyStore crypto.KeyStore + sessions *SessionManager + templates *template.Template } -func New(authInstance auth.AuthenticationServices, vcrInstance vcr.VCR, vdrInstance vdr.VDR) *Wrapper { +func New(authInstance auth.AuthenticationServices, vcrInstance vcr.VCR, vdrInstance vdr.VDR, privateKeyStore crypto.KeyStore) *Wrapper { templates := template.New("oauth2 templates") _, err := templates.ParseFS(assets, "assets/*.html") if err != nil { panic(err) } return &Wrapper{ - sessions: &SessionManager{sessions: new(sync.Map)}, - auth: authInstance, - vcr: vcrInstance, - vdr: vdrInstance, - templates: templates, + sessions: &SessionManager{sessions: new(sync.Map)}, + auth: authInstance, + vcr: vcrInstance, + vdr: vdrInstance, + privateKeyStore: privateKeyStore, + templates: templates, } } @@ -113,6 +116,11 @@ func (r Wrapper) Routes(router core.EchoRouter) { // HandleTokenRequest handles calls to the token endpoint for exchanging a grant (e.g authorization code or pre-authorized code) for an access token. func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequestRequestObject) (HandleTokenRequestResponseObject, error) { + ownDID, err := r.idToOwnedDID(ctx, request.Id) + if err != nil { + return nil, err + } + switch request.Body.GrantType { case "authorization_code": // Options: @@ -124,6 +132,9 @@ func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequ case "urn:ietf:params:oauth:grant-type:pre-authorized_code": // Options: // - OpenID4VCI + case "vp_token-bearer": + // Nuts RFC021 vp_token bearer flow + return r.handleS2SAccessTokenRequest(ctx, *ownDID, request.Body.AdditionalProperties) default: // TODO: Don't use openid4vci package for errors return nil, openid4vci.Error{ @@ -146,7 +157,10 @@ func (r Wrapper) HandleTokenRequest(ctx context.Context, request HandleTokenRequ // HandleAuthorizeRequest handles calls to the authorization endpoint for starting an authorization code flow. func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAuthorizeRequestRequestObject) (HandleAuthorizeRequestResponseObject, error) { - ownDID := idToDID(request.Id) + ownDID, err := r.idToOwnedDID(ctx, request.Id) + if err != nil { + return nil, err + } // Create session object to be passed to handler // Workaround: deepmap codegen doesn't support dynamic query parameters. @@ -156,7 +170,7 @@ func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAutho for key, value := range httpRequest.URL.Query() { params[key] = value[0] } - session := createSession(params, ownDID) + session := createSession(params, *ownDID) if session.RedirectURI == "" { // TODO: Spec says that the redirect URI is optional, but it's not clear what to do if it's not provided. // Threat models say it's unsafe to omit redirect_uri. @@ -253,6 +267,24 @@ func (r Wrapper) OAuthClientMetadata(ctx context.Context, request OAuthClientMet return OAuthClientMetadata200JSONResponse(clientMetadata(*identity)), nil } +func (r Wrapper) idToOwnedDID(ctx context.Context, id string) (*did.DID, error) { + ownDID := idToDID(id) + owned, err := r.vdr.IsOwner(ctx, ownDID) + if err != nil { + if resolver.IsFunctionalResolveError(err) { + // TODO: OAuth2 error 404 (waiting for https://github.com/nuts-foundation/nuts-node/pull/2515) + return nil, core.NotFoundError("DID unresolvable: %w", err) + } + // TODO: OAuth2 error 500 (waiting for https://github.com/nuts-foundation/nuts-node/pull/2515) + return nil, core.Error(500, "DID resolution failed: %w", err) + } + if !owned { + // TODO: OAuth2 error 404 (waiting for https://github.com/nuts-foundation/nuts-node/pull/2515) + return nil, core.NotFoundError("DID not owned") + } + return &ownDID, nil +} + func createSession(params map[string]string, ownDID did.DID) *Session { session := &Session{ // TODO: Validate client ID diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 3b2d25b524..08910a1772 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -25,6 +25,10 @@ import ( "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/vcr" + "github.com/nuts-foundation/nuts-node/vcr/pe" + "github.com/nuts-foundation/nuts-node/vcr/verifier" "github.com/nuts-foundation/nuts-node/vdr" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/assert" @@ -169,15 +173,28 @@ type testCtx struct { client *Wrapper authnServices *auth.MockAuthenticationServices vdr *vdr.MockVDR + keyStore *crypto.MockKeyStore resolver *resolver.MockDIDResolver + verifier *verifier.MockVerifier + vcr *vcr.MockVCR } func newTestClient(t testing.TB) *testCtx { publicURL, err := url.Parse("https://example.com") require.NoError(t, err) ctrl := gomock.NewController(t) + keyStore := crypto.NewMockKeyStore(ctrl) + mockVerifier := verifier.NewMockVerifier(ctrl) + mockVCR := vcr.NewMockVCR(ctrl) + mockVCR.EXPECT().Verifier().Return(mockVerifier).AnyTimes() + authnServices := auth.NewMockAuthenticationServices(ctrl) authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes() + presentationDefinitionResolver := pe.DefinitionResolver{} + err = presentationDefinitionResolver.LoadFromFile("../../../vcr/pe/test/definition_mapping.json") + require.NoError(t, err) + authnServices.EXPECT().PresentationDefinitions().Return(&presentationDefinitionResolver).AnyTimes() + resolver := resolver.NewMockDIDResolver(ctrl) vdr := vdr.NewMockVDR(ctrl) vdr.EXPECT().Resolver().Return(resolver).AnyTimes() @@ -185,9 +202,14 @@ func newTestClient(t testing.TB) *testCtx { authnServices: authnServices, resolver: resolver, vdr: vdr, + keyStore: keyStore, + verifier: mockVerifier, + vcr: mockVCR, client: &Wrapper{ - auth: authnServices, - vdr: vdr, + auth: authnServices, + vdr: vdr, + vcr: mockVCR, + privateKeyStore: keyStore, }, } } diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go index effc889e6d..1503719048 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -42,7 +42,7 @@ var holderDID = did.MustParseDID("did:web:example.com:holder") var issuerDID = did.MustParseDID("did:web:example.com:issuer") func TestWrapper_sendPresentationRequest(t *testing.T) { - instance := New(nil, nil, nil) + instance := New(nil, nil, nil, nil) redirectURI, _ := url.Parse("https://example.com/redirect") verifierID, _ := url.Parse("https://example.com/verifier") @@ -101,7 +101,7 @@ func TestWrapper_handlePresentationRequest(t *testing.T) { mockAuth.EXPECT().PresentationDefinitions().Return(peStore) mockWallet.EXPECT().List(gomock.Any(), holderDID).Return(walletCredentials, nil) mockVDR.EXPECT().IsOwner(gomock.Any(), holderDID).Return(true, nil) - instance := New(mockAuth, mockVCR, mockVDR) + instance := New(mockAuth, mockVCR, mockVDR, nil) params := map[string]string{ "scope": "eOverdracht-overdrachtsbericht", diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/s2s_vptoken.go index 65fa221404..a3124561ad 100644 --- a/auth/api/iam/s2s_vptoken.go +++ b/auth/api/iam/s2s_vptoken.go @@ -19,49 +19,147 @@ package iam import ( + "bytes" "context" + "encoding/json" "errors" - "github.com/labstack/echo/v4" + "fmt" + "github.com/lestrrat-go/jwx/jws" + "github.com/lestrrat-go/jwx/jwt" "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/resolver" - "net/http" + "net/url" + "strings" + "time" ) -// serviceToService adds support for service-to-service OAuth2 flows, -// which uses a custom vp_token grant to authenticate calls to the token endpoint. -// Clients first call the presentation definition endpoint to get a presentation definition for the desired scope, -// then create a presentation submission given the definition which is posted to the token endpoint as vp_token. -// The AS then returns an access token with the requested scope. -// Requires: -// - GET /presentation_definition?scope=... (returns a presentation definition) -// - POST /token (with vp_token grant) -type serviceToService struct { -} +// accessTokenValidity defines how long access tokens are valid. +// TODO: Do we need to make this configurable? +const accessTokenValidity = 15 * time.Minute -func (s serviceToService) Routes(router core.EchoRouter) { - router.Add("GET", "/public/oauth2/:did/presentation_definition", func(echoCtx echo.Context) error { - // TODO: Read scope, map to presentation definition, return - return echoCtx.JSON(http.StatusOK, map[string]string{}) - }) -} +// scopeJWTClaimKey defines the JWT claim name for the OAuth2 scope in created tokens. +const scopeJWTClaimKey = "scope" + +// scopeCredentialsClaimKey defines the JWT claim name for the Verifiable Credentials that were presented by the holder +// when the token was created. +const scopeCredentialsClaimKey = "vcs" + +// handleS2SAccessTokenRequest handles the /token request with vp_token bearer grant type, intended for service-to-service exchanges. +// It performs cheap checks first (parameter presence and validity), then the more expensive ones (checking signatures and matching VCs to the presentation definition). +func (s Wrapper) handleS2SAccessTokenRequest(ctx context.Context, issuer did.DID, params map[string]string) (HandleTokenRequestResponseObject, error) { + submissionEncoded := params["presentation_submission"] + scope := params[scopeParam] + assertionEncoded := params["assertion"] + if submissionEncoded == "" || scope == "" || assertionEncoded == "" { + // TODO: OAuth2 error + return nil, errors.New("missing required parameters") + } + + // Unmarshal VP, which can be in URL-encoded JSON or JWT format + if strings.HasPrefix(assertionEncoded, "ey") { + // (VP in JWT format) + return nil, errors.New("TODO: VPs in JWT format not yet supported") + } + // (VP as URL-encoded JSON) + assertionDecoded, err := url.QueryUnescape(assertionEncoded) + if err != nil { + // TODO: OAuth2 error + return nil, fmt.Errorf("assertion parameter is invalid: %w", err) + } + var vp vc.VerifiablePresentation + err = json.Unmarshal([]byte(assertionDecoded), &vp) + if err != nil { + // TODO: OAuth2 error + return nil, fmt.Errorf("assertion parameter is invalid: %w", err) + } + + // Unmarshal presentation submission + var submission pe.PresentationSubmission + submissionDecoded, err := url.QueryUnescape(submissionEncoded) + if err != nil { + // TODO: OAuth2 error + return nil, fmt.Errorf("presentation_submission parameter is invalid: %w", err) + } + if err = json.Unmarshal([]byte(submissionDecoded), &submission); err != nil { + // TODO: OAuth2 error + return nil, fmt.Errorf("presentation_submission parameter is invalid: %w", err) + } + + // Check signatures and VC issuer trust + issueTime := time.Now() + _, err = s.vcr.Verifier().VerifyVP(vp, true, false, &issueTime) + if err != nil { + // TODO: OAuth2 error + return nil, fmt.Errorf("verifiable presentation verification failed: %w", err) + } + + // Validate the presentation submission: + // 1. Resolve presentation definition for the requested scope + // 2. Take VCs mapped by the presentation submission + // 3. Match the VCs from (2) to the definition from (1). This should yield the same list of credentials. + // This actually creates a new submission. + presentationDefinition := s.auth.PresentationDefinitions().ByScope(scope) + if presentationDefinition == nil { + return nil, fmt.Errorf("unsupported scope for presentation exchange: %s", scope) + } + submissionCredentials, err := submission.Credentials(vp) + if err != nil { + return nil, err + } + // Match the credentials from the presentation submission with the presentation definition we got through the scope + _, matchedCredentials, err := presentationDefinition.Match(submissionCredentials) + if err != nil { + return nil, fmt.Errorf("presentation_submission parameter is invalid: re-evaluation of the credentials against the presentation definition fails: %w", err) + } + submissionCredentialsJSON, _ := json.Marshal(submissionCredentials) + matchedCredentialsJSON, _ := json.Marshal(matchedCredentials) + if !bytes.Equal(submissionCredentialsJSON, matchedCredentialsJSON) { + return nil, errors.New("presentation_submission parameter is invalid: re-evaluation of the credentials against the presentation definition yields different credentials (bug or attempted hack)") + } -func (s serviceToService) validateVPToken(params map[string]string) (string, error) { - submission := params["presentation_submission"] - scope := params["scope"] - vp_token := params["vp_token"] - if submission == "" || scope == "" || vp_token == "" { - // TODO: right error response - return "", errors.New("missing required parameters") - } - // TODO: https://github.com/nuts-foundation/nuts-node/issues/2418 - // TODO: verify parameters - return scope, nil + // TODO: Check that the returned credentials fulfill the presentation definition of the scope. + // TODO: Only include credentials from the presentation submission + response, err := s.createAccessToken(ctx, issuer, issueTime, matchedCredentials, scope) + if err != nil { + return nil, fmt.Errorf("creating access token: %w", err) + } + return HandleTokenRequest200JSONResponse(*response), nil } -func (s serviceToService) handleAuthzRequest(_ map[string]string, _ *Session) (*authzResponse, error) { - // Protocol does not support authorization code flow - return nil, nil +func (s Wrapper) createAccessToken(ctx context.Context, issuer did.DID, issueTime time.Time, credentials []vc.VerifiableCredential, scope string) (*TokenResponse, error) { + // Sign with the private key of the issuer + keyResolver := resolver.DIDKeyResolver{Resolver: s.vdr.Resolver()} + signingKeyID, _, err := keyResolver.ResolveKey(issuer, &issueTime, resolver.NutsSigningKeyType) + if err != nil { + return nil, fmt.Errorf("resolve issuer signing key: %w", err) + } + // TODO: The JWT becomes quite large due to inclusion of the complete VCs; + // do we need to have an opaque token with backend storage of tokens? + claims := map[string]interface{}{ + jwt.IssuedAtKey: issueTime.Unix(), + jwt.IssuerKey: issuer.String(), + jwt.ExpirationKey: issueTime.Add(accessTokenValidity).Unix(), + jwt.NotBeforeKey: issueTime.Unix(), + scopeJWTClaimKey: scope, + scopeCredentialsClaimKey: credentials, + } + headers := map[string]interface{}{ + jws.KeyIDKey: signingKeyID.String(), + } + token, err := s.privateKeyStore.SignJWT(ctx, claims, headers, signingKeyID.String()) + if err != nil { + return nil, fmt.Errorf("sign token: %w", err) + } + expiredIn := int(accessTokenValidity.Seconds()) + return &TokenResponse{ + AccessToken: token, + ExpiresIn: &expiredIn, + Scope: &scope, + TokenType: "bearer", + }, nil } func (r Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTokenRequestObject) (RequestAccessTokenResponseObject, error) { diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index e438585179..f3a810106e 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -19,8 +19,21 @@ package iam import ( + "context" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + ssi "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/audit" + "github.com/nuts-foundation/nuts-node/vcr/pe" + "github.com/nuts-foundation/nuts-node/vcr/test" "github.com/nuts-foundation/nuts-node/vdr/resolver" + "go.uber.org/mock/gomock" + "net/url" "testing" + "time" "github.com/nuts-foundation/go-did/did" "github.com/stretchr/testify/assert" @@ -86,3 +99,157 @@ func TestWrapper_RequestAccessToken(t *testing.T) { assert.EqualError(t, err, "verifier not found: unable to find the DID document") }) } + +func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { + issuerDID := did.MustParseDID("did:test:123") + const requestedScope = "eOverdracht-overdrachtsbericht" + const expectedAccessToken = "access-token" + // Create issuer DID document and keys + keyPair, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + issuerDIDDocument := did.Document{ + ID: issuerDID, + } + keyID := issuerDID + keyID.Fragment = "1" + verificationMethod, err := did.NewVerificationMethod(keyID, ssi.JsonWebKey2020, issuerDID, keyPair.Public()) + require.NoError(t, err) + issuerDIDDocument.AddAssertionMethod(verificationMethod) + + // Create VC, VP and Presentation Definition and Submission + var presentationDefinition pe.PresentationDefinition + _ = json.Unmarshal([]byte(pe.TestNutsOrganizationPresentationDefinition), &presentationDefinition) + var presentationSubmission pe.PresentationSubmission + _ = json.Unmarshal([]byte(` +{ + "descriptor_map": [ + { + "id": "1", + "path": "$.verifiableCredential[0]", + "format": "ldp_vc" + } + ] +}`), &presentationSubmission) + presentationSubmissionJSON, _ := json.Marshal(presentationSubmission) + verifiableCredential := test.NutsOrganizationCredential() + verifiablePresentation := vc.VerifiablePresentation{ + VerifiableCredential: []vc.VerifiableCredential{verifiableCredential}, + } + verifiablePresentationJSON, _ := verifiablePresentation.MarshalJSON() + + t.Run("ok", func(t *testing.T) { + ctx := newTestClient(t) + ctx.verifier.EXPECT().VerifyVP(verifiablePresentation, true, false, gomock.Any()).Return(verifiablePresentation.VerifiableCredential, nil) + ctx.resolver.EXPECT().Resolve(issuerDID, gomock.Any()).Return(&issuerDIDDocument, &resolver.DocumentMetadata{}, nil) + var actualClaims map[string]interface{} + var actualHeaders map[string]interface{} + ctx.keyStore.EXPECT().SignJWT(gomock.Any(), gomock.Any(), gomock.Any(), keyID.String()). + DoAndReturn(func(_ context.Context, claims map[string]interface{}, headers map[string]interface{}, _ interface{}) (string, error) { + actualClaims = claims + actualHeaders = headers + return expectedAccessToken, nil + }) + + params := map[string]string{ + "assertion": url.QueryEscape(string(verifiablePresentationJSON)), + "presentation_submission": url.QueryEscape(string(presentationSubmissionJSON)), + "scope": requestedScope, + } + + resp, err := ctx.client.handleS2SAccessTokenRequest(audit.TestContext(), issuerDID, params) + + require.NoError(t, err) + require.IsType(t, HandleTokenRequest200JSONResponse{}, resp) + tokenResponse := TokenResponse(resp.(HandleTokenRequest200JSONResponse)) + assert.Equal(t, "bearer", tokenResponse.TokenType) + assert.Equal(t, requestedScope, *tokenResponse.Scope) + assert.Equal(t, int(accessTokenValidity.Seconds()), *tokenResponse.ExpiresIn) + assert.Equal(t, expectedAccessToken, tokenResponse.AccessToken) + assert.Equal(t, keyID.String(), actualHeaders["kid"]) + // Make sure token is not valid for too long + assert.GreaterOrEqual(t, time.Now().Add(accessTokenValidity).Add(time.Minute).Unix(), actualClaims["exp"]) + }) + t.Run("VP is in JWT format (not supported)", func(t *testing.T) { + ctx := newTestClient(t) + params := map[string]string{ + "assertion": url.QueryEscape("eyTHIS IS A MARKER FOR JWTs"), + "presentation_submission": url.QueryEscape(string(presentationSubmissionJSON)), + "scope": requestedScope, + } + + resp, err := ctx.client.handleS2SAccessTokenRequest(audit.TestContext(), issuerDID, params) + + assert.EqualError(t, err, "TODO: VPs in JWT format not yet supported") + assert.Nil(t, resp) + }) + t.Run("VP is not valid JSON", func(t *testing.T) { + ctx := newTestClient(t) + params := map[string]string{ + "assertion": url.QueryEscape("[true, false]"), + "presentation_submission": url.QueryEscape(string(presentationSubmissionJSON)), + "scope": requestedScope, + } + + resp, err := ctx.client.handleS2SAccessTokenRequest(audit.TestContext(), issuerDID, params) + + assert.EqualError(t, err, "assertion parameter is invalid: json: cannot unmarshal array into Go value of type map[string]interface {}") + assert.Nil(t, resp) + }) + t.Run("submission is not valid JSON", func(t *testing.T) { + ctx := newTestClient(t) + params := map[string]string{ + "assertion": url.QueryEscape(string(verifiablePresentationJSON)), + "presentation_submission": url.QueryEscape("not-a-valid-submission"), + "scope": requestedScope, + } + + resp, err := ctx.client.handleS2SAccessTokenRequest(audit.TestContext(), issuerDID, params) + + assert.EqualError(t, err, `presentation_submission parameter is invalid: invalid character 'o' in literal null (expecting 'u')`) + assert.Nil(t, resp) + }) + t.Run("unsupported scope", func(t *testing.T) { + ctx := newTestClient(t) + ctx.verifier.EXPECT().VerifyVP(verifiablePresentation, true, false, gomock.Any()).Return(verifiablePresentation.VerifiableCredential, nil) + params := map[string]string{ + "assertion": url.QueryEscape(string(verifiablePresentationJSON)), + "presentation_submission": url.QueryEscape(string(presentationSubmissionJSON)), + "scope": "everything", + } + + resp, err := ctx.client.handleS2SAccessTokenRequest(audit.TestContext(), issuerDID, params) + + assert.EqualError(t, err, `unsupported scope for presentation exchange: everything`) + assert.Nil(t, resp) + }) + t.Run("re-evaluation of presentation definition yields different credentials", func(t *testing.T) { + // This indicates the client presented credentials that don't actually match the presentation definition, + // which could indicate a malicious client. + otherVerifiableCredential := vc.VerifiableCredential{ + CredentialSubject: []interface{}{ + map[string]interface{}{ + // just for demonstration purposes, what matters is that the credential does not match the presentation definition. + "IsAdministrator": true, + }, + }, + } + verifiablePresentation := vc.VerifiablePresentation{ + VerifiableCredential: []vc.VerifiableCredential{otherVerifiableCredential}, + } + verifiablePresentationJSON, _ := verifiablePresentation.MarshalJSON() + + ctx := newTestClient(t) + ctx.verifier.EXPECT().VerifyVP(verifiablePresentation, true, false, gomock.Any()).Return(verifiablePresentation.VerifiableCredential, nil) + ctx.resolver.EXPECT().Resolve(issuerDID, gomock.Any()).Return(&issuerDIDDocument, &resolver.DocumentMetadata{}, nil) + + params := map[string]string{ + "assertion": url.QueryEscape(string(verifiablePresentationJSON)), + "presentation_submission": url.QueryEscape(string(presentationSubmissionJSON)), + "scope": requestedScope, + } + + resp, err := ctx.client.handleS2SAccessTokenRequest(audit.TestContext(), issuerDID, params) + + assert.EqualError(t, err, "presentation_submission parameter is invalid: re-evaluation of the credentials against the presentation definition yields different credentials (bug or attempted hack)") + assert.Nil(t, resp) + }) +} diff --git a/cmd/root.go b/cmd/root.go index a01eaaa9f6..c8e800ef3b 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -214,7 +214,7 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System { system.RegisterRoutes(statusEngine.(core.Routable)) system.RegisterRoutes(metricsEngine.(core.Routable)) system.RegisterRoutes(&authAPIv1.Wrapper{Auth: authInstance, CredentialResolver: credentialInstance}) - system.RegisterRoutes(authIAMAPI.New(authInstance, credentialInstance, vdrInstance)) + system.RegisterRoutes(authIAMAPI.New(authInstance, credentialInstance, vdrInstance, cryptoInstance)) system.RegisterRoutes(&authMeansAPI.Wrapper{Auth: authInstance}) system.RegisterRoutes(&didmanAPI.Wrapper{Didman: didmanInstance}) diff --git a/vcr/pe/matcher_test.go b/vcr/pe/matcher_test.go index 51fff6c36d..0e06c4d936 100644 --- a/vcr/pe/matcher_test.go +++ b/vcr/pe/matcher_test.go @@ -22,66 +22,13 @@ import ( "encoding/json" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/vcr/test" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "os" "testing" ) -const testPresentationDefinition = ` -{ - "id": "Definition requesting NutsOrganizationCredential", - "input_descriptors": [ - { - "id": "some random ID", - "name": "Organization matcher", - "purpose": "Finding any organization in CareTown starting with 'Care'", - "constraints": { - "fields": [ - { - "path": [ - "$.credentialSubject.organization.city" - ], - "filter": { - "type": "string", - "const": "IJbergen" - } - }, - { - "path": [ - "$.credentialSubject.organization.name" - ], - "filter": { - "type": "string", - "pattern": "care" - } - }, - { - "path": [ - "$.type" - ], - "filter": { - "type": "string", - "const": "NutsOrganizationCredential" - } - } - ] - } - } - ], - "format": { - "jwt_vc": { - "alg": ["ES256K", "ES384"] - }, - "ldp_vc": { - "proof_type": [ - "JsonWebSignature2020" - ] - } - } -} -` - var testCredentialString = ` { "type": "VerifiableCredential", @@ -92,21 +39,21 @@ var testCredentialString = ` func TestMatch(t *testing.T) { presentationDefinition := PresentationDefinition{} - _ = json.Unmarshal([]byte(testPresentationDefinition), &presentationDefinition) - verifiableCredential := vc.VerifiableCredential{} - vcJSON, _ := os.ReadFile("../test/vc.json") - _ = json.Unmarshal(vcJSON, &verifiableCredential) + _ = json.Unmarshal([]byte(TestNutsOrganizationPresentationDefinition), &presentationDefinition) t.Run("Happy flow", func(t *testing.T) { - presentationSubmission, vcs, err := presentationDefinition.Match([]vc.VerifiableCredential{verifiableCredential}) + presentationSubmission, vcs, err := presentationDefinition.Match([]vc.VerifiableCredential{test.NutsOrganizationCredential()}) require.NoError(t, err) assert.Len(t, vcs, 1) require.Len(t, presentationSubmission.DescriptorMap, 1) assert.Equal(t, "$.verifiableCredential[0]", presentationSubmission.DescriptorMap[0].Path) + + ps, _ := json.MarshalIndent(presentationSubmission, "", " ") + println(string(ps)) }) t.Run("Only second VC matches", func(t *testing.T) { - presentationSubmission, vcs, err := presentationDefinition.Match([]vc.VerifiableCredential{{Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential")}}, verifiableCredential}) + presentationSubmission, vcs, err := presentationDefinition.Match([]vc.VerifiableCredential{{Type: []ssi.URI{ssi.MustParseURI("VerifiableCredential")}}, test.NutsOrganizationCredential()}) require.NoError(t, err) assert.Len(t, vcs, 1) diff --git a/vcr/pe/submission.go b/vcr/pe/submission.go new file mode 100644 index 0000000000..44f9057f1d --- /dev/null +++ b/vcr/pe/submission.go @@ -0,0 +1,55 @@ +package pe + +import ( + "encoding/json" + "fmt" + "github.com/PaesslerAG/jsonpath" + "github.com/nuts-foundation/go-did/vc" +) + +// Credentials returns the credential from the Verifiable Presentation mapped by the submission's InputDescriptors. +// If one or more InputDescriptors can't be mapped to a Verifiable Credential, it returns an error. +// It will never return more credentials than mapped by the submission. +// It will return a unique set: if multiple InputDescriptors map to the same credential (have the same path), +// the credential will be included just once. +func (s PresentationSubmission) Credentials(presentation vc.VerifiablePresentation) ([]vc.VerifiableCredential, error) { + presentationJSON, err := presentation.MarshalJSON() + if err != nil { + return nil, err + } + var presentationAsMap map[string]interface{} + _ = json.Unmarshal(presentationJSON, &presentationAsMap) + // Handle compacted array of JSON-LD VP: presentation submission indexes VerifiablePresentation.credentialSubject, + // which only works if it's actually an array. + verifiableCredentials := presentationAsMap["verifiableCredential"] + if verifiableCredentials != nil { + _, isSlice := verifiableCredentials.([]interface{}) + if !isSlice { + presentationAsMap["verifiableCredential"] = []interface{}{verifiableCredentials} + } + } + + var credentials []vc.VerifiableCredential + var mappedCredentials = make(map[string]bool, len(s.DescriptorMap)) + for _, inputDescriptor := range s.DescriptorMap { + // Note: path_nested is not supported. Do we need it? + credentialAsInterf, err := jsonpath.Get(inputDescriptor.Path, presentationAsMap) + if err != nil { + return nil, fmt.Errorf("descriptor '%s' evaluation failed: %w", inputDescriptor.Id, err) + } + if credentialAsInterf == nil || err != nil { + return nil, fmt.Errorf("descriptor '%s' does not evaluate to a VC in the VP", inputDescriptor.Id) + } + credentialAsJSON, _ := json.Marshal(credentialAsInterf) // can only fail when credentialAsInterf is a func(), which is impossible + var credential vc.VerifiableCredential + if err = json.Unmarshal(credentialAsJSON, &credential); err != nil { + return nil, fmt.Errorf("descriptor '%s' evaluates to an invalid VC in the VP: %w", inputDescriptor.Id, err) + } + if mappedCredentials[inputDescriptor.Path] { + continue + } + mappedCredentials[inputDescriptor.Path] = true + credentials = append(credentials, credential) + } + return credentials, nil +} diff --git a/vcr/pe/submission_test.go b/vcr/pe/submission_test.go new file mode 100644 index 0000000000..fe8d8c36e6 --- /dev/null +++ b/vcr/pe/submission_test.go @@ -0,0 +1,201 @@ +/* + * 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" + ssi "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/go-did/vc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" +) + +func TestPresentationSubmission_Credentials(t *testing.T) { + credential1ID := ssi.MustParseURI("cred-1") + credential1 := vc.VerifiableCredential{ + ID: &credential1ID, + } + credential2ID := ssi.MustParseURI("cred-2") + credential2 := vc.VerifiableCredential{ + ID: &credential2ID, + } + + t.Run("1 input descriptor", func(t *testing.T) { + vp := vc.VerifiablePresentation{ + VerifiableCredential: []vc.VerifiableCredential{credential1}, + } + submissionJSON := ` +{ + "descriptor_map": [ + { + "id": "1", + "path": "$.verifiableCredential[0]", + "format": "ldp_vc" + } + ] +}` + var submission PresentationSubmission + _ = json.Unmarshal([]byte(submissionJSON), &submission) + + credentials, err := submission.Credentials(vp) + + require.NoError(t, err) + assert.Equal(t, []vc.VerifiableCredential{credential1}, credentials) + }) + t.Run("2 input descriptors", func(t *testing.T) { + vp := vc.VerifiablePresentation{ + VerifiableCredential: []vc.VerifiableCredential{credential1, credential2}, + } + submissionJSON := ` +{ + "descriptor_map": [ + { + "id": "1", + "path": "$.verifiableCredential[0]", + "format": "ldp_vc" + }, + { + "id": "2", + "path": "$.verifiableCredential[1]", + "format": "ldp_vc" + } + ] +}` + var submission PresentationSubmission + _ = json.Unmarshal([]byte(submissionJSON), &submission) + + credentials, err := submission.Credentials(vp) + + require.NoError(t, err) + assert.Equal(t, []vc.VerifiableCredential{credential1, credential2}, credentials) + }) + t.Run("input descriptor does not match", func(t *testing.T) { + vp := vc.VerifiablePresentation{ + VerifiableCredential: []vc.VerifiableCredential{credential1}, + } + submissionJSON := ` +{ + "descriptor_map": [ + { + "id": "1", + "path": "$.verifiableCredential[1]", + "format": "ldp_vc" + } + ] +}` + var submission PresentationSubmission + _ = json.Unmarshal([]byte(submissionJSON), &submission) + + credentials, err := submission.Credentials(vp) + + assert.EqualError(t, err, "descriptor '1' does not evaluate to a VC in the VP") + assert.Nil(t, credentials) + }) + t.Run("input descriptors with the same path", func(t *testing.T) { + vp := vc.VerifiablePresentation{ + VerifiableCredential: []vc.VerifiableCredential{credential1, credential2}, + } + submissionJSON := ` +{ + "descriptor_map": [ + { + "id": "1", + "path": "$.verifiableCredential[0]", + "format": "ldp_vc" + }, + { + "id": "2", + "path": "$.verifiableCredential[0]", + "format": "ldp_vc" + } + ] +}` + var submission PresentationSubmission + _ = json.Unmarshal([]byte(submissionJSON), &submission) + + credentials, err := submission.Credentials(vp) + + require.NoError(t, err) + assert.Equal(t, []vc.VerifiableCredential{credential1}, credentials) + }) + t.Run("no input descriptors", func(t *testing.T) { + vp := vc.VerifiablePresentation{ + VerifiableCredential: []vc.VerifiableCredential{credential1, credential2}, + } + submissionJSON := ` +{ + "descriptor_map": [] +}` + var submission PresentationSubmission + _ = json.Unmarshal([]byte(submissionJSON), &submission) + + credentials, err := submission.Credentials(vp) + + assert.NoError(t, err) + assert.Empty(t, credentials) + }) + t.Run("invalid input descriptor path (empty)", func(t *testing.T) { + vp := vc.VerifiablePresentation{ + VerifiableCredential: []vc.VerifiableCredential{credential1, credential2}, + } + submissionJSON := ` +{ + "descriptor_map": [ + { + "id": "1", + "path": "", + "format": "ldp_vc" + } + ] +}` + var submission PresentationSubmission + _ = json.Unmarshal([]byte(submissionJSON), &submission) + + credentials, err := submission.Credentials(vp) + + assert.EqualError(t, err, "descriptor '1' evaluation failed: parsing error: \t - 1:1 unexpected EOF while scanning extensions") + assert.Empty(t, credentials) + }) + t.Run("input descriptor does not point to a verifiable credential", func(t *testing.T) { + vp := vc.VerifiablePresentation{ + Proof: []interface{}{ + "test", + }, + VerifiableCredential: []vc.VerifiableCredential{credential1, credential2}, + } + submissionJSON := ` +{ + "descriptor_map": [ + { + "id": "1", + "path": "$.proof", + "format": "ldp_vc" + } + ] +}` + var submission PresentationSubmission + _ = json.Unmarshal([]byte(submissionJSON), &submission) + + credentials, err := submission.Credentials(vp) + + assert.EqualError(t, err, "descriptor '1' evaluates to an invalid VC in the VP: json: cannot unmarshal string into Go value of type map[string]interface {}") + assert.Empty(t, credentials) + }) +} diff --git a/vcr/pe/test.go b/vcr/pe/test.go new file mode 100644 index 0000000000..4ceae343c2 --- /dev/null +++ b/vcr/pe/test.go @@ -0,0 +1,55 @@ +package pe + +const TestNutsOrganizationPresentationDefinition = ` +{ + "id": "Definition requesting NutsOrganizationCredential", + "input_descriptors": [ + { + "id": "some random ID", + "name": "Organization matcher", + "purpose": "Finding any organization in CareTown starting with 'Care'", + "constraints": { + "fields": [ + { + "path": [ + "$.credentialSubject.organization.city" + ], + "filter": { + "type": "string", + "const": "IJbergen" + } + }, + { + "path": [ + "$.credentialSubject.organization.name" + ], + "filter": { + "type": "string", + "pattern": "care" + } + }, + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "const": "NutsOrganizationCredential" + } + } + ] + } + } + ], + "format": { + "jwt_vc": { + "alg": ["ES256K", "ES384"] + }, + "ldp_vc": { + "proof_type": [ + "JsonWebSignature2020" + ] + } + } +} +` diff --git a/vcr/test/test.go b/vcr/test/test.go new file mode 100644 index 0000000000..6f2596d8db --- /dev/null +++ b/vcr/test/test.go @@ -0,0 +1,24 @@ +package test + +import ( + "embed" + "encoding/json" + "github.com/nuts-foundation/go-did/vc" +) + +//go:embed vc.json +var testVC embed.FS + +// NutsOrganizationCredential returns a VerifiableCredential of type NutsOrganizationCredential for use in unit tests. +func NutsOrganizationCredential() vc.VerifiableCredential { + vcJSON, err := testVC.ReadFile("vc.json") + if err != nil { + panic(err) + } + verifiableCredential := vc.VerifiableCredential{} + err = json.Unmarshal(vcJSON, &verifiableCredential) + if err != nil { + panic(err) + } + return verifiableCredential +}