diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index e0d002a280..90e14223c3 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -38,6 +38,7 @@ import ( "github.com/nuts-foundation/nuts-node/vdr/resolver" "html/template" "net/http" + "net/url" "strings" "time" ) @@ -49,6 +50,10 @@ const apiPath = "iam" const apiModuleName = auth.ModuleName + "/" + apiPath const httpRequestContextKey = "http-request" +// accessTokenValidity defines how long access tokens are valid. +// TODO: Might want to make this configurable at some point +const accessTokenValidity = 15 * time.Minute + //go:embed assets var assets embed.FS @@ -76,7 +81,7 @@ func New(authInstance auth.AuthenticationServices, vcrInstance vcr.VCR, vdrInsta } } -func (r Wrapper) Routes(router core.EchoRouter) { +func (r *Wrapper) Routes(router core.EchoRouter) { RegisterHandlers(router, NewStrictHandler(r, []StrictMiddlewareFunc{ func(f StrictHandlerFunc, operationID string) StrictHandlerFunc { return func(ctx echo.Context, request interface{}) (response interface{}, err error) { @@ -120,7 +125,11 @@ func (r Wrapper) middleware(ctx echo.Context, request interface{}, operationID s } // 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(_ context.Context, request HandleTokenRequestRequestObject) (HandleTokenRequestResponseObject, error) { +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: @@ -130,13 +139,6 @@ func (r Wrapper) HandleTokenRequest(_ context.Context, request HandleTokenReques Code: oauth.UnsupportedGrantType, Description: "not implemented yet", } - case "vp_token-bearer": - // Options: - // - service-to-service vp_token flow - return nil, oauth.OAuth2Error{ - Code: oauth.UnsupportedGrantType, - Description: "not implemented yet", - } case "urn:ietf:params:oauth:grant-type:pre-authorized_code": // Options: // - OpenID4VCI @@ -144,6 +146,15 @@ func (r Wrapper) HandleTokenRequest(_ context.Context, request HandleTokenReques Code: oauth.UnsupportedGrantType, Description: "not implemented yet", } + case "vp_token-bearer": + // Nuts RFC021 vp_token bearer flow + if request.Body.PresentationSubmission == nil || request.Body.Scope == nil || request.Body.Assertion == nil { + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "missing required parameters", + } + } + return r.handleS2SAccessTokenRequest(*ownDID, *request.Body.Scope, *request.Body.PresentationSubmission, *request.Body.Assertion) default: return nil, oauth.OAuth2Error{ Code: oauth.UnsupportedGrantType, @@ -161,7 +172,7 @@ func (r Wrapper) IntrospectAccessToken(ctx context.Context, request IntrospectAc } token := AccessToken{} - if err := r.s2sAccessTokenStore().Get(request.Body.Token, &token); err != nil { + if err := r.accessTokenStore().Get(request.Body.Token, &token); err != nil { // Return 200 + 'Active = false' when token is invalid or malformed return IntrospectAccessToken200JSONResponse{}, err } @@ -232,8 +243,10 @@ func toAnyMap(input any) (*map[string]any, error) { // HandleAuthorizeRequest handles calls to the authorization endpoint for starting an authorization code flow. func (r Wrapper) HandleAuthorizeRequest(ctx context.Context, request HandleAuthorizeRequestRequestObject) (HandleAuthorizeRequestResponseObject, error) { - // TODO: must be web DID once web DID creation and DB are implemented - ownDID := idToNutsDID(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. @@ -243,7 +256,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. @@ -303,7 +316,6 @@ func (r Wrapper) OAuthAuthorizationServerMetadata(ctx context.Context, request O func (r Wrapper) GetWebDID(_ context.Context, request GetWebDIDRequestObject) (GetWebDIDResponseObject, error) { ownDID := r.idToDID(request.Id) - document, err := r.vdr.ResolveManaged(ownDID) if err != nil { if resolver.IsFunctionalResolveError(err) { @@ -317,20 +329,12 @@ func (r Wrapper) GetWebDID(_ context.Context, request GetWebDIDRequestObject) (G // OAuthClientMetadata returns the OAuth2 Client metadata for the request.Id if it is managed by this node. func (r Wrapper) OAuthClientMetadata(ctx context.Context, request OAuthClientMetadataRequestObject) (OAuthClientMetadataResponseObject, error) { - // TODO: must be web DID once web DID creation and DB are implemented - ownDID := idToNutsDID(request.Id) - owned, err := r.vdr.IsOwner(ctx, ownDID) + _, err := r.idToOwnedDID(ctx, request.Id) if err != nil { - log.Logger().WithField("did", ownDID.String()).Errorf("oauth metadata: failed to assert ownership of did: %s", err.Error()) - return nil, core.Error(500, err.Error()) - } - if !owned { - return nil, core.NotFoundError("did not owned") + return nil, err } - identity := r.auth.PublicURL().JoinPath("iam", request.Id) - - return OAuthClientMetadata200JSONResponse(clientMetadata(*identity)), nil + return OAuthClientMetadata200JSONResponse(clientMetadata(*r.identityURL(request.Id))), nil } func (r Wrapper) PresentationDefinition(_ context.Context, request PresentationDefinitionRequestObject) (PresentationDefinitionResponseObject, error) { if len(request.Params.Scope) == 0 { @@ -350,6 +354,27 @@ func (r Wrapper) PresentationDefinition(_ context.Context, request PresentationD return PresentationDefinition200JSONResponse(*presentationDefinition), nil } +func (r Wrapper) idToOwnedDID(ctx context.Context, id string) (*did.DID, error) { + ownDID := r.idToDID(id) + owned, err := r.vdr.IsOwner(ctx, ownDID) + if err != nil { + if resolver.IsFunctionalResolveError(err) { + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "invalid issuer DID: " + err.Error(), + } + } + return nil, fmt.Errorf("DID resolution failed: %w", err) + } + if !owned { + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "issuer DID not owned by the server", + } + } + return &ownDID, nil +} + func createSession(params map[string]string, ownDID did.DID) *Session { session := &Session{ // TODO: Validate client ID @@ -365,17 +390,22 @@ func createSession(params map[string]string, ownDID did.DID) *Session { } return session } + +// idToDID converts the tenant-specific part of a did:web DID (e.g. 123) +// to a fully qualified did:web DID (e.g. did:web:example.com:123), using the configured Nuts node URL. func (r Wrapper) idToDID(id string) did.DID { - url := r.auth.PublicURL().JoinPath("iam", id) - did, _ := didweb.URLToDID(*url) - return *did + identityURL := r.identityURL(id) + result, _ := didweb.URLToDID(*identityURL) + return *result } -func idToNutsDID(id string) did.DID { - return did.DID{ - // should be changed to web when migrated to web DID - Method: "nuts", - ID: id, - DecodedID: id, - } +// identityURL the tenant-specific part of a did:web DID (e.g. 123) +// to an identity URL (e.g. did:web:example.com:123), which is used as base URL for resolving metadata and its did:web DID, +// using the configured Nuts node URL. +func (r Wrapper) identityURL(id string) *url.URL { + return r.auth.PublicURL().JoinPath("iam", id) +} + +func (r *Wrapper) accessTokenStore() storage.SessionStore { + return r.storageEngine.GetSessionDatabase().GetStore(accessTokenValidity, "accesstoken") } diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index 1c322b56fe..4055e479c2 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -37,7 +37,9 @@ import ( "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/jsonld" "github.com/nuts-foundation/nuts-node/storage" + "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" @@ -46,7 +48,6 @@ import ( "time" ) -var nutsDID = did.MustParseDID("did:nuts:123") var webDID = did.MustParseDID("did:web:example.com:iam:123") var webIDPart = "123" @@ -140,32 +141,13 @@ func TestWrapper_GetWebDID(t *testing.T) { func TestWrapper_GetOAuthClientMetadata(t *testing.T) { t.Run("ok", func(t *testing.T) { ctx := newTestClient(t) - ctx.vdr.EXPECT().IsOwner(nil, nutsDID).Return(true, nil) + ctx.vdr.EXPECT().IsOwner(nil, webDID).Return(true, nil) - res, err := ctx.client.OAuthClientMetadata(nil, OAuthClientMetadataRequestObject{Id: nutsDID.ID}) + res, err := ctx.client.OAuthClientMetadata(nil, OAuthClientMetadataRequestObject{Id: webIDPart}) require.NoError(t, err) assert.IsType(t, OAuthClientMetadata200JSONResponse{}, res) }) - t.Run("error - did not managed by this node", func(t *testing.T) { - ctx := newTestClient(t) - ctx.vdr.EXPECT().IsOwner(nil, nutsDID) - - res, err := ctx.client.OAuthClientMetadata(nil, OAuthClientMetadataRequestObject{Id: nutsDID.ID}) - - assert.Equal(t, 404, statusCodeFrom(err)) - assert.Nil(t, res) - }) - t.Run("error - internal error 500", func(t *testing.T) { - ctx := newTestClient(t) - ctx.vdr.EXPECT().IsOwner(nil, nutsDID).Return(false, errors.New("unknown error")) - - res, err := ctx.client.OAuthClientMetadata(nil, OAuthClientMetadataRequestObject{Id: nutsDID.ID}) - - assert.Equal(t, 500, statusCodeFrom(err)) - assert.EqualError(t, err, "unknown error") - assert.Nil(t, res) - }) } func TestWrapper_PresentationDefinition(t *testing.T) { webDID := did.MustParseDID("did:web:example.com:iam:123") @@ -175,7 +157,6 @@ func TestWrapper_PresentationDefinition(t *testing.T) { t.Run("ok", func(t *testing.T) { test := newTestClient(t) - test.authnServices.EXPECT().PresentationDefinitions().Return(&definitionResolver) response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{Did: webDID.ID, Params: PresentationDefinitionParams{Scope: "eOverdracht-overdrachtsbericht"}}) @@ -198,7 +179,6 @@ func TestWrapper_PresentationDefinition(t *testing.T) { t.Run("error - unknown scope", func(t *testing.T) { test := newTestClient(t) - test.authnServices.EXPECT().PresentationDefinitions().Return(&definitionResolver) response, err := test.client.PresentationDefinition(ctx, PresentationDefinitionRequestObject{Did: webDID.ID, Params: PresentationDefinitionParams{Scope: "unknown"}}) @@ -211,9 +191,10 @@ func TestWrapper_PresentationDefinition(t *testing.T) { func TestWrapper_HandleAuthorizeRequest(t *testing.T) { t.Run("missing redirect_uri", func(t *testing.T) { ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil) res, err := ctx.client.HandleAuthorizeRequest(requestContext(map[string]string{}), HandleAuthorizeRequestRequestObject{ - Id: nutsDID.String(), + Id: webIDPart, }) requireOAuthError(t, err, oauth.InvalidRequest, "redirect_uri is required") @@ -221,12 +202,13 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) { }) t.Run("unsupported response type", func(t *testing.T) { ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil) res, err := ctx.client.HandleAuthorizeRequest(requestContext(map[string]string{ "redirect_uri": "https://example.com", "response_type": "unsupported", }), HandleAuthorizeRequestRequestObject{ - Id: nutsDID.String(), + Id: webIDPart, }) requireOAuthError(t, err, oauth.UnsupportedResponseType, "") @@ -237,9 +219,10 @@ func TestWrapper_HandleAuthorizeRequest(t *testing.T) { func TestWrapper_HandleTokenRequest(t *testing.T) { t.Run("unsupported grant type", func(t *testing.T) { ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), webDID).Return(true, nil) res, err := ctx.client.HandleTokenRequest(nil, HandleTokenRequestRequestObject{ - Id: nutsDID.String(), + Id: webIDPart, Body: &HandleTokenRequestFormdataRequestBody{ GrantType: "unsupported", }, @@ -267,7 +250,7 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { }) t.Run("error - expired token", func(t *testing.T) { token := AccessToken{Expiration: time.Now().Add(-time.Second)} - require.NoError(t, ctx.client.s2sAccessTokenStore().Put("token", token)) + require.NoError(t, ctx.client.accessTokenStore().Put("token", token)) res, err := ctx.client.IntrospectAccessToken(context.Background(), IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "token"}}) @@ -276,7 +259,7 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { }) t.Run("ok", func(t *testing.T) { token := AccessToken{Expiration: time.Now().Add(time.Second)} - require.NoError(t, ctx.client.s2sAccessTokenStore().Put("token", token)) + require.NoError(t, ctx.client.accessTokenStore().Put("token", token)) res, err := ctx.client.IntrospectAccessToken(context.Background(), IntrospectAccessTokenRequestObject{Body: &TokenIntrospectionRequest{Token: "token"}}) @@ -307,7 +290,7 @@ func TestWrapper_IntrospectAccessToken(t *testing.T) { PresentationDefinition: &pe.PresentationDefinition{}, } - require.NoError(t, ctx.client.s2sAccessTokenStore().Put(token.Token, token)) + require.NoError(t, ctx.client.accessTokenStore().Put(token.Token, token)) expectedResponse, err := json.Marshal(IntrospectAccessToken200JSONResponse{ Active: true, ClientId: ptrTo("client"), @@ -372,6 +355,8 @@ type testCtx struct { vdr *vdr.MockVDR resolver *resolver.MockDIDResolver relyingParty *oauthServices.MockRelyingParty + verifier *verifier.MockVerifier + vcr *vcr.MockVCR } func newTestClient(t testing.TB) *testCtx { @@ -379,14 +364,21 @@ func newTestClient(t testing.TB) *testCtx { require.NoError(t, err) ctrl := gomock.NewController(t) storageEngine := storage.NewTestStorageEngine(t) + mockVerifier := verifier.NewMockVerifier(ctrl) + mockVCR := vcr.NewMockVCR(ctrl) + mockVCR.EXPECT().Verifier().Return(mockVerifier).AnyTimes() authnServices := auth.NewMockAuthenticationServices(ctrl) authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes() + authnServices.EXPECT().PresentationDefinitions().Return(pe.TestDefinitionResolver(t)).AnyTimes() resolver := resolver.NewMockDIDResolver(ctrl) relyingPary := oauthServices.NewMockRelyingParty(ctrl) + verifier := verifier.NewMockVerifier(ctrl) vdr := vdr.NewMockVDR(ctrl) + vcr := vcr.NewMockVCR(ctrl) authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes() authnServices.EXPECT().RelyingParty().Return(relyingPary).AnyTimes() + vcr.EXPECT().Verifier().Return(verifier).AnyTimes() vdr.EXPECT().Resolver().Return(resolver).AnyTimes() return &testCtx{ @@ -394,9 +386,12 @@ func newTestClient(t testing.TB) *testCtx { relyingParty: relyingPary, resolver: resolver, vdr: vdr, + verifier: mockVerifier, + vcr: mockVCR, client: &Wrapper{ auth: authnServices, vdr: vdr, + vcr: mockVCR, storageEngine: storageEngine, }, } @@ -409,7 +404,7 @@ func TestWrapper_Routes(t *testing.T) { router.EXPECT().GET(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() router.EXPECT().POST(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes() - Wrapper{}.Routes(router) + (&Wrapper{}).Routes(router) } func TestWrapper_middleware(t *testing.T) { @@ -458,6 +453,41 @@ func TestWrapper_middleware(t *testing.T) { } +func TestWrapper_idToOwnedDID(t *testing.T) { + t.Run("ok", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(nil, webDID).Return(true, nil) + + _, err := ctx.client.idToOwnedDID(nil, webIDPart) + + assert.NoError(t, err) + }) + t.Run("error - did not managed by this node", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(nil, webDID) + + _, err := ctx.client.idToOwnedDID(nil, webIDPart) + + assert.EqualError(t, err, "invalid_request - issuer DID not owned by the server") + }) + t.Run("DID does not exist (functional resolver error)", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(nil, webDID).Return(false, resolver.ErrNotFound) + + _, err := ctx.client.idToOwnedDID(nil, webIDPart) + + assert.EqualError(t, err, "invalid_request - invalid issuer DID: unable to find the DID document") + }) + t.Run("other resolver error", func(t *testing.T) { + ctx := newTestClient(t) + ctx.vdr.EXPECT().IsOwner(nil, webDID).Return(false, errors.New("unknown error")) + + _, err := ctx.client.idToOwnedDID(nil, webIDPart) + + assert.EqualError(t, err, "DID resolution failed: unknown error") + }) +} + type strictServerCallCapturer bool func (s *strictServerCallCapturer) handle(ctx echo.Context, request interface{}) (response interface{}, err error) { diff --git a/auth/api/iam/generated.go b/auth/api/iam/generated.go index 8c0961f8f1..1353107de0 100644 --- a/auth/api/iam/generated.go +++ b/auth/api/iam/generated.go @@ -107,9 +107,11 @@ type HandleAuthorizeRequestParams struct { // HandleTokenRequestFormdataBody defines parameters for HandleTokenRequest. type HandleTokenRequestFormdataBody struct { - Code string `form:"code" json:"code"` - GrantType string `form:"grant_type" json:"grant_type"` - AdditionalProperties map[string]string `json:"-"` + Assertion *string `form:"assertion,omitempty" json:"assertion,omitempty"` + Code *string `form:"code,omitempty" json:"code,omitempty"` + GrantType string `form:"grant_type" json:"grant_type"` + PresentationSubmission *string `form:"presentation_submission,omitempty" json:"presentation_submission,omitempty"` + Scope *string `form:"scope,omitempty" json:"scope,omitempty"` } // RequestAccessTokenJSONBody defines parameters for RequestAccessToken. @@ -128,85 +130,6 @@ type IntrospectAccessTokenFormdataRequestBody = TokenIntrospectionRequest // RequestAccessTokenJSONRequestBody defines body for RequestAccessToken for application/json ContentType. type RequestAccessTokenJSONRequestBody RequestAccessTokenJSONBody -// Getter for additional properties for HandleTokenRequestFormdataBody. Returns the specified -// element and whether it was found -func (a HandleTokenRequestFormdataBody) Get(fieldName string) (value string, found bool) { - if a.AdditionalProperties != nil { - value, found = a.AdditionalProperties[fieldName] - } - return -} - -// Setter for additional properties for HandleTokenRequestFormdataBody -func (a *HandleTokenRequestFormdataBody) Set(fieldName string, value string) { - if a.AdditionalProperties == nil { - a.AdditionalProperties = make(map[string]string) - } - a.AdditionalProperties[fieldName] = value -} - -// Override default JSON handling for HandleTokenRequestFormdataBody to handle AdditionalProperties -func (a *HandleTokenRequestFormdataBody) UnmarshalJSON(b []byte) error { - object := make(map[string]json.RawMessage) - err := json.Unmarshal(b, &object) - if err != nil { - return err - } - - if raw, found := object["code"]; found { - err = json.Unmarshal(raw, &a.Code) - if err != nil { - return fmt.Errorf("error reading 'code': %w", err) - } - delete(object, "code") - } - - if raw, found := object["grant_type"]; found { - err = json.Unmarshal(raw, &a.GrantType) - if err != nil { - return fmt.Errorf("error reading 'grant_type': %w", err) - } - delete(object, "grant_type") - } - - if len(object) != 0 { - a.AdditionalProperties = make(map[string]string) - for fieldName, fieldBuf := range object { - var fieldVal string - err := json.Unmarshal(fieldBuf, &fieldVal) - if err != nil { - return fmt.Errorf("error unmarshaling field %s: %w", fieldName, err) - } - a.AdditionalProperties[fieldName] = fieldVal - } - } - return nil -} - -// Override default JSON handling for HandleTokenRequestFormdataBody to handle AdditionalProperties -func (a HandleTokenRequestFormdataBody) MarshalJSON() ([]byte, error) { - var err error - object := make(map[string]json.RawMessage) - - object["code"], err = json.Marshal(a.Code) - if err != nil { - return nil, fmt.Errorf("error marshaling 'code': %w", err) - } - - object["grant_type"], err = json.Marshal(a.GrantType) - if err != nil { - return nil, fmt.Errorf("error marshaling 'grant_type': %w", err) - } - - for fieldName, field := range a.AdditionalProperties { - object[fieldName], err = json.Marshal(field) - if err != nil { - return nil, fmt.Errorf("error marshaling '%s': %w", fieldName, err) - } - } - return json.Marshal(object) -} - // ServerInterface represents all server handlers. type ServerInterface interface { // Get the OAuth2 Authorization Server metadata diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go index eb55988dfc..a12a8a578d 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -93,14 +93,12 @@ func TestWrapper_handlePresentationRequest(t *testing.T) { } t.Run("with scope", func(t *testing.T) { ctrl := gomock.NewController(t) - peStore := &pe.DefinitionResolver{} - require.NoError(t, peStore.LoadFromFile("test/presentation_definition_mapping.json")) mockVDR := vdr.NewMockVDR(ctrl) mockVCR := vcr.NewMockVCR(ctrl) mockWallet := holder.NewMockWallet(ctrl) mockVCR.EXPECT().Wallet().Return(mockWallet) mockAuth := auth.NewMockAuthenticationServices(ctrl) - mockAuth.EXPECT().PresentationDefinitions().Return(peStore) + mockAuth.EXPECT().PresentationDefinitions().Return(pe.TestDefinitionResolver(t)) mockWallet.EXPECT().List(gomock.Any(), holderDID).Return(walletCredentials, nil) mockVDR.EXPECT().IsOwner(gomock.Any(), holderDID).Return(true, nil) instance := New(mockAuth, mockVCR, mockVDR, storage.NewTestStorageEngine(t)) diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/s2s_vptoken.go index 4fb1bd6807..136c490def 100644 --- a/auth/api/iam/s2s_vptoken.go +++ b/auth/api/iam/s2s_vptoken.go @@ -22,61 +22,91 @@ import ( "context" "errors" "fmt" - "github.com/nuts-foundation/nuts-node/auth/oauth" - "github.com/nuts-foundation/nuts-node/crypto" - "net/http" "time" - "github.com/labstack/echo/v4" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/auth/oauth" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/storage" + "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/pe" "github.com/nuts-foundation/nuts-node/vdr/resolver" ) -// accessTokenValidity defines how long access tokens are valid. -// TODO: Might want to make this configurable at some point -const accessTokenValidity = 15 * time.Minute - -// 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 { -} +// s2sMaxPresentationValidity defines the maximum validity of a presentation. +// This is to prevent replay attacks. The value is specified by Nuts RFC021, and excludes max. clock skew. +const s2sMaxPresentationValidity = 5 * time.Second -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{}) - }) -} +// s2sMaxClockSkew defines the maximum clock skew between nodes. +// The value is specified by Nuts RFC021. +const s2sMaxClockSkew = 5 * time.Second -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 -} +// 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, matching VCs to the presentation definition), then the more expensive ones (checking signatures). +func (r *Wrapper) handleS2SAccessTokenRequest(issuer did.DID, scope string, submissionJSON string, assertionJSON string) (HandleTokenRequestResponseObject, error) { + pexEnvelope, err := pe.ParseEnvelope([]byte(assertionJSON)) + if err != nil { + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "assertion parameter is invalid: " + err.Error(), + } + } + + submission, err := pe.ParsePresentationSubmission([]byte(submissionJSON)) + if err != nil { + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: fmt.Sprintf("invalid presentation submission: %s", err.Error()), + } + } + + var credentialSubjectID did.DID + for _, presentation := range pexEnvelope.Presentations { + if err := validateS2SPresentationMaxValidity(presentation); err != nil { + return nil, err + } + if subjectDID, err := validatePresentationSigner(presentation, credentialSubjectID); err != nil { + return nil, err + } else { + credentialSubjectID = *subjectDID + } + if err := r.validatePresentationAudience(presentation, issuer); err != nil { + return nil, err + } + } + credentialMap, definition, err := r.validatePresentationSubmission(scope, submission, pexEnvelope) + if err != nil { + return nil, err + } + for _, presentation := range pexEnvelope.Presentations { + if err := r.validateS2SPresentationNonce(presentation); err != nil { + return nil, err + } + } + + // Check signatures of VP and VCs. Trust should be established by the Presentation Definition. + for _, presentation := range pexEnvelope.Presentations { + _, err = r.vcr.Verifier().VerifyVP(presentation, true, true, nil) + if err != nil { + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "presentation(s) or contained credential(s) are invalid", + InternalError: err, + } + } + } -func (s serviceToService) handleAuthzRequest(_ map[string]string, _ *Session) (*authzResponse, error) { - // Protocol does not support authorization code flow - return nil, nil + // All OK, allow access + response, err := r.createS2SAccessToken(issuer, time.Now(), pexEnvelope.Presentations, *submission, *definition, scope, credentialSubjectID, credentialMap) + if err != nil { + return nil, err + } + return HandleTokenRequest200JSONResponse(*response), nil } -func (r Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTokenRequestObject) (RequestAccessTokenResponseObject, error) { +func (r *Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTokenRequestObject) (RequestAccessTokenResponseObject, error) { if request.Body == nil { // why did oapi-codegen generate a pointer for the body?? return nil, core.InvalidInputError("missing request body") @@ -115,22 +145,24 @@ func (r Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTo return RequestAccessToken200JSONResponse(*tokenResult), nil } -func (r Wrapper) createAccessToken(issuer did.DID, issueTime time.Time, presentation vc.VerifiablePresentation, scope string) (*oauth.TokenResponse, error) { +func (r *Wrapper) createS2SAccessToken(issuer did.DID, issueTime time.Time, presentations []vc.VerifiablePresentation, submission pe.PresentationSubmission, definition PresentationDefinition, scope string, credentialSubjectDID did.DID, credentialMap map[string]vc.VerifiableCredential) (*oauth.TokenResponse, error) { + fieldsMap, err := definition.ResolveConstraintsFields(credentialMap) + if err != nil { + return nil, fmt.Errorf("unable to resolve Presentation Definition Constraints Fields: %w", err) + } accessToken := AccessToken{ - Token: crypto.GenerateNonce(), - Issuer: issuer.String(), - // TODO: set ClientId - ClientId: "", - IssuedAt: issueTime, - Expiration: issueTime.Add(accessTokenValidity), - Scope: scope, - // TODO: set values - InputDescriptorConstraintIdMap: nil, - VPToken: []VerifiablePresentation{presentation}, - PresentationDefinition: nil, - PresentationSubmission: nil, - } - err := r.s2sAccessTokenStore().Put(accessToken.Token, accessToken) + Token: crypto.GenerateNonce(), + Issuer: issuer.String(), + ClientId: credentialSubjectDID.String(), + IssuedAt: issueTime, + Expiration: issueTime.Add(accessTokenValidity), + Scope: scope, + VPToken: presentations, + PresentationDefinition: &definition, + PresentationSubmission: &submission, + InputDescriptorConstraintIdMap: fieldsMap, + } + err = r.accessTokenStore().Put(accessToken.Token, accessToken) if err != nil { return nil, fmt.Errorf("unable to store access token: %w", err) } @@ -143,8 +175,144 @@ func (r Wrapper) createAccessToken(issuer did.DID, issueTime time.Time, presenta }, nil } -func (r Wrapper) s2sAccessTokenStore() storage.SessionStore { - return r.storageEngine.GetSessionDatabase().GetStore(accessTokenValidity, "s2s", "accesstoken") +// validatePresentationSubmission checks if the presentation submission is valid for the given scope: +// 1. Resolve presentation definition for the requested scope +// 2. Check submission against presentation and definition +func (r Wrapper) validatePresentationSubmission(scope string, submission *pe.PresentationSubmission, pexEnvelope *pe.Envelope) (map[string]vc.VerifiableCredential, *PresentationDefinition, error) { + definition := r.auth.PresentationDefinitions().ByScope(scope) + if definition == nil { + return nil, nil, oauth.OAuth2Error{ + Code: oauth.InvalidScope, + Description: fmt.Sprintf("unsupported scope for presentation exchange: %s", scope), + } + } + + credentialMap, err := submission.Validate(*pexEnvelope, *definition) + if err != nil { + return nil, nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "presentation submission does not conform to Presentation Definition", + InternalError: err, + } + } + return credentialMap, definition, err +} + +// validateS2SPresentationMaxValidity checks that the presentation is valid for a reasonable amount of time. +func validateS2SPresentationMaxValidity(presentation vc.VerifiablePresentation) error { + created := credential.PresentationIssuanceDate(presentation) + expires := credential.PresentationExpirationDate(presentation) + if created == nil || expires == nil { + return oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "presentation is missing creation or expiration date", + } + } + if expires.Sub(*created) > s2sMaxPresentationValidity { + return oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: fmt.Sprintf("presentation is valid for too long (max %s)", s2sMaxPresentationValidity), + } + } + return nil +} + +// validatePresentationSigner checks if the presenter of the VP is the same as the subject of the VCs being presented. +func validatePresentationSigner(presentation vc.VerifiablePresentation, expectedCredentialSubjectDID did.DID) (*did.DID, error) { + subjectDID, err := credential.PresenterIsCredentialSubject(presentation) + if err != nil { + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: err.Error(), + } + } + if subjectDID == nil { + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "presentation signer is not credential subject", + } + } + if !expectedCredentialSubjectDID.Empty() && !subjectDID.Equals(expectedCredentialSubjectDID) { + return nil, oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "not all presentations have the same credential subject ID", + } + } + return subjectDID, nil +} + +// validateS2SPresentationNonce checks if the nonce has been used before; 'nonce' claim for JWTs or LDProof's 'nonce' for JSON-LD. +func (r *Wrapper) validateS2SPresentationNonce(presentation vc.VerifiablePresentation) error { + var nonce string + switch presentation.Format() { + case vc.JWTPresentationProofFormat: + nonceRaw, _ := presentation.JWT().Get("nonce") + nonce, _ = nonceRaw.(string) + if nonce == "" { + return oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "presentation has invalid/missing nonce", + } + } + case vc.JSONLDPresentationProofFormat: + proof, err := credential.ParseLDProof(presentation) + if err != nil || proof.Nonce == nil || *proof.Nonce == "" { + return oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + InternalError: err, + Description: "presentation has invalid proof or nonce", + } + } + nonce = *proof.Nonce + } + + nonceStore := r.storageEngine.GetSessionDatabase().GetStore(s2sMaxPresentationValidity+s2sMaxClockSkew, "s2s", "nonce") + nonceError := nonceStore.Get(nonce, new(bool)) + if nonceError != nil && errors.Is(nonceError, storage.ErrNotFound) { + // this is OK, nonce has not been used before + nonceError = nil + } else if nonceError == nil { + // no store error: value was retrieved from store, meaning the nonce has been used before + nonceError = oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "presentation nonce has already been used", + } + } + // Other error occurred. Keep error to report after storing nonce. + + // Regardless the result of the nonce checking, the nonce of the VP must not be used again. + // So always store the nonce. + if err := nonceStore.Put(nonce, true); err != nil { + nonceError = errors.Join(fmt.Errorf("unable to store nonce: %w", err), nonceError) + } + return nonceError +} + +// validatePresentationAudience checks if the presentation audience (aud claim for JWTs, domain property for JSON-LD proofs) contains the issuer DID. +func (r *Wrapper) validatePresentationAudience(presentation vc.VerifiablePresentation, issuer did.DID) error { + var audience []string + switch presentation.Format() { + case vc.JWTPresentationProofFormat: + audience = presentation.JWT().Audience() + case vc.JSONLDPresentationProofFormat: + proof, err := credential.ParseLDProof(presentation) + if err != nil { + return err + } + if proof.Domain != nil { + audience = []string{*proof.Domain} + } + } + for _, aud := range audience { + if aud == issuer.String() { + return nil + } + } + return oauth.OAuth2Error{ + Code: oauth.InvalidRequest, + Description: "presentation audience/domain is missing or does not match", + InternalError: fmt.Errorf("expected: %s, got: %v", issuer, audience), + } } type AccessToken struct { diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index c034a4e505..7ce63422fe 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -19,18 +19,30 @@ package iam import ( + "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/nuts-foundation/nuts-node/vcr/signature/proof" "net/http" "testing" "time" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/json" + "errors" + 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/auth/oauth" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/jsonld" + "github.com/nuts-foundation/nuts-node/vcr/credential" + "github.com/nuts-foundation/nuts-node/vcr/pe" + "github.com/nuts-foundation/nuts-node/vcr/test" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" ) func TestWrapper_RequestAccessToken(t *testing.T) { @@ -105,16 +117,313 @@ func TestWrapper_RequestAccessToken(t *testing.T) { }) } +func TestWrapper_handleS2SAccessTokenRequest(t *testing.T) { + issuerDIDStr := "did:web:example.com:iam:123" + issuerDID := did.MustParseDID(issuerDIDStr) + const requestedScope = "eOverdracht-overdrachtsbericht" + // Create issuer DID document and keys + keyPair, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + issuerDIDDocument := did.Document{ + ID: issuerDID, + } + keyID := did.DIDURL{DID: issuerDID} + keyID.Fragment = "1" + verificationMethod, err := did.NewVerificationMethod(keyID, ssi.JsonWebKey2020, issuerDID, keyPair.Public()) + require.NoError(t, err) + issuerDIDDocument.AddAssertionMethod(verificationMethod) + + var submission pe.PresentationSubmission + require.NoError(t, json.Unmarshal([]byte(` +{ + "descriptor_map": [ + { + "id": "1", + "path": "$.verifiableCredential", + "format": "ldp_vc" + } + ] +}`), &submission)) + submissionJSONBytes, _ := json.Marshal(submission) + submissionJSON := string(submissionJSONBytes) + verifiableCredential := credential.ValidNutsOrganizationCredential(t) + subjectDID, _ := verifiableCredential.SubjectDID() + proofVisitor := test.LDProofVisitor(func(proof *proof.LDProof) { + proof.Domain = &issuerDIDStr + }) + presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, verifiableCredential) + + t.Run("JSON-LD VP", func(t *testing.T) { + ctx := newTestClient(t) + ctx.verifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + + resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + + 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.NotEmpty(t, tokenResponse.AccessToken) + }) + t.Run("missing presentation expiry date", func(t *testing.T) { + ctx := newTestClient(t) + presentation := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { + require.NoError(t, token.Remove(jwt.ExpirationKey)) + }, verifiableCredential) + + _, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + + require.EqualError(t, err, "invalid_request - presentation is missing creation or expiration date") + }) + t.Run("missing presentation not before date", func(t *testing.T) { + ctx := newTestClient(t) + presentation := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { + require.NoError(t, token.Remove(jwt.NotBeforeKey)) + }, verifiableCredential) + + _, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + + require.EqualError(t, err, "invalid_request - presentation is missing creation or expiration date") + }) + t.Run("missing presentation valid for too long", func(t *testing.T) { + ctx := newTestClient(t) + presentation := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { + require.NoError(t, token.Set(jwt.ExpirationKey, time.Now().Add(time.Hour))) + }, verifiableCredential) + + _, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + + require.EqualError(t, err, "invalid_request - presentation is valid for too long (max 5s)") + }) + t.Run("JWT VP", func(t *testing.T) { + ctx := newTestClient(t) + presentation := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { + require.NoError(t, token.Set(jwt.AudienceKey, issuerDID.String())) + }, verifiableCredential) + ctx.verifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + + resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + + 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.NotEmpty(t, tokenResponse.AccessToken) + }) + t.Run("VP is not valid JSON", func(t *testing.T) { + ctx := newTestClient(t) + resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, "[true, false]") + + assert.EqualError(t, err, "invalid_request - assertion parameter is invalid: unable to parse PEX envelope as verifiable presentation: invalid JWT") + assert.Nil(t, resp) + }) + t.Run("not all VPs have the same credential subject ID", func(t *testing.T) { + ctx := newTestClient(t) + + secondSubjectID := did.MustParseDID("did:web:example.com:other") + secondPresentation := test.CreateJSONLDPresentation(t, secondSubjectID, proofVisitor, credential.JWTNutsOrganizationCredential(t, secondSubjectID)) + assertionJSON, _ := json.Marshal([]VerifiablePresentation{presentation, secondPresentation}) + + resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, string(assertionJSON)) + assert.EqualError(t, err, "invalid_request - not all presentations have the same credential subject ID") + assert.Nil(t, resp) + }) + t.Run("nonce", func(t *testing.T) { + t.Run("replay attack (nonce is reused)", func(t *testing.T) { + ctx := newTestClient(t) + ctx.verifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(presentation.VerifiableCredential, nil) + + _, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + require.NoError(t, err) + + resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + assert.EqualError(t, err, "invalid_request - presentation nonce has already been used") + assert.Nil(t, resp) + }) + t.Run("JSON-LD VP is missing nonce", func(t *testing.T) { + ctx := newTestClient(t) + + proofVisitor := test.LDProofVisitor(func(proof *proof.LDProof) { + proof.Domain = &issuerDIDStr + proof.Nonce = nil + }) + presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, verifiableCredential) + + resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + assert.EqualError(t, err, "invalid_request - presentation has invalid proof or nonce") + assert.Nil(t, resp) + }) + t.Run("JSON-LD VP has empty nonce", func(t *testing.T) { + ctx := newTestClient(t) + + proofVisitor := test.LDProofVisitor(func(proof *proof.LDProof) { + proof.Domain = &issuerDIDStr + proof.Nonce = new(string) + }) + presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, verifiableCredential) + + resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + assert.EqualError(t, err, "invalid_request - presentation has invalid proof or nonce") + assert.Nil(t, resp) + }) + t.Run("JWT VP is missing nonce", func(t *testing.T) { + ctx := newTestClient(t) + presentation := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { + _ = token.Set(jwt.AudienceKey, issuerDID.String()) + _ = token.Remove("nonce") + }, verifiableCredential) + + _, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + + require.EqualError(t, err, "invalid_request - presentation has invalid/missing nonce") + }) + t.Run("JWT VP has empty nonce", func(t *testing.T) { + ctx := newTestClient(t) + presentation := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { + _ = token.Set(jwt.AudienceKey, issuerDID.String()) + _ = token.Set("nonce", "") + }, verifiableCredential) + + _, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + + require.EqualError(t, err, "invalid_request - presentation has invalid/missing nonce") + }) + t.Run("JWT VP nonce is not a string", func(t *testing.T) { + ctx := newTestClient(t) + presentation := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { + _ = token.Set(jwt.AudienceKey, issuerDID.String()) + _ = token.Set("nonce", true) + }, verifiableCredential) + + _, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + + require.EqualError(t, err, "invalid_request - presentation has invalid/missing nonce") + }) + }) + t.Run("audience", func(t *testing.T) { + t.Run("missing", func(t *testing.T) { + ctx := newTestClient(t) + presentation := test.CreateJWTPresentation(t, *subjectDID, nil, verifiableCredential) + + resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + + assert.EqualError(t, err, "invalid_request - expected: did:web:example.com:iam:123, got: [] - presentation audience/domain is missing or does not match") + assert.Nil(t, resp) + }) + t.Run("not matching", func(t *testing.T) { + ctx := newTestClient(t) + presentation := test.CreateJWTPresentation(t, *subjectDID, func(token jwt.Token) { + require.NoError(t, token.Set(jwt.AudienceKey, "did:example:other")) + }, verifiableCredential) + + resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + + assert.EqualError(t, err, "invalid_request - expected: did:web:example.com:iam:123, got: [did:example:other] - presentation audience/domain is missing or does not match") + assert.Nil(t, resp) + }) + }) + t.Run("VP verification fails", func(t *testing.T) { + ctx := newTestClient(t) + ctx.verifier.EXPECT().VerifyVP(presentation, true, true, gomock.Any()).Return(nil, errors.New("invalid")) + + resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + + assert.EqualError(t, err, "invalid_request - invalid - presentation(s) or contained credential(s) are invalid") + assert.Nil(t, resp) + }) + t.Run("proof of ownership", func(t *testing.T) { + t.Run("VC without credentialSubject.id", func(t *testing.T) { + ctx := newTestClient(t) + presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, vc.VerifiableCredential{ + CredentialSubject: []interface{}{map[string]string{}}, + }) + + resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + + assert.EqualError(t, err, `invalid_request - unable to get subject DID from VC: credential subjects have no ID`) + assert.Nil(t, resp) + }) + t.Run("signing key is not owned by credentialSubject.id", func(t *testing.T) { + ctx := newTestClient(t) + invalidProof := presentation.Proof[0].(map[string]interface{}) + invalidProof["verificationMethod"] = "did:example:other#1" + verifiablePresentation := vc.VerifiablePresentation{ + VerifiableCredential: []vc.VerifiableCredential{verifiableCredential}, + Proof: []interface{}{invalidProof}, + } + verifiablePresentationJSON, _ := verifiablePresentation.MarshalJSON() + + resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, string(verifiablePresentationJSON)) + + assert.EqualError(t, err, `invalid_request - presentation signer is not credential subject`) + assert.Nil(t, resp) + }) + }) + t.Run("submission is not valid JSON", func(t *testing.T) { + ctx := newTestClient(t) + + resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, "not-a-valid-submission", presentation.Raw()) + + assert.EqualError(t, err, `invalid_request - invalid presentation submission: invalid character 'o' in literal null (expecting 'u')`) + assert.Nil(t, resp) + }) + t.Run("unsupported scope", func(t *testing.T) { + ctx := newTestClient(t) + + resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, "everything", submissionJSON, presentation.Raw()) + + assert.EqualError(t, err, `invalid_scope - 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{}{ + "id": subjectDID.String(), + // just for demonstration purposes, what matters is that the credential does not match the presentation definition. + "IsAdministrator": true, + }, + }, + } + presentation := test.CreateJSONLDPresentation(t, *subjectDID, proofVisitor, otherVerifiableCredential) + + ctx := newTestClient(t) + + resp, err := ctx.client.handleS2SAccessTokenRequest(issuerDID, requestedScope, submissionJSON, presentation.Raw()) + assert.EqualError(t, err, "invalid_request - presentation submission doesn't match presentation definition - presentation submission does not conform to Presentation Definition") + assert.Nil(t, resp) + }) +} + func TestWrapper_createAccessToken(t *testing.T) { + credentialSubjectID := did.MustParseDID("did:nuts:B8PUHs2AUHbFF1xLLK4eZjgErEcMXHxs68FteY7NDtCY") + verificationMethodID := ssi.MustParseURI(credentialSubjectID.String() + "#1") credential, err := vc.ParseVerifiableCredential(jsonld.TestOrganizationCredential) require.NoError(t, err) - presentation := vc.VerifiablePresentation{ + presentation := test.ParsePresentation(t, vc.VerifiablePresentation{ VerifiableCredential: []vc.VerifiableCredential{*credential}, + Proof: []interface{}{ + proof.LDProof{ + VerificationMethod: verificationMethodID, + }, + }, + }) + submission := pe.PresentationSubmission{ + Id: "submissive", + } + definition := pe.PresentationDefinition{ + Id: "definitive", } t.Run("ok", func(t *testing.T) { ctx := newTestClient(t) - accessToken, err := ctx.client.createAccessToken(issuerDID, time.Now(), presentation, "everything") + vps := []VerifiablePresentation{test.ParsePresentation(t, presentation)} + accessToken, err := ctx.client.createS2SAccessToken(issuerDID, time.Now(), vps, submission, definition, "everything", credentialSubjectID, nil) require.NoError(t, err) assert.NotEmpty(t, accessToken.AccessToken) @@ -123,9 +432,11 @@ func TestWrapper_createAccessToken(t *testing.T) { assert.Equal(t, "everything", *accessToken.Scope) var storedToken AccessToken - err = ctx.client.s2sAccessTokenStore().Get(accessToken.AccessToken, &storedToken) + err = ctx.client.accessTokenStore().Get(accessToken.AccessToken, &storedToken) require.NoError(t, err) assert.Equal(t, accessToken.AccessToken, storedToken.Token) + assert.Equal(t, submission, *storedToken.PresentationSubmission) + assert.Equal(t, definition, *storedToken.PresentationDefinition) expectedVPJSON, _ := presentation.MarshalJSON() actualVPJSON, _ := storedToken.VPToken[0].MarshalJSON() assert.JSONEq(t, string(expectedVPJSON), string(actualVPJSON)) diff --git a/auth/api/iam/test/presentation_definition_mapping.json b/auth/api/iam/test/presentation_definition_mapping.json index 319765aca3..33ebbf4377 100644 --- a/auth/api/iam/test/presentation_definition_mapping.json +++ b/auth/api/iam/test/presentation_definition_mapping.json @@ -1,6 +1,53 @@ { "eOverdracht-overdrachtsbericht": { "id": "eOverdracht", - "input_descriptors": [] + "input_descriptors": [ + { + "id": "1", + "name": "Organization matcher", + "purpose": "Any care organization", + "constraints": { + "fields": [ + { + "path": [ + "$.credentialSubject.organization.city" + ], + "filter": { + "type": "string" + } + }, + { + "path": [ + "$.credentialSubject.organization.name" + ], + "filter": { + "type": "string" + } + }, + { + "path": [ + "$.type" + ], + "filter": { + "type": "string", + "const": "NutsOrganizationCredential" + } + } + ] + } + } + ], + "format": { + "jwt_vc": { + "alg": [ + "ES256" + ] + }, + "ldp_vc": { + "proof_type": [ + "JsonWebSignature2020" + ] + } + } } } diff --git a/auth/client/iam/client.go b/auth/client/iam/client.go index d7cf10a4cb..508306205d 100644 --- a/auth/client/iam/client.go +++ b/auth/client/iam/client.go @@ -137,11 +137,12 @@ func (hb HTTPClient) AccessToken(ctx context.Context, tokenEndpoint string, vp v } // create a POST request with x-www-form-urlencoded body - assertion, _ := json.Marshal(vp) + assertion := vp.Raw() presentationSubmission, _ := json.Marshal(submission) + log.Logger().Tracef("Requesting access token from '%s' for scope '%s'\n VP: %s\n Submission: %s", presentationDefinitionURL.String(), scopes, assertion, string(presentationSubmission)) data := url.Values{} data.Set(oauth.GrantTypeParam, oauth.VpTokenGrantType) - data.Set(oauth.AssertionParam, string(assertion)) + data.Set(oauth.AssertionParam, assertion) data.Set(oauth.PresentationSubmissionParam, string(presentationSubmission)) data.Set(oauth.ScopeParam, scopes) request, err := http.NewRequestWithContext(ctx, http.MethodPost, presentationDefinitionURL.String(), strings.NewReader(data.Encode())) diff --git a/auth/oauth/openid.go b/auth/oauth/openid.go index f657cc0f5a..852ecae90c 100644 --- a/auth/oauth/openid.go +++ b/auth/oauth/openid.go @@ -1,3 +1,21 @@ +/* + * 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 oauth // algValuesSupported contains a list of supported cipher suites for jwt_vc_json & jwt_vp_json presentation formats diff --git a/auth/services/oauth/relying_party.go b/auth/services/oauth/relying_party.go index 1998e0fd97..308f5da302 100644 --- a/auth/services/oauth/relying_party.go +++ b/auth/services/oauth/relying_party.go @@ -179,15 +179,17 @@ func (s *relyingParty) RequestRFC021AccessToken(ctx context.Context, requester d if signInstructions.Empty() { return nil, core.Error(http.StatusPreconditionFailed, "no matching credentials") } - expires := time.Now().Add(time.Minute * 15) //todo - nonce := nutsCrypto.GenerateNonce() + expires := time.Now().Add(time.Second * 5) // todo: support multiple wallets + domain := verifier.String() + nonce := nutsCrypto.GenerateNonce() vp, err := s.wallet.BuildPresentation(ctx, signInstructions[0].VerifiableCredentials, holder.PresentationOptions{ Format: format, ProofOptions: proof.ProofOptions{ - Created: time.Now(), - Challenge: &nonce, - Expires: &expires, + Created: time.Now(), + Expires: &expires, + Domain: &domain, + Nonce: &nonce, }, }, &requester, false) if err != nil { diff --git a/docs/_static/auth/iam.yaml b/docs/_static/auth/iam.yaml index d25d49324e..e458cc3619 100644 --- a/docs/_static/auth/iam.yaml +++ b/docs/_static/auth/iam.yaml @@ -52,16 +52,18 @@ paths: type: object required: - grant_type - - code properties: grant_type: type: string example: urn:ietf:params:oauth:grant-type:authorized_code code: type: string - example: secret - additionalProperties: - type: string + assertion: + type: string + presentation_submission: + type: string + scope: + type: string responses: "200": description: OK diff --git a/docs/pages/deployment/cli-reference.rst b/docs/pages/deployment/cli-reference.rst index b32c1400b4..dc1fe5f6f5 100755 --- a/docs/pages/deployment/cli-reference.rst +++ b/docs/pages/deployment/cli-reference.rst @@ -412,7 +412,7 @@ Print conflicted documents and their metadata nuts vdr create-did ^^^^^^^^^^^^^^^^^^^ -When using the V2 API, a web:did will be created. All the other options are ignored for a web:did. +When using the V2 API, a did:web DID will be created. All the other options are ignored for did:web. :: @@ -430,7 +430,7 @@ When using the V2 API, a web:did will be created. All the other options are igno --timeout duration Client time-out when performing remote operations, such as '500ms' or '10s'. Refer to Golang's 'time.Duration' syntax for a more elaborate description of the syntax. (default 10s) --token string Token to be used for authenticating on the remote node. Takes precedence over 'token-file'. --token-file string File from which the authentication token will be read. If not specified it will try to read the token from the '.nuts-client.cfg' file in the user's home dir. - --v2 Pass 'true' to use the V2 API and create a web:did. + --v2 Pass 'true' to use the V2 API and create a did:web DID. --verbosity string Log level (trace, debug, info, warn, error) (default "info") nuts vdr deactivate diff --git a/e2e-tests/oauth-flow/rfc021/docker-compose.yml b/e2e-tests/oauth-flow/rfc021/docker-compose.yml index 57dae33a7d..c34f0bad59 100644 --- a/e2e-tests/oauth-flow/rfc021/docker-compose.yml +++ b/e2e-tests/oauth-flow/rfc021/docker-compose.yml @@ -11,6 +11,9 @@ services: - "./node-A/data:/opt/nuts/data:rw" - "../../tls-certs/nodeA-backend-certificate.pem:/opt/nuts/certificate-and-key.pem:ro" - "../../tls-certs/truststore.pem:/opt/nuts/truststore.pem:ro" + # did:web resolver uses the OS CA bundle, but e2e tests use a self-signed CA which can be found in truststore.pem + # So we need to mount that file to the OS CA bundle location, otherwise did:web resolving will fail due to untrusted certs. + - "../../tls-certs/truststore.pem:/etc/ssl/certs/Nuts_RootCA.pem:ro" - "./node-A/presentationexchangemapping.json:/opt/nuts/presentationexchangemapping.json:ro" healthcheck: interval: 1s # Make test run quicker by checking health status more often @@ -36,6 +39,9 @@ services: - "../../tls-certs/nodeB-certificate.pem:/opt/nuts/certificate-and-key.pem:ro" - "../../tls-certs/truststore.pem:/opt/nuts/truststore.pem:ro" - "../../tls-certs/truststore.pem:/etc/ssl/certs/truststore.pem:ro" + # did:web resolver uses the OS CA bundle, but e2e tests use a self-signed CA which can be found in truststore.pem + # So we need to mount that file to the OS CA bundle location, otherwise did:web resolving will fail due to untrusted certs. + - "../../tls-certs/truststore.pem:/etc/ssl/certs/Nuts_RootCA.pem:ro" healthcheck: interval: 1s # Make test run quicker by checking health status more often nodeB: diff --git a/e2e-tests/oauth-flow/rfc021/run-test.sh b/e2e-tests/oauth-flow/rfc021/run-test.sh index 6e68322cd3..7fafab3399 100755 --- a/e2e-tests/oauth-flow/rfc021/run-test.sh +++ b/e2e-tests/oauth-flow/rfc021/run-test.sh @@ -52,30 +52,21 @@ echo "---------------------------------------" echo "Perform OAuth 2.0 rfc021 flow..." echo "---------------------------------------" # Request access token -# Create DID for A with :nuts: replaced with :web: REQUEST="{\"verifier\":\"${VENDOR_A_DID}\",\"scope\":\"test\"}" RESPONSE=$(echo $REQUEST | curl -X POST -s --data-binary @- http://localhost:21323/internal/auth/v2/$VENDOR_B_DID/request-access-token -H "Content-Type:application/json" -v) -#if echo $RESPONSE | grep -q "access_token"; then -# echo $RESPONSE | sed -E 's/.*"access_token":"([^"]*).*/\1/' > ./node-B/data/accesstoken.txt -# echo "access token stored in ./node-B/data/accesstoken.txt" -#else -# echo "FAILED: Could not get access token from node-A" 1>&2 -# echo $RESPONSE -# exitWithDockerLogs 1 -#fi -if echo $RESPONSE | grep -q "unsupported_grant_type - not implemented yet"; then - echo "Good so far!" +if echo $RESPONSE | grep -q "access_token"; then + echo $RESPONSE | sed -E 's/.*"access_token":"([^"]*).*/\1/' > ./node-B/data/accesstoken.txt + echo "access token stored in ./node-B/data/accesstoken.txt" else echo "FAILED: Could not get access token from node-A" 1>&2 echo $RESPONSE exitWithDockerLogs 1 fi -#echo "------------------------------------" -#echo "Retrieving data..." -#echo "------------------------------------" -# -#RESPONSE=$(docker compose exec nodeB curl --insecure --cert /opt/nuts/certificate-and-key.pem --key /opt/nuts/certificate-and-key.pem https://nodeA:443/ping -H "Authorization: bearer $(cat ./node-B/data/accesstoken.txt)" -v) +echo "------------------------------------" +echo "Retrieving data..." +echo "------------------------------------" +#RESPONSE=$(docker compose exec nodeB curl --insecure --cert /etc/nginx/ssl/server.pem --key /etc/nginx/ssl/key.pem https://nodeA:443/ping -H "Authorization: bearer $(cat ./node-B/data/accesstoken.txt)" -v) #if echo $RESPONSE | grep -q "pong"; then # echo "success!" #else diff --git a/vcr/assets/test_assets.go b/vcr/assets/test_assets.go index 355920dc2d..bdf0e1e490 100644 --- a/vcr/assets/test_assets.go +++ b/vcr/assets/test_assets.go @@ -1,5 +1,5 @@ /* - * Copyright (C) 2022 Nuts community + * 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 @@ -18,7 +18,9 @@ package assets -import "embed" +import ( + "embed" +) // TestAssets contains the embedded test files needed for VCR. // diff --git a/vcr/credential/resolver.go b/vcr/credential/resolver.go index eda669ff84..b6644fc370 100644 --- a/vcr/credential/resolver.go +++ b/vcr/credential/resolver.go @@ -70,20 +70,30 @@ func PresentationSigner(presentation vc.VerifiablePresentation) (*did.DID, error return nil, errors.New("JWT presentation does not have 'iss' claim") } return did.ParseDID(issuer) - case vc.JSONLDCredentialProofFormat: - fallthrough - default: - var proofs []proof.LDProof - if err := presentation.UnmarshalProofValue(&proofs); err != nil { - return nil, fmt.Errorf("invalid LD-proof for presentation: %w", err) - } - if len(proofs) != 1 { - return nil, fmt.Errorf("presentation should have exactly 1 proof, got %d", len(proofs)) + case vc.JSONLDPresentationProofFormat: + proof, err := ParseLDProof(presentation) + if err != nil { + return nil, err } - verificationMethod, err := did.ParseDIDURL(proofs[0].VerificationMethod.String()) + verificationMethod, err := did.ParseDIDURL(proof.VerificationMethod.String()) if err != nil || verificationMethod.DID.Empty() { return nil, fmt.Errorf("invalid verification method for JSON-LD presentation: %w", err) } return &verificationMethod.DID, nil + default: + return nil, fmt.Errorf("unsupported presentation format: %s", presentation.Format()) + } +} + +// ParseLDProof parses the LinkedData proof from the presentation. +// It returns an error if the presentation does not have exactly 1 proof. +func ParseLDProof(presentation vc.VerifiablePresentation) (*proof.LDProof, error) { + var proofs []proof.LDProof + if err := presentation.UnmarshalProofValue(&proofs); err != nil { + return nil, fmt.Errorf("invalid LD-proof for presentation: %w", err) + } + if len(proofs) != 1 { + return nil, fmt.Errorf("presentation should have exactly 1 proof, got %d", len(proofs)) } + return &proofs[0], nil } diff --git a/vcr/credential/resolver_test.go b/vcr/credential/resolver_test.go index 310e364659..2983f0e347 100644 --- a/vcr/credential/resolver_test.go +++ b/vcr/credential/resolver_test.go @@ -27,6 +27,7 @@ import ( "github.com/lestrrat-go/jwx/v2/jwt" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" + "github.com/nuts-foundation/nuts-node/vcr/test" "github.com/stretchr/testify/require" "testing" @@ -98,54 +99,60 @@ func TestPresentationSigner(t *testing.T) { }) t.Run("JSON-LD", func(t *testing.T) { t.Run("ok", func(t *testing.T) { - presentation := vc.VerifiablePresentation{ + presentation := test.ParsePresentation(t, vc.VerifiablePresentation{ Proof: []interface{}{proof.LDProof{ VerificationMethod: keyID.URI(), }}, - } + }) actual, err := PresentationSigner(presentation) - assert.NoError(t, err) + require.NoError(t, err) assert.Equal(t, keyID.DID, *actual) }) t.Run("too many proofs", func(t *testing.T) { - presentation := vc.VerifiablePresentation{ + presentation := test.ParsePresentation(t, vc.VerifiablePresentation{ Proof: []interface{}{proof.LDProof{ VerificationMethod: keyID.URI(), }, proof.LDProof{ VerificationMethod: keyID.URI(), }}, - } + }) actual, err := PresentationSigner(presentation) assert.EqualError(t, err, "presentation should have exactly 1 proof, got 2") assert.Nil(t, actual) }) t.Run("not a JSON-LD proof", func(t *testing.T) { - presentation := vc.VerifiablePresentation{ + presentation := test.ParsePresentation(t, vc.VerifiablePresentation{ Proof: []interface{}{5}, - } + }) actual, err := PresentationSigner(presentation) assert.EqualError(t, err, "invalid LD-proof for presentation: json: cannot unmarshal number into Go value of type proof.LDProof") assert.Nil(t, actual) }) t.Run("invalid DID in proof", func(t *testing.T) { - presentation := vc.VerifiablePresentation{ + presentation := test.ParsePresentation(t, vc.VerifiablePresentation{ Proof: []interface{}{proof.LDProof{ VerificationMethod: ssi.MustParseURI("foo"), }}, - } + }) actual, err := PresentationSigner(presentation) assert.EqualError(t, err, "invalid verification method for JSON-LD presentation: invalid DID") assert.Nil(t, actual) }) t.Run("empty VerificationMethod", func(t *testing.T) { - presentation := vc.VerifiablePresentation{ + presentation := test.ParsePresentation(t, vc.VerifiablePresentation{ Proof: []interface{}{proof.LDProof{ VerificationMethod: ssi.MustParseURI(""), }}, - } + }) actual, err := PresentationSigner(presentation) assert.ErrorContains(t, err, "invalid verification method for JSON-LD presentation") assert.Nil(t, actual) }) }) + t.Run("unsupported format", func(t *testing.T) { + presentation := vc.VerifiablePresentation{} + actual, err := PresentationSigner(presentation) + assert.EqualError(t, err, "unsupported presentation format: ") + assert.Nil(t, actual) + }) } diff --git a/vcr/credential/test.go b/vcr/credential/test.go index 4e1f7d87d5..33de4bcd0e 100644 --- a/vcr/credential/test.go +++ b/vcr/credential/test.go @@ -26,6 +26,7 @@ import ( "encoding/json" "github.com/lestrrat-go/jwx/v2/jwa" "github.com/lestrrat-go/jwx/v2/jwt" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/vcr/assets" "github.com/stretchr/testify/require" "testing" @@ -75,12 +76,13 @@ func ValidNutsOrganizationCredential(t *testing.T) vc.VerifiableCredential { return inputVC } -func JWTNutsOrganizationCredential(t *testing.T) vc.VerifiableCredential { +func JWTNutsOrganizationCredential(t *testing.T, subjectID did.DID) vc.VerifiableCredential { privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) require.NoError(t, err) token := jwt.New() require.NoError(t, token.Set("vc", map[string]interface{}{ "credentialSubject": map[string]interface{}{ + "id": subjectID, "organization": map[string]interface{}{ "city": "IJbergen", "name": "care", diff --git a/vcr/credential/util.go b/vcr/credential/util.go new file mode 100644 index 0000000000..afbf2d1884 --- /dev/null +++ b/vcr/credential/util.go @@ -0,0 +1,110 @@ +/* + * 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 credential + +import ( + "errors" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/go-did/vc" + "time" +) + +// ResolveSubjectDID resolves the subject DID from the given credentials. +// It returns an error if: +// - the credentials do not have the same subject DID. +// - the credentials do not have a subject DID. +func ResolveSubjectDID(credentials ...vc.VerifiableCredential) (*did.DID, error) { + var subjectID did.DID + for _, credential := range credentials { + sid, err := credential.SubjectDID() + if err != nil { + return nil, err + } + if !subjectID.Empty() && !subjectID.Equals(*sid) { + return nil, errors.New("not all VCs have the same credentialSubject.id") + } + subjectID = *sid + } + return &subjectID, nil +} + +// PresenterIsCredentialSubject checks if the presenter of the VP is the same as the subject of the VCs being presented. +// If the presentation signer or credential subject can't be resolved, it returns an error. +// If parsing succeeds and the signer DID is the same as the credential subject DID, it returns the DID. +func PresenterIsCredentialSubject(vp vc.VerifiablePresentation) (*did.DID, error) { + signerDID, err := PresentationSigner(vp) + if err != nil { + return nil, err + } + credentialSubjectID, err := ResolveSubjectDID(vp.VerifiableCredential...) + if err != nil { + return nil, err + } + if !credentialSubjectID.Equals(*signerDID) { + return nil, nil + } + return signerDID, nil +} + +// PresentationIssuanceDate returns the date at which the presentation was issued. +// For JSON-LD, it looks at the first LinkedData proof's 'created' property. +// For JWT, it looks at the 'nbf' claim, or if that is not present, the 'iat' claim. +// If it can't resolve the date, it returns nil. +func PresentationIssuanceDate(presentation vc.VerifiablePresentation) *time.Time { + var result time.Time + switch presentation.Format() { + case vc.JWTPresentationProofFormat: + jwt := presentation.JWT() + if result = jwt.NotBefore(); result.IsZero() { + result = jwt.IssuedAt() + } + case vc.JSONLDPresentationProofFormat: + ldProof, err := ParseLDProof(presentation) + if err != nil { + return nil + } + result = ldProof.Created + } + if result.IsZero() { + return nil + } + return &result +} + +// PresentationExpirationDate returns the date at which the presentation was issued. +// For JSON-LD, it looks at the first LinkedData proof's 'expires' property. +// For JWT, it looks at the 'exp' claim. +// If it can't resolve the date, it returns nil. +func PresentationExpirationDate(presentation vc.VerifiablePresentation) *time.Time { + var result time.Time + switch presentation.Format() { + case vc.JWTPresentationProofFormat: + result = presentation.JWT().Expiration() + case vc.JSONLDPresentationProofFormat: + ldProof, err := ParseLDProof(presentation) + if err != nil || ldProof.Expires == nil { + return nil + } + result = *ldProof.Expires + } + if result.IsZero() { + return nil + } + return &result +} diff --git a/vcr/credential/util_test.go b/vcr/credential/util_test.go new file mode 100644 index 0000000000..e7eba1c518 --- /dev/null +++ b/vcr/credential/util_test.go @@ -0,0 +1,289 @@ +/* + * 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 credential + +import ( + "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/nuts-foundation/nuts-node/vcr/signature/proof" + "github.com/nuts-foundation/nuts-node/vcr/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func TestResolveSubjectDID(t *testing.T) { + did1 := did.MustParseDID("did:test:123") + did2 := did.MustParseDID("did:test:456") + credential1 := vc.VerifiableCredential{ + CredentialSubject: []interface{}{map[string]interface{}{"id": did1}}, + } + credential2 := vc.VerifiableCredential{ + CredentialSubject: []interface{}{map[string]interface{}{"id": did1}}, + } + credential3 := vc.VerifiableCredential{ + CredentialSubject: []interface{}{map[string]interface{}{"id": did2}}, + } + t.Run("all the same", func(t *testing.T) { + actual, err := ResolveSubjectDID(credential1, credential2) + assert.NoError(t, err) + assert.Equal(t, did1, *actual) + }) + t.Run("differ", func(t *testing.T) { + actual, err := ResolveSubjectDID(credential1, credential3) + assert.EqualError(t, err, "not all VCs have the same credentialSubject.id") + assert.Nil(t, actual) + }) + t.Run("no ID", func(t *testing.T) { + actual, err := ResolveSubjectDID(vc.VerifiableCredential{CredentialSubject: []interface{}{map[string]interface{}{}}}) + assert.EqualError(t, err, "unable to get subject DID from VC: credential subjects have no ID") + assert.Nil(t, actual) + }) + t.Run("no credentialSubject", func(t *testing.T) { + actual, err := ResolveSubjectDID(vc.VerifiableCredential{}) + assert.EqualError(t, err, "unable to get subject DID from VC: there must be at least 1 credentialSubject") + assert.Nil(t, actual) + }) + +} + +func TestPresenterIsCredentialSubject(t *testing.T) { + subjectDID := did.MustParseDID("did:test:123") + keyID := ssi.MustParseURI("did:test:123#1") + t.Run("ok", func(t *testing.T) { + vp := test.ParsePresentation(t, vc.VerifiablePresentation{ + Proof: []interface{}{ + proof.LDProof{ + Type: "JsonWebSignature2020", + VerificationMethod: keyID, + }, + }, + VerifiableCredential: []vc.VerifiableCredential{ + { + CredentialSubject: []interface{}{map[string]interface{}{"id": subjectDID}}, + }, + }, + }) + is, err := PresenterIsCredentialSubject(vp) + assert.NoError(t, err) + assert.Equal(t, subjectDID, *is) + }) + t.Run("no proof", func(t *testing.T) { + vp := test.ParsePresentation(t, vc.VerifiablePresentation{}) + actual, err := PresenterIsCredentialSubject(vp) + assert.EqualError(t, err, "presentation should have exactly 1 proof, got 0") + assert.Nil(t, actual) + }) + t.Run("no VC subject", func(t *testing.T) { + vp := test.ParsePresentation(t, vc.VerifiablePresentation{ + Proof: []interface{}{ + proof.LDProof{ + Type: "JsonWebSignature2020", + VerificationMethod: keyID, + }, + }, + VerifiableCredential: []vc.VerifiableCredential{ + {}, + }, + }) + is, err := PresenterIsCredentialSubject(vp) + assert.EqualError(t, err, "unable to get subject DID from VC: there must be at least 1 credentialSubject") + assert.Nil(t, is) + }) + t.Run("no VC subject ID", func(t *testing.T) { + vp := test.ParsePresentation(t, vc.VerifiablePresentation{ + Proof: []interface{}{ + proof.LDProof{ + Type: "JsonWebSignature2020", + VerificationMethod: keyID, + }, + }, + VerifiableCredential: []vc.VerifiableCredential{ + { + CredentialSubject: []interface{}{map[string]interface{}{}}, + }, + }, + }) + is, err := PresenterIsCredentialSubject(vp) + assert.EqualError(t, err, "unable to get subject DID from VC: credential subjects have no ID") + assert.Nil(t, is) + }) + t.Run("proof verification method does not equal VC subject ID", func(t *testing.T) { + vp := test.ParsePresentation(t, vc.VerifiablePresentation{ + Proof: []interface{}{ + proof.LDProof{ + Type: "JsonWebSignature2020", + VerificationMethod: keyID, + }, + }, + VerifiableCredential: []vc.VerifiableCredential{ + { + CredentialSubject: []interface{}{map[string]interface{}{"id": did.MustParseDID("did:test:456")}}, + }, + }, + }) + is, err := PresenterIsCredentialSubject(vp) + assert.NoError(t, err) + assert.Nil(t, is) + }) + t.Run("proof type is unsupported", func(t *testing.T) { + vp := test.ParsePresentation(t, vc.VerifiablePresentation{ + Proof: []interface{}{ + true, + }, + VerifiableCredential: []vc.VerifiableCredential{ + { + CredentialSubject: []interface{}{map[string]interface{}{"id": subjectDID}}, + }, + }, + }) + is, err := PresenterIsCredentialSubject(vp) + assert.EqualError(t, err, "invalid LD-proof for presentation: json: cannot unmarshal bool into Go value of type proof.LDProof") + assert.Nil(t, is) + }) + t.Run("too many proofs", func(t *testing.T) { + vp := test.ParsePresentation(t, vc.VerifiablePresentation{ + Proof: []interface{}{ + proof.LDProof{}, + proof.LDProof{}, + }, + VerifiableCredential: []vc.VerifiableCredential{ + { + CredentialSubject: []interface{}{map[string]interface{}{"id": subjectDID}}, + }, + }, + }) + is, err := PresenterIsCredentialSubject(vp) + assert.EqualError(t, err, "presentation should have exactly 1 proof, got 2") + assert.Nil(t, is) + }) +} + +func TestPresentationIssuanceDate(t *testing.T) { + presenterDID := did.MustParseDID("did:test:123") + expected := time.Now().In(time.UTC).Truncate(time.Second) + t.Run("JWT iat", func(t *testing.T) { + presentation := test.CreateJWTPresentation(t, presenterDID, func(token jwt.Token) { + _ = token.Remove(jwt.NotBeforeKey) + require.NoError(t, token.Set(jwt.IssuedAtKey, expected)) + }) + actual := PresentationIssuanceDate(presentation) + assert.Equal(t, expected, *actual) + }) + t.Run("JWT nbf", func(t *testing.T) { + presentation := test.CreateJWTPresentation(t, presenterDID, func(token jwt.Token) { + _ = token.Remove(jwt.IssuedAtKey) + require.NoError(t, token.Set(jwt.NotBeforeKey, expected)) + }) + actual := PresentationIssuanceDate(presentation) + assert.Equal(t, expected, *actual) + }) + t.Run("JWT nbf takes precedence over iat", func(t *testing.T) { + presentation := test.CreateJWTPresentation(t, presenterDID, func(token jwt.Token) { + require.NoError(t, token.Set(jwt.IssuedAtKey, expected.Add(time.Hour))) + require.NoError(t, token.Set(jwt.NotBeforeKey, expected)) + }) + actual := PresentationIssuanceDate(presentation) + assert.Equal(t, expected, *actual) + }) + t.Run("JWT no iat or nbf", func(t *testing.T) { + presentation := test.CreateJWTPresentation(t, presenterDID, func(token jwt.Token) { + _ = token.Remove(jwt.IssuedAtKey) + _ = token.Remove(jwt.NotBeforeKey) + }) + actual := PresentationIssuanceDate(presentation) + assert.Nil(t, actual) + }) + t.Run("JSON-LD", func(t *testing.T) { + presentation := test.ParsePresentation(t, vc.VerifiablePresentation{ + Proof: []interface{}{ + proof.LDProof{ + ProofOptions: proof.ProofOptions{ + Created: expected, + }, + }, + }, + }) + actual := PresentationIssuanceDate(presentation) + assert.Equal(t, expected, *actual) + }) + t.Run("JSON-LD no proof", func(t *testing.T) { + presentation := test.ParsePresentation(t, vc.VerifiablePresentation{}) + actual := PresentationIssuanceDate(presentation) + assert.Nil(t, actual) + }) + t.Run("JSON-LD no created", func(t *testing.T) { + presentation := test.ParsePresentation(t, vc.VerifiablePresentation{ + Proof: []interface{}{ + proof.LDProof{}, + }, + }) + actual := PresentationIssuanceDate(presentation) + assert.Nil(t, actual) + }) +} + +func TestPresentationExpirationDate(t *testing.T) { + presenterDID := did.MustParseDID("did:test:123") + expected := time.Now().In(time.UTC).Truncate(time.Second) + t.Run("JWT", func(t *testing.T) { + presentation := test.CreateJWTPresentation(t, presenterDID, func(token jwt.Token) { + require.NoError(t, token.Set(jwt.ExpirationKey, expected)) + }) + actual := PresentationExpirationDate(presentation) + assert.Equal(t, expected, *actual) + }) + t.Run("JWT no exp", func(t *testing.T) { + presentation := test.CreateJWTPresentation(t, presenterDID, func(token jwt.Token) { + _ = token.Remove(jwt.ExpirationKey) + }) + actual := PresentationExpirationDate(presentation) + assert.Nil(t, actual) + }) + t.Run("JSON-LD", func(t *testing.T) { + presentation := test.ParsePresentation(t, vc.VerifiablePresentation{ + Proof: []interface{}{ + proof.LDProof{ + ProofOptions: proof.ProofOptions{ + Expires: &expected, + }, + }, + }, + }) + actual := PresentationExpirationDate(presentation) + assert.Equal(t, expected, *actual) + }) + t.Run("JSON-LD no proof", func(t *testing.T) { + presentation := test.ParsePresentation(t, vc.VerifiablePresentation{}) + actual := PresentationExpirationDate(presentation) + assert.Nil(t, actual) + }) + t.Run("JSON-LD no expires", func(t *testing.T) { + presentation := test.ParsePresentation(t, vc.VerifiablePresentation{ + Proof: []interface{}{ + proof.LDProof{}, + }, + }) + actual := PresentationExpirationDate(presentation) + assert.Nil(t, actual) + }) +} diff --git a/vcr/holder/wallet.go b/vcr/holder/wallet.go index ca5d6275dc..38b8761a4e 100644 --- a/vcr/holder/wallet.go +++ b/vcr/holder/wallet.go @@ -34,6 +34,7 @@ import ( "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/jsonld" + "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/log" "github.com/nuts-foundation/nuts-node/vcr/signature" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" @@ -71,7 +72,7 @@ func New( func (h wallet) BuildPresentation(ctx context.Context, credentials []vc.VerifiableCredential, options PresentationOptions, signerDID *did.DID, validateVC bool) (*vc.VerifiablePresentation, error) { var err error if signerDID == nil { - signerDID, err = h.resolveSubjectDID(credentials) + signerDID, err = credential.ResolveSubjectDID(credentials...) if err != nil { return nil, fmt.Errorf("unable to resolve signer DID from VCs for creating VP: %w", err) } @@ -124,6 +125,12 @@ func (h wallet) buildJWTPresentation(ctx context.Context, subjectDID did.DID, cr 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 { @@ -166,9 +173,8 @@ func (h wallet) buildJSONLDPresentation(ctx context.Context, subjectDID did.DID, return nil, err } - // TODO: choose between different proof types (JWT or LD-Proof) - signingResult, err := proof. - NewLDProof(options.ProofOptions). + ldProof := proof.NewLDProof(options.ProofOptions) + signingResult, err := ldProof. Sign(ctx, document, signature.JSONWebSignature2020{ContextLoader: h.jsonldManager.DocumentLoader(), Signer: h.keyStore}, key) if err != nil { return nil, fmt.Errorf("unable to sign VP with LD proof: %w", err) @@ -181,12 +187,12 @@ func (h wallet) Put(ctx context.Context, credentials ...vc.VerifiableCredential) err := h.walletStore.Write(ctx, func(tx stoabs.WriteTx) error { stats := tx.GetShelfWriter(statsShelf) var newCredentials uint32 - for _, credential := range credentials { - subjectDID, err := h.resolveSubjectDID([]vc.VerifiableCredential{credential}) + for _, curr := range credentials { + subjectDID, err := curr.SubjectDID() if err != nil { - return fmt.Errorf("unable to resolve subject DID from VC %s: %w", credential.ID, err) + return fmt.Errorf("unable to resolve subject DID from VC %s: %w", curr.ID, err) } - walletKey := stoabs.BytesKey(credential.ID.String()) + walletKey := stoabs.BytesKey(curr.ID.String()) // First check if the VC doesn't already exist; otherwise stats will be incorrect walletShelf := tx.GetShelfWriter(subjectDID.String()) _, err = walletShelf.Get(walletKey) @@ -195,13 +201,13 @@ func (h wallet) Put(ctx context.Context, credentials ...vc.VerifiableCredential) continue } else if !errors.Is(err, stoabs.ErrKeyNotFound) { // Other error - return fmt.Errorf("unable to check if credential %s already exists: %w", credential.ID, err) + return fmt.Errorf("unable to check if credential %s already exists: %w", curr.ID, err) } // Write credential - data, _ := credential.MarshalJSON() + data, _ := curr.MarshalJSON() err = walletShelf.Put(walletKey, data) if err != nil { - return fmt.Errorf("unable to store credential %s: %w", credential.ID, err) + return fmt.Errorf("unable to store credential %s: %w", curr.ID, err) } newCredentials++ } @@ -267,26 +273,6 @@ func (h wallet) IsEmpty() (bool, error) { return count == 0, err } -func (h wallet) resolveSubjectDID(credentials []vc.VerifiableCredential) (*did.DID, error) { - var subjectID did.DID - for _, credential := range credentials { - sid, err := credential.SubjectDID() - if err != nil { - return nil, err - } - if !subjectID.Empty() && !subjectID.Equals(*sid) { - return nil, errors.New("not all VCs have the same credentialSubject.id") - } - subjectID = *sid - } - - if subjectID.Empty() { - return nil, errors.New("could not resolve subject DID from VCs") - } - - return &subjectID, nil -} - func (h wallet) readCredentialCount(statsShelf stoabs.Reader) (uint32, error) { countBytes, err := statsShelf.Get(credentialCountStatsKey) if errors.Is(err, stoabs.ErrKeyNotFound) { diff --git a/vcr/holder/wallet_test.go b/vcr/holder/wallet_test.go index 6d6d0b0df8..10c0521370 100644 --- a/vcr/holder/wallet_test.go +++ b/vcr/holder/wallet_test.go @@ -89,15 +89,22 @@ func TestWallet_BuildPresentation(t *testing.T) { assert.Equal(t, testDID, did.MustParseDIDURL(result.ID.String()).DID, "id must be the DID of the holder") assert.NotEmpty(t, result.ID.Fragment, "id must have a fragment") assert.Equal(t, JSONLDPresentationFormat, result.Format()) + ldProof, err := credential.ParseLDProof(*result) + require.NoError(t, err) + assert.Empty(t, ldProof.Nonce) }) t.Run("ok - custom options", func(t *testing.T) { ctrl := gomock.NewController(t) specialType := ssi.MustParseURI("SpecialPresentation") + domain := "https://example.com" + nonce := "the-nonce" options := PresentationOptions{ AdditionalContexts: []ssi.URI{credential.NutsV1ContextURI}, AdditionalTypes: []ssi.URI{specialType}, ProofOptions: proof.ProofOptions{ ProofPurpose: "authentication", + Domain: &domain, + Nonce: &nonce, }, Format: JSONLDPresentationFormat, } @@ -113,9 +120,12 @@ func TestWallet_BuildPresentation(t *testing.T) { require.NotNil(t, result) assert.True(t, result.IsType(specialType)) assert.True(t, result.ContainsContext(credential.NutsV1ContextURI)) - proofs, _ := result.Proofs() + var proofs []proof.LDProof + require.NoError(t, result.UnmarshalProofValue(&proofs)) require.Len(t, proofs, 1) - assert.Equal(t, proofs[0].ProofPurpose, "authentication") + assert.Equal(t, "authentication", proofs[0].ProofPurpose) + assert.Equal(t, "https://example.com", *proofs[0].Domain) + assert.Equal(t, nonce, *proofs[0].Nonce) assert.Equal(t, JSONLDPresentationFormat, result.Format()) }) t.Run("ok - multiple VCs", func(t *testing.T) { @@ -152,6 +162,8 @@ func TestWallet_BuildPresentation(t *testing.T) { assert.NotEmpty(t, result.ID.Fragment, "id must have a fragment") assert.Equal(t, JWTPresentationFormat, result.Format()) assert.NotNil(t, result.JWT()) + nonce, _ := result.JWT().Get("nonce") + assert.Empty(t, nonce) }) t.Run("ok - multiple VCs", func(t *testing.T) { ctrl := gomock.NewController(t) @@ -171,11 +183,15 @@ func TestWallet_BuildPresentation(t *testing.T) { }) t.Run("optional proof options", func(t *testing.T) { exp := time.Now().Local().Truncate(time.Second) + domain := "https://example.com" + nonce := "the-nonce" options := PresentationOptions{ Format: JWTPresentationFormat, ProofOptions: proof.ProofOptions{ Expires: &exp, Created: exp.Add(-1 * time.Hour), + Domain: &domain, + Nonce: &nonce, }, } @@ -194,6 +210,9 @@ func TestWallet_BuildPresentation(t *testing.T) { assert.NotNil(t, result.JWT()) assert.Equal(t, *options.ProofOptions.Expires, result.JWT().Expiration().Local()) assert.Equal(t, options.ProofOptions.Created, result.JWT().NotBefore().Local()) + assert.Equal(t, []string{domain}, result.JWT().Audience()) + actualNonce, _ := result.JWT().Get("nonce") + assert.Equal(t, nonce, actualNonce) }) }) t.Run("validation", func(t *testing.T) { diff --git a/vcr/pe/presentation_definition_test.go b/vcr/pe/presentation_definition_test.go index 431d264d6a..e8e5515d37 100644 --- a/vcr/pe/presentation_definition_test.go +++ b/vcr/pe/presentation_definition_test.go @@ -24,6 +24,7 @@ import ( "crypto/rand" "embed" "encoding/json" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/vcr/credential" "testing" @@ -89,7 +90,7 @@ func definitions() testDefinitions { func TestMatch(t *testing.T) { jsonldVC := credential.ValidNutsOrganizationCredential(t) - jwtVC := credential.JWTNutsOrganizationCredential(t) + jwtVC := credential.JWTNutsOrganizationCredential(t, did.MustParseDID("did:web:example.com")) t.Run("Basic", func(t *testing.T) { t.Run("JSON-LD", func(t *testing.T) { @@ -671,8 +672,9 @@ func Test_matchFilter(t *testing.T) { } func TestPresentationDefinition_ResolveConstraintsFields(t *testing.T) { - jwtCredential := credential.JWTNutsOrganizationCredential(t) - jsonldCredential := credential.JWTNutsOrganizationCredential(t) + subjectDID := did.MustParseDID("did:web:example.com") + jwtCredential := credential.JWTNutsOrganizationCredential(t, subjectDID) + jsonldCredential := credential.JWTNutsOrganizationCredential(t, subjectDID) definition := definitions().JSONLDorJWT t.Run("match JWT", func(t *testing.T) { credentialMap := map[string]vc.VerifiableCredential{ diff --git a/vcr/pe/test.go b/vcr/pe/test.go new file mode 100644 index 0000000000..7930e11657 --- /dev/null +++ b/vcr/pe/test.go @@ -0,0 +1,30 @@ +/* + * 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 ( + "github.com/stretchr/testify/require" + "testing" +) + +func TestDefinitionResolver(t testing.TB) *DefinitionResolver { + peStore := &DefinitionResolver{} + require.NoError(t, peStore.LoadFromFile("test/presentation_definition_mapping.json")) + return peStore +} diff --git a/vcr/pe/util_test.go b/vcr/pe/util_test.go index 291e8f0054..4b027a6c47 100644 --- a/vcr/pe/util_test.go +++ b/vcr/pe/util_test.go @@ -30,7 +30,7 @@ import ( func TestParseEnvelope(t *testing.T) { t.Run("JWT", func(t *testing.T) { - presentation := test.CreateJWTPresentation(t, did.MustParseDID("did:example:1"), credential.ValidNutsOrganizationCredential(t)) + presentation := test.CreateJWTPresentation(t, did.MustParseDID("did:example:1"), nil, credential.ValidNutsOrganizationCredential(t)) envelope, err := ParseEnvelope([]byte(presentation.Raw())) require.NoError(t, err) require.Equal(t, presentation.ID.String(), envelope.asInterface.(map[string]interface{})["id"]) @@ -59,7 +59,7 @@ func TestParseEnvelope(t *testing.T) { 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)) + presentation := test.CreateJWTPresentation(t, did.MustParseDID("did:example:1"), nil, credential.ValidNutsOrganizationCredential(t)) presentations := []string{presentation.Raw(), presentation.Raw()} listJSON, _ := json.Marshal(presentations) envelope, err := ParseEnvelope(listJSON) diff --git a/vcr/signature/proof/jsonld.go b/vcr/signature/proof/jsonld.go index 124ff075fd..ff5b71fd2b 100644 --- a/vcr/signature/proof/jsonld.go +++ b/vcr/signature/proof/jsonld.go @@ -62,6 +62,8 @@ type ProofOptions struct { // ProofPurpose contains a specific intent for the proof, the reason why an entity created it. // Acts as a safeguard to prevent the proof from being misused for a purpose other than the one it was intended for. ProofPurpose string `json:"proofPurpose"` + // Nonce contains a value that is used to prevent replay attacks + Nonce *string `json:"nonce,omitempty"` } // ValidAt checks if the proof is valid at a certain given time. @@ -81,7 +83,6 @@ func (o ProofOptions) ValidAt(at time.Time, maxSkew time.Duration) bool { // LDProof contains the fields of the Proof data model: https://w3c-ccg.github.io/data-integrity-spec/#proofs type LDProof struct { ProofOptions - Nonce *string `json:"nonce,omitempty"` // Type contains the signature type. Its is determined from the key type. Type ssi.ProofType `json:"type"` // VerificationMethod is the key identifier for the public/private key pair used to sign this proof diff --git a/vcr/test/test.go b/vcr/test/test.go index 9699576e45..ceb1793be9 100644 --- a/vcr/test/test.go +++ b/vcr/test/test.go @@ -29,13 +29,15 @@ import ( 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/crypto" + "github.com/nuts-foundation/nuts-node/vcr/signature/proof" "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 { +func CreateJWTPresentation(t *testing.T, subjectDID did.DID, tokenVisitor func(token jwt.Token), credentials ...vc.VerifiableCredential) vc.VerifiablePresentation { headers := jws.NewHeaders() require.NoError(t, headers.Set(jws.TypeKey, "JWT")) claims := map[string]interface{}{ @@ -43,7 +45,8 @@ func CreateJWTPresentation(t *testing.T, subjectDID did.DID, credentials ...vc.V jwt.SubjectKey: subjectDID.String(), jwt.JwtIDKey: subjectDID.String() + "#" + uuid.NewString(), jwt.NotBeforeKey: time.Now().Unix(), - jwt.ExpirationKey: time.Now().Add(time.Hour).Unix(), + jwt.ExpirationKey: time.Now().Add(5 * time.Second).Unix(), + "nonce": crypto.GenerateNonce(), "vp": vc.VerifiablePresentation{ Type: []ssi.URI{vc.VerifiablePresentationTypeV1URI()}, VerifiableCredential: credentials, @@ -53,9 +56,57 @@ func CreateJWTPresentation(t *testing.T, subjectDID did.DID, credentials ...vc.V for k, v := range claims { require.NoError(t, unsignedToken.Set(k, v)) } + if tokenVisitor != nil { + tokenVisitor(unsignedToken) + } 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 } + +// CreateJSONLDPresentation creates a JSON-LD presentation with the given subject DID and credentials. +// The presentation is not actually signed. +func CreateJSONLDPresentation(t *testing.T, subjectDID did.DID, visitor func(presentation *vc.VerifiablePresentation), verifiableCredential ...vc.VerifiableCredential) vc.VerifiablePresentation { + id := ssi.MustParseURI(subjectDID.String() + "#" + uuid.NewString()) + exp := time.Now().Add(5 * time.Second) + nonce := crypto.GenerateNonce() + vp := vc.VerifiablePresentation{ + ID: &id, + VerifiableCredential: verifiableCredential, + Proof: []interface{}{ + proof.LDProof{ + Type: ssi.JsonWebSignature2020, + VerificationMethod: ssi.MustParseURI(subjectDID.String() + "#1"), + ProofOptions: proof.ProofOptions{ + Created: time.Now(), + Expires: &exp, + Nonce: &nonce, + }, + }, + }, + } + if visitor != nil { + visitor(&vp) + } + return ParsePresentation(t, vp) +} + +// LDProofVisitor is a util function that creates a visitor for CreateJSONLDPresentation to easily modify the LinkedData proof. +func LDProofVisitor(visitor func(proof *proof.LDProof)) func(*vc.VerifiablePresentation) { + return func(presentation *vc.VerifiablePresentation) { + ldProof := presentation.Proof[0].(proof.LDProof) + visitor(&ldProof) + presentation.Proof[0] = ldProof + } +} + +// ParsePresentation marshals the given presentation and parses it again, to make sure the format property is set correctly. +func ParsePresentation(t *testing.T, presentation vc.VerifiablePresentation) vc.VerifiablePresentation { + data, err := presentation.MarshalJSON() + require.NoError(t, err) + result, err := vc.ParseVerifiablePresentation(string(data)) + require.NoError(t, err) + return *result +} diff --git a/vcr/verifier/verifier.go b/vcr/verifier/verifier.go index e52325e420..31df90d8be 100644 --- a/vcr/verifier/verifier.go +++ b/vcr/verifier/verifier.go @@ -328,12 +328,10 @@ func (v *verifier) validateJSONLDPresentation(presentation vc.VerifiablePresenta return newVerificationError("exactly 1 proof is expected") } // Make sure the proofs are LD-proofs - var ldProofs []proof.LDProof - err := presentation.UnmarshalProofValue(&ldProofs) + ldProof, err := credential.ParseLDProof(presentation) if err != nil { return newVerificationError("unsupported proof type: %w", err) } - ldProof := ldProofs[0] // Validate signing time at := timeFunc() @@ -377,7 +375,7 @@ func (v *verifier) validateJWTPresentation(presentation vc.VerifiablePresentatio return time.Now() } return *at - }))) + })), jwt.WithAcceptableSkew(maxSkew)) if err != nil { return fmt.Errorf("unable to validate JWT credential: %w", err) } diff --git a/vcr/verifier/verifier_test.go b/vcr/verifier/verifier_test.go index 964ee07082..2b18e30c04 100644 --- a/vcr/verifier/verifier_test.go +++ b/vcr/verifier/verifier_test.go @@ -744,7 +744,7 @@ func TestVerifier_VerifyVP(t *testing.T) { vcs, err := ctx.verifier.VerifyVP(vp, false, false, validAt) - assert.EqualError(t, err, "verification error: unsupported proof type: json: cannot unmarshal string into Go value of type proof.LDProof") + assert.EqualError(t, err, "verification error: unsupported proof type: invalid LD-proof for presentation: json: cannot unmarshal string into Go value of type proof.LDProof") assert.Empty(t, vcs) }) t.Run("error - no proof", func(t *testing.T) { diff --git a/vdr/cmd/cmd.go b/vdr/cmd/cmd.go index 6ff996c9ae..b921d58dad 100644 --- a/vdr/cmd/cmd.go +++ b/vdr/cmd/cmd.go @@ -82,7 +82,7 @@ func createCmd() *cobra.Command { result := &cobra.Command{ Use: "create-did", Short: "Registers a new DID", - Long: "When using the V2 API, a web:did will be created. All the other options are ignored for a web:did.", + Long: "When using the V2 API, a did:web DID will be created. All the other options are ignored for did:web.", Args: cobra.ExactArgs(0), RunE: func(cmd *cobra.Command, args []string) error { clientConfig := core.NewClientConfigForCommand(cmd) @@ -118,7 +118,7 @@ func createCmd() *cobra.Command { result.Flags().BoolVar(createRequest.CapabilityInvocation, "capabilityInvocation", defs.KeyFlags.Is(management.CapabilityInvocationUsage), setUsage(defs.KeyFlags.Is(management.CapabilityInvocationUsage), "Pass '%t' to %s capabilityInvocation capabilities.")) result.Flags().BoolVar(createRequest.KeyAgreement, "keyAgreement", defs.KeyFlags.Is(management.KeyAgreementUsage), setUsage(defs.KeyFlags.Is(management.KeyAgreementUsage), "Pass '%t' to %s keyAgreement capabilities.")) result.Flags().BoolVar(createRequest.SelfControl, "selfControl", defs.SelfControl, setUsage(defs.SelfControl, "Pass '%t' to %s DID Document control.")) - result.Flags().BoolVar(&useV2, "v2", false, "Pass 'true' to use the V2 API and create a web:did.") + result.Flags().BoolVar(&useV2, "v2", false, "Pass 'true' to use the V2 API and create a did:web DID.") result.Flags().StringSliceVar(createRequest.Controllers, "controllers", []string{}, "Comma-separated list of DIDs that can control the generated DID Document.") return result diff --git a/vdr/management/management_mock.go b/vdr/management/management_mock.go index 6ae0dbd41f..f3d5f10cc2 100644 --- a/vdr/management/management_mock.go +++ b/vdr/management/management_mock.go @@ -14,138 +14,9 @@ import ( did "github.com/nuts-foundation/go-did/did" crypto "github.com/nuts-foundation/nuts-node/crypto" - resolver "github.com/nuts-foundation/nuts-node/vdr/resolver" gomock "go.uber.org/mock/gomock" ) -// MockManager is a mock of Manager interface. -type MockManager struct { - ctrl *gomock.Controller - recorder *MockManagerMockRecorder -} - -// MockManagerMockRecorder is the mock recorder for MockManager. -type MockManagerMockRecorder struct { - mock *MockManager -} - -// NewMockManager creates a new mock instance. -func NewMockManager(ctrl *gomock.Controller) *MockManager { - mock := &MockManager{ctrl: ctrl} - mock.recorder = &MockManagerMockRecorder{mock} - return mock -} - -// EXPECT returns an object that allows the caller to indicate expected use. -func (m *MockManager) EXPECT() *MockManagerMockRecorder { - return m.recorder -} - -// AddVerificationMethod mocks base method. -func (m *MockManager) AddVerificationMethod(ctx context.Context, id did.DID, keyUsage DIDKeyFlags) (*did.VerificationMethod, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "AddVerificationMethod", ctx, id, keyUsage) - ret0, _ := ret[0].(*did.VerificationMethod) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// AddVerificationMethod indicates an expected call of AddVerificationMethod. -func (mr *MockManagerMockRecorder) AddVerificationMethod(ctx, id, keyUsage any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "AddVerificationMethod", reflect.TypeOf((*MockManager)(nil).AddVerificationMethod), ctx, id, keyUsage) -} - -// Create mocks base method. -func (m *MockManager) Create(ctx context.Context, method string, options DIDCreationOptions) (*did.Document, crypto.Key, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", ctx, method, options) - ret0, _ := ret[0].(*did.Document) - ret1, _ := ret[1].(crypto.Key) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// Create indicates an expected call of Create. -func (mr *MockManagerMockRecorder) Create(ctx, method, options any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockManager)(nil).Create), ctx, method, options) -} - -// Deactivate mocks base method. -func (m *MockManager) Deactivate(ctx context.Context, id did.DID) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Deactivate", ctx, id) - ret0, _ := ret[0].(error) - return ret0 -} - -// Deactivate indicates an expected call of Deactivate. -func (mr *MockManagerMockRecorder) Deactivate(ctx, id any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Deactivate", reflect.TypeOf((*MockManager)(nil).Deactivate), ctx, id) -} - -// IsOwner mocks base method. -func (m *MockManager) IsOwner(arg0 context.Context, arg1 did.DID) (bool, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "IsOwner", arg0, arg1) - ret0, _ := ret[0].(bool) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// IsOwner indicates an expected call of IsOwner. -func (mr *MockManagerMockRecorder) IsOwner(arg0, arg1 any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsOwner", reflect.TypeOf((*MockManager)(nil).IsOwner), arg0, arg1) -} - -// ListOwned mocks base method. -func (m *MockManager) ListOwned(ctx context.Context) ([]did.DID, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "ListOwned", ctx) - ret0, _ := ret[0].([]did.DID) - ret1, _ := ret[1].(error) - return ret0, ret1 -} - -// ListOwned indicates an expected call of ListOwned. -func (mr *MockManagerMockRecorder) ListOwned(ctx any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListOwned", reflect.TypeOf((*MockManager)(nil).ListOwned), ctx) -} - -// RemoveVerificationMethod mocks base method. -func (m *MockManager) RemoveVerificationMethod(ctx context.Context, id did.DID, keyID did.DIDURL) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "RemoveVerificationMethod", ctx, id, keyID) - ret0, _ := ret[0].(error) - return ret0 -} - -// RemoveVerificationMethod indicates an expected call of RemoveVerificationMethod. -func (mr *MockManagerMockRecorder) RemoveVerificationMethod(ctx, id, keyID any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "RemoveVerificationMethod", reflect.TypeOf((*MockManager)(nil).RemoveVerificationMethod), ctx, id, keyID) -} - -// Resolve mocks base method. -func (m *MockManager) Resolve(id did.DID, metadata *resolver.ResolveMetadata) (*did.Document, *resolver.DocumentMetadata, error) { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Resolve", id, metadata) - ret0, _ := ret[0].(*did.Document) - ret1, _ := ret[1].(*resolver.DocumentMetadata) - ret2, _ := ret[2].(error) - return ret0, ret1, ret2 -} - -// Resolve indicates an expected call of Resolve. -func (mr *MockManagerMockRecorder) Resolve(id, metadata any) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Resolve", reflect.TypeOf((*MockManager)(nil).Resolve), id, metadata) -} - // MockDocCreator is a mock of DocCreator interface. type MockDocCreator struct { ctrl *gomock.Controller @@ -170,9 +41,9 @@ func (m *MockDocCreator) EXPECT() *MockDocCreatorMockRecorder { } // Create mocks base method. -func (m *MockDocCreator) Create(ctx context.Context, method string, options DIDCreationOptions) (*did.Document, crypto.Key, error) { +func (m *MockDocCreator) Create(ctx context.Context, options DIDCreationOptions) (*did.Document, crypto.Key, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Create", ctx, method, options) + ret := m.ctrl.Call(m, "Create", ctx, options) ret0, _ := ret[0].(*did.Document) ret1, _ := ret[1].(crypto.Key) ret2, _ := ret[2].(error) @@ -180,9 +51,9 @@ func (m *MockDocCreator) Create(ctx context.Context, method string, options DIDC } // Create indicates an expected call of Create. -func (mr *MockDocCreatorMockRecorder) Create(ctx, method, options any) *gomock.Call { +func (mr *MockDocCreatorMockRecorder) Create(ctx, options any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockDocCreator)(nil).Create), ctx, method, options) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockDocCreator)(nil).Create), ctx, options) } // MockDocUpdater is a mock of DocUpdater interface.