diff --git a/Dockerfile b/Dockerfile index 8c0e1df897..a2cc2894ef 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # golang alpine -FROM golang:1.21.1-alpine as builder +FROM golang:1.21.3-alpine as builder ARG TARGETARCH ARG TARGETOS diff --git a/auth/api/auth/v1/api_test.go b/auth/api/auth/v1/api_test.go index 62879525f0..fd1b7a7b78 100644 --- a/auth/api/auth/v1/api_test.go +++ b/auth/api/auth/v1/api_test.go @@ -40,6 +40,7 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" "net/http" + "net/http/httptest" "net/url" "reflect" "testing" @@ -165,8 +166,12 @@ func TestWrapper_GetSignSessionStatus(t *testing.T) { response, err := ctx.wrapper.GetSignSessionStatus(ctx.audit, sessionObj) - assert.Equal(t, expectedResponse, response) - assert.NoError(t, err) + require.NoError(t, err) + actualResponseJSON := httptest.NewRecorder() + require.NoError(t, response.VisitGetSignSessionStatusResponse(actualResponseJSON)) + expectedResponseJSON := httptest.NewRecorder() + require.NoError(t, expectedResponse.VisitGetSignSessionStatusResponse(expectedResponseJSON)) + assert.JSONEq(t, string(expectedResponseJSON.Body.Bytes()), string(actualResponseJSON.Body.Bytes())) }) t.Run("nok - SigningSessionStatus returns error", func(t *testing.T) { diff --git a/auth/api/iam/api.go b/auth/api/iam/api.go index 20c47a1829..9f94cf722a 100644 --- a/auth/api/iam/api.go +++ b/auth/api/iam/api.go @@ -28,13 +28,13 @@ import ( "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/auth/log" "github.com/nuts-foundation/nuts-node/core" + "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vdr" "github.com/nuts-foundation/nuts-node/vdr/resolver" "html/template" "net/http" "strings" - "sync" ) var _ core.Routable = &Wrapper{} @@ -49,25 +49,25 @@ var assets embed.FS // Wrapper handles OAuth2 flows. type Wrapper struct { - vcr vcr.VCR - vdr vdr.VDR - auth auth.AuthenticationServices - sessions *SessionManager - templates *template.Template + vcr vcr.VCR + vdr vdr.VDR + auth auth.AuthenticationServices + templates *template.Template + storageEngine storage.Engine } -func New(authInstance auth.AuthenticationServices, vcrInstance vcr.VCR, vdrInstance vdr.VDR) *Wrapper { +func New(authInstance auth.AuthenticationServices, vcrInstance vcr.VCR, vdrInstance vdr.VDR, storageEngine storage.Engine) *Wrapper { templates := template.New("oauth2 templates") _, err := templates.ParseFS(assets, "assets/*.html") if err != nil { panic(err) } return &Wrapper{ - sessions: &SessionManager{sessions: new(sync.Map)}, - auth: authInstance, - vcr: vcrInstance, - vdr: vdrInstance, - templates: templates, + storageEngine: storageEngine, + auth: authInstance, + vcr: vcrInstance, + vdr: vdrInstance, + templates: templates, } } diff --git a/auth/api/iam/api_test.go b/auth/api/iam/api_test.go index b2fa31b4be..35e0e30611 100644 --- a/auth/api/iam/api_test.go +++ b/auth/api/iam/api_test.go @@ -33,6 +33,7 @@ import ( "github.com/nuts-foundation/nuts-node/auth" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/vcr/pe" + "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vdr" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/assert" @@ -283,18 +284,21 @@ func newTestClient(t testing.TB) *testCtx { publicURL, err := url.Parse("https://example.com") require.NoError(t, err) ctrl := gomock.NewController(t) + storageEngine := storage.NewTestStorageEngine(t) authnServices := auth.NewMockAuthenticationServices(ctrl) authnServices.EXPECT().PublicURL().Return(publicURL).AnyTimes() resolver := resolver.NewMockDIDResolver(ctrl) vdr := vdr.NewMockVDR(ctrl) vdr.EXPECT().Resolver().Return(resolver).AnyTimes() + return &testCtx{ authnServices: authnServices, resolver: resolver, vdr: vdr, client: &Wrapper{ - auth: authnServices, - vdr: vdr, + auth: authnServices, + vdr: vdr, + storageEngine: storageEngine, }, } } diff --git a/auth/api/iam/authorized_code.go b/auth/api/iam/authorized_code.go deleted file mode 100644 index 2e4cc2521b..0000000000 --- a/auth/api/iam/authorized_code.go +++ /dev/null @@ -1,115 +0,0 @@ -/* - * 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 iam - -import ( - "bytes" - "crypto/rand" - "encoding/base64" - "errors" - "fmt" - "github.com/labstack/echo/v4" - "github.com/nuts-foundation/nuts-node/core" - "html/template" - "net/http" - "net/url" -) - -func newAuthorizedCodeFlow(sessions *SessionManager) *authorizedCodeFlow { - authzTemplate, _ := template.ParseFS(assets, "assets/authz_en.html") - return &authorizedCodeFlow{ - sessions: sessions, - authzTemplate: authzTemplate, - } -} - -// authorizedCodeFlow implements the grant type as specified by https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3. -type authorizedCodeFlow struct { - sessions *SessionManager - authzTemplate *template.Template -} - -func (a authorizedCodeFlow) Routes(router core.EchoRouter) { - router.Add(http.MethodPost, "/public/oauth2/:did/authz_consent", a.handleAuthConsent) -} - -func (a authorizedCodeFlow) handleAuthzRequest(params map[string]string, session *Session) (*authzResponse, error) { - // This authz request handling is just for demonstration purposes. - sessionId := a.sessions.Create(*session) - - // Render HTML - buf := new(bytes.Buffer) - // TODO: Support multiple languages - err := a.authzTemplate.Execute(buf, struct { - SessionID string - Session - }{ - SessionID: sessionId, - Session: *session, - }) - if err != nil { - return nil, fmt.Errorf("unable to render authorization page: %w", err) - } - return &authzResponse{ - html: buf.Bytes(), - }, nil -} - -// handleAuthConsent handles the authorization consent form submission. -func (a authorizedCodeFlow) handleAuthConsent(c echo.Context) error { - var session *Session - if sessionID := c.Param("sessionID"); sessionID != "" { - session = a.sessions.Get(sessionID) - } - if session == nil { - return errors.New("invalid session") - } - - redirectURI, _ := url.Parse(session.RedirectURI) // Validated on session creation, can't fail - query := redirectURI.Query() - query.Add("code", generateCode()) - redirectURI.RawQuery = query.Encode() - - return c.Redirect(http.StatusFound, redirectURI.String()) -} - -func (a authorizedCodeFlow) validateCode(params map[string]string) (string, error) { - code, ok := params["code"] - invalidCodeError := OAuth2Error{ - Code: InvalidRequest, - Description: "missing or invalid code parameter", - } - if !ok { - return "", invalidCodeError - } - session := a.sessions.Get(code) - if session == nil { - return "", invalidCodeError - } - return session.Scope, nil -} - -func generateCode() string { - buf := make([]byte, 128/8) - _, err := rand.Read(buf) - if err != nil { - panic(err) - } - return base64.URLEncoding.EncodeToString(buf) -} diff --git a/auth/api/iam/openid4vp.go b/auth/api/iam/openid4vp.go index 30f467a85a..b282b4f5f8 100644 --- a/auth/api/iam/openid4vp.go +++ b/auth/api/iam/openid4vp.go @@ -24,6 +24,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/google/uuid" "github.com/labstack/echo/v4" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" @@ -33,8 +34,11 @@ import ( "net/http" "net/url" "strings" + "time" ) +const sessionExpiry = 5 * time.Minute + // createPresentationRequest creates a new Authorization Request as specified by OpenID4VP: https://openid.net/specs/openid-4-verifiable-presentations-1_0.html. // It is sent by a verifier to a wallet, to request one or more verifiable credentials as verifiable presentation from the wallet. func (r *Wrapper) sendPresentationRequest(ctx context.Context, response http.ResponseWriter, scope string, @@ -145,7 +149,12 @@ func (r *Wrapper) handlePresentationRequest(params map[string]string, session *S } session.ServerState["openid4vp_credentials"] = credentialIDs - templateParams.SessionID = r.sessions.Create(*session) + sessionID := uuid.NewString() + err = r.storageEngine.GetSessionDatabase().GetStore(sessionExpiry, session.OwnDID.String(), "session").Put(sessionID, *session) + if err != nil { + return nil, err + } + templateParams.SessionID = sessionID // TODO: Support multiple languages buf := new(bytes.Buffer) @@ -162,12 +171,16 @@ func (r *Wrapper) handlePresentationRequest(params map[string]string, session *S // handleAuthConsent handles the authorization consent form submission. func (r *Wrapper) handlePresentationRequestAccept(c echo.Context) error { // TODO: Needs authentication? - var session *Session - if sessionID := c.FormValue("sessionID"); sessionID != "" { - session = r.sessions.Get(sessionID) + sessionID := c.FormValue("sessionID") + if sessionID == "" { + return errors.New("missing sessionID parameter") } - if session == nil { - return errors.New("invalid session") + + var session Session + sessionStore := r.storageEngine.GetSessionDatabase().GetStore(sessionExpiry, "openid", session.OwnDID.String(), "session") + err := sessionStore.Get(sessionID, &session) + if err != nil { + return fmt.Errorf("invalid session: %w", err) } // TODO: Change to loading from wallet diff --git a/auth/api/iam/openid4vp_test.go b/auth/api/iam/openid4vp_test.go index 4ce9bf5df4..399ac275f5 100644 --- a/auth/api/iam/openid4vp_test.go +++ b/auth/api/iam/openid4vp_test.go @@ -25,6 +25,7 @@ import ( "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/auth" + "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/holder" @@ -42,7 +43,7 @@ var holderDID = did.MustParseDID("did:web:example.com:holder") var issuerDID = did.MustParseDID("did:web:example.com:issuer") func TestWrapper_sendPresentationRequest(t *testing.T) { - instance := New(nil, nil, nil) + instance := New(nil, nil, nil, nil) redirectURI, _ := url.Parse("https://example.com/redirect") verifierID, _ := url.Parse("https://example.com/verifier") @@ -92,7 +93,7 @@ func TestWrapper_handlePresentationRequest(t *testing.T) { t.Run("with scope", func(t *testing.T) { ctrl := gomock.NewController(t) peStore := &pe.DefinitionResolver{} - _ = peStore.LoadFromFile("test/presentation_definition_mapping.json") + require.NoError(t, peStore.LoadFromFile("test/presentation_definition_mapping.json")) mockVDR := vdr.NewMockVDR(ctrl) mockVCR := vcr.NewMockVCR(ctrl) mockWallet := holder.NewMockWallet(ctrl) @@ -101,7 +102,7 @@ func TestWrapper_handlePresentationRequest(t *testing.T) { mockAuth.EXPECT().PresentationDefinitions().Return(peStore) mockWallet.EXPECT().List(gomock.Any(), holderDID).Return(walletCredentials, nil) mockVDR.EXPECT().IsOwner(gomock.Any(), holderDID).Return(true, nil) - instance := New(mockAuth, mockVCR, mockVDR) + instance := New(mockAuth, mockVCR, mockVDR, storage.NewTestStorageEngine(t)) params := map[string]string{ "scope": "test", @@ -124,7 +125,7 @@ func TestWrapper_handlePresentationRequest(t *testing.T) { _ = peStore.LoadFromFile("test/presentation_definition_mapping.json") mockAuth := auth.NewMockAuthenticationServices(ctrl) mockAuth.EXPECT().PresentationDefinitions().Return(peStore) - instance := New(mockAuth, nil, nil) + instance := New(mockAuth, nil, nil, nil) params := map[string]string{ "scope": "unsupported", @@ -139,7 +140,7 @@ func TestWrapper_handlePresentationRequest(t *testing.T) { assert.Nil(t, response) }) t.Run("invalid response_mode", func(t *testing.T) { - instance := New(nil, nil, nil) + instance := New(nil, nil, nil, nil) params := map[string]string{ "scope": "eOverdracht-overdrachtsbericht", "response_type": "code", diff --git a/auth/api/iam/s2s_vptoken.go b/auth/api/iam/s2s_vptoken.go index 65fa221404..7c16066277 100644 --- a/auth/api/iam/s2s_vptoken.go +++ b/auth/api/iam/s2s_vptoken.go @@ -20,14 +20,27 @@ package iam import ( "context" + "crypto/rand" + "encoding/base64" "errors" + "fmt" "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/core" + "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vdr/resolver" "net/http" + "time" ) +// secretSizeBits is the size of the generated random secrets (access tokens, pre-authorized codes) in bits. +const secretSizeBits = 128 + +// 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, @@ -99,3 +112,43 @@ func (r Wrapper) RequestAccessToken(ctx context.Context, request RequestAccessTo return RequestAccessToken200JSONResponse{}, nil } + +func (r Wrapper) createAccessToken(issuer did.DID, issueTime time.Time, presentation vc.VerifiablePresentation, scope string) (*TokenResponse, error) { + accessToken := AccessToken{ + Token: generateCode(), + Issuer: issuer.String(), + Expiration: issueTime.Add(accessTokenValidity), + Presentation: presentation, + } + err := r.accessTokenStore(issuer).Put(accessToken.Token, accessToken) + if err != nil { + return nil, fmt.Errorf("unable to store access token: %w", err) + } + expiresIn := int(accessTokenValidity.Seconds()) + return &TokenResponse{ + AccessToken: accessToken.Token, + ExpiresIn: &expiresIn, + Scope: &scope, + TokenType: "bearer", + }, nil +} + +func (r Wrapper) accessTokenStore(issuer did.DID) storage.SessionStore { + return r.storageEngine.GetSessionDatabase().GetStore(accessTokenValidity, "s2s", issuer.String(), "accesstoken") +} + +func generateCode() string { + buf := make([]byte, secretSizeBits/8) + _, err := rand.Read(buf) + if err != nil { + panic(err) + } + return base64.URLEncoding.EncodeToString(buf) +} + +type AccessToken struct { + Token string + Issuer string + Expiration time.Time + Presentation vc.VerifiablePresentation +} diff --git a/auth/api/iam/s2s_vptoken_test.go b/auth/api/iam/s2s_vptoken_test.go index e438585179..858aab47e4 100644 --- a/auth/api/iam/s2s_vptoken_test.go +++ b/auth/api/iam/s2s_vptoken_test.go @@ -19,12 +19,14 @@ package iam import ( - "github.com/nuts-foundation/nuts-node/vdr/resolver" - "testing" - "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/jsonld" + "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "testing" + "time" ) func TestWrapper_RequestAccessToken(t *testing.T) { @@ -86,3 +88,32 @@ func TestWrapper_RequestAccessToken(t *testing.T) { assert.EqualError(t, err, "verifier not found: unable to find the DID document") }) } + +func TestWrapper_createAccessToken(t *testing.T) { + credential, err := vc.ParseVerifiableCredential(jsonld.TestOrganizationCredential) + require.NoError(t, err) + presentation := vc.VerifiablePresentation{ + VerifiableCredential: []vc.VerifiableCredential{*credential}, + } + t.Run("ok", func(t *testing.T) { + ctx := newTestClient(t) + + accessToken, err := ctx.client.createAccessToken(issuerDID, time.Now(), presentation, "everything") + + require.NoError(t, err) + assert.NotEmpty(t, accessToken.AccessToken) + assert.Equal(t, "bearer", accessToken.TokenType) + assert.Equal(t, 900, *accessToken.ExpiresIn) + assert.Equal(t, "everything", *accessToken.Scope) + + var storedToken AccessToken + err = ctx.client.accessTokenStore(issuerDID).Get(accessToken.AccessToken, &storedToken) + require.NoError(t, err) + assert.Equal(t, accessToken.AccessToken, storedToken.Token) + expectedVPJSON, _ := presentation.MarshalJSON() + actualVPJSON, _ := storedToken.Presentation.MarshalJSON() + assert.JSONEq(t, string(expectedVPJSON), string(actualVPJSON)) + assert.Equal(t, issuerDID.String(), storedToken.Issuer) + assert.NotEmpty(t, storedToken.Expiration) + }) +} diff --git a/auth/api/iam/session.go b/auth/api/iam/session.go index de6629f458..f72a4c1d56 100644 --- a/auth/api/iam/session.go +++ b/auth/api/iam/session.go @@ -19,34 +19,10 @@ package iam import ( - "github.com/google/uuid" "github.com/nuts-foundation/go-did/did" "net/url" - "sync" ) -type SessionManager struct { - sessions *sync.Map -} - -func (s *SessionManager) Create(session Session) string { - // TODO: Session expiration - // TODO: Session storage - // TODO: Session pinning and other safety measures (see OAuth2 Threat Model) - id := uuid.NewString() - s.sessions.Store(id, session) - return id -} - -func (s *SessionManager) Get(id string) *Session { - session, ok := s.sessions.Load(id) - if !ok { - return nil - } - result := session.(Session) - return &result -} - type Session struct { ClientID string Scope string diff --git a/auth/api/iam/test/presentation_definition_mapping.json b/auth/api/iam/test/presentation_definition_mapping.json index e7a73acc52..319765aca3 100644 --- a/auth/api/iam/test/presentation_definition_mapping.json +++ b/auth/api/iam/test/presentation_definition_mapping.json @@ -1 +1,6 @@ -{"test":{}} \ No newline at end of file +{ + "eOverdracht-overdrachtsbericht": { + "id": "eOverdracht", + "input_descriptors": [] + } +} diff --git a/auth/services/oauth/authz_server_test.go b/auth/services/oauth/authz_server_test.go index 33c06ce382..a8f75548d3 100644 --- a/auth/services/oauth/authz_server_test.go +++ b/auth/services/oauth/authz_server_test.go @@ -525,7 +525,7 @@ func TestService_validateAuthorizationCredentials(t *testing.T) { err := ctx.oauthService.validateAuthorizationCredentials(tokenCtx) - assert.EqualError(t, err, "invalid jwt.vcs: cannot unmarshal authorization credential: json: cannot unmarshal string into Go value of type map[string]interface {}") + assert.EqualError(t, err, "invalid jwt.vcs: cannot unmarshal authorization credential: failed to parse token: invalid character '}' looking for beginning of value") }) t.Run("error - jwt.iss <> credentialSubject.ID mismatch", func(t *testing.T) { diff --git a/auth/services/selfsigned/signer.go b/auth/services/selfsigned/signer.go index f7d6b40f52..c4a74f2d5c 100644 --- a/auth/services/selfsigned/signer.go +++ b/auth/services/selfsigned/signer.go @@ -35,6 +35,7 @@ import ( "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/holder" + "github.com/nuts-foundation/nuts-node/vcr/issuer" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" "net/url" "time" @@ -119,7 +120,7 @@ func (v *signer) createVP(ctx context.Context, s types.Session, issuanceDate tim } expirationData := issuanceDate.Add(24 * time.Hour) - credentialOptions := vc.VerifiableCredential{ + template := vc.VerifiableCredential{ Context: []ssi.URI{credential.NutsV1ContextURI}, Type: []ssi.URI{ssi.MustParseURI(credentialType)}, Issuer: issuerID.URI(), @@ -127,7 +128,7 @@ func (v *signer) createVP(ctx context.Context, s types.Session, issuanceDate tim ExpirationDate: &expirationData, CredentialSubject: s.CredentialSubject(), } - verifiableCredential, err := v.vcr.Issuer().Issue(ctx, credentialOptions, false, false) + verifiableCredential, err := v.vcr.Issuer().Issue(ctx, template, issuer.CredentialOptions{}) if err != nil { return nil, fmt.Errorf("issue VC failed: %w", err) } diff --git a/auth/services/selfsigned/signer_test.go b/auth/services/selfsigned/signer_test.go index 11f41fecae..38221ddef2 100644 --- a/auth/services/selfsigned/signer_test.go +++ b/auth/services/selfsigned/signer_test.go @@ -132,7 +132,10 @@ func TestSessionStore_SigningSessionStatus(t *testing.T) { t.Run("status completed returns VP on SigningSessionResult", func(t *testing.T) { mockContext := newMockContext(t) ss := NewSigner(mockContext.vcr, "").(*signer) - mockContext.issuer.EXPECT().Issue(context.TODO(), gomock.Any(), false, false).Return(&testVC, nil) + mockContext.issuer.EXPECT().Issue(context.TODO(), gomock.Any(), issuer.CredentialOptions{ + Publish: false, + Public: false, + }).Return(&testVC, nil) mockContext.wallet.EXPECT().BuildPresentation(context.TODO(), gomock.Len(1), gomock.Any(), &employer, true).Return(&testVP, nil) sp, err := ss.StartSigningSession(contract.Contract{RawContractText: testContract}, params) @@ -191,16 +194,10 @@ func TestSessionStore_SigningSessionStatus(t *testing.T) { t.Run("correct VC options are passed to issuer", func(t *testing.T) { mockContext := newMockContext(t) ss := NewSigner(mockContext.vcr, "").(*signer) - mockContext.issuer.EXPECT().Issue(context.TODO(), gomock.Any(), false, false).DoAndReturn( - func(arg0 interface{}, unsignedCredential interface{}, public interface{}, publish interface{}) (*vc.VerifiableCredential, error) { - isPublic, ok := public.(bool) - isPublished, ok2 := publish.(bool) - credential, ok3 := unsignedCredential.(vc.VerifiableCredential) - require.True(t, ok) - require.True(t, ok2) - require.True(t, ok3) - assert.False(t, isPublic) - assert.False(t, isPublished) + mockContext.issuer.EXPECT().Issue(context.TODO(), gomock.Any(), issuer.CredentialOptions{}).DoAndReturn( + func(arg0 interface{}, credential vc.VerifiableCredential, options issuer.CredentialOptions) (*vc.VerifiableCredential, error) { + assert.False(t, options.Public) + assert.False(t, options.Publish) assert.Equal(t, employer.URI(), credential.Issuer) assert.Equal(t, []ssi.URI{ssi.MustParseURI("NutsEmployeeCredential")}, credential.Type) @@ -241,7 +238,10 @@ func TestSessionStore_SigningSessionStatus(t *testing.T) { t.Run("error on VC issuance", func(t *testing.T) { mockContext := newMockContext(t) ss := NewSigner(mockContext.vcr, "").(*signer) - mockContext.issuer.EXPECT().Issue(context.TODO(), gomock.Any(), false, false).Return(nil, errors.New("error")) + mockContext.issuer.EXPECT().Issue(context.TODO(), gomock.Any(), issuer.CredentialOptions{ + Publish: false, + Public: false, + }).Return(nil, errors.New("error")) sp, err := ss.StartSigningSession(contract.Contract{RawContractText: testContract}, params) require.NoError(t, err) @@ -256,7 +256,10 @@ func TestSessionStore_SigningSessionStatus(t *testing.T) { t.Run("error on building VP", func(t *testing.T) { mockContext := newMockContext(t) ss := NewSigner(mockContext.vcr, "").(*signer) - mockContext.issuer.EXPECT().Issue(context.TODO(), gomock.Any(), false, false).Return(&testVC, nil) + mockContext.issuer.EXPECT().Issue(context.TODO(), gomock.Any(), issuer.CredentialOptions{ + Publish: false, + Public: false, + }).Return(&testVC, nil) mockContext.wallet.EXPECT().BuildPresentation(context.TODO(), gomock.Len(1), gomock.Any(), &employer, true).Return(nil, errors.New("error")) sp, err := ss.StartSigningSession(contract.Contract{RawContractText: testContract}, params) diff --git a/auth/services/selfsigned/test/generate_test.go b/auth/services/selfsigned/test/generate_test.go index fc8b1c4372..298c526862 100644 --- a/auth/services/selfsigned/test/generate_test.go +++ b/auth/services/selfsigned/test/generate_test.go @@ -36,7 +36,7 @@ import ( func Test_GenerateTestData(t *testing.T) { store := false - contextLoader, _ := jsonld.NewContextLoader(false, jsonld.DefaultContextConfig()) + contextLoader := jsonld.NewTestJSONLDManager(t).DocumentLoader() createdTime := time.Date(2023, 4, 20, 9, 53, 3, 0, time.UTC) expirationTime := createdTime.Add(4 * 24 * time.Hour) diff --git a/auth/services/selfsigned/validator_test.go b/auth/services/selfsigned/validator_test.go index 046c23c94e..54c5ee8899 100644 --- a/auth/services/selfsigned/validator_test.go +++ b/auth/services/selfsigned/validator_test.go @@ -91,7 +91,10 @@ func TestSigner_Validator_Roundtrip(t *testing.T) { // #2428: NutsEmployeeCredential does not need to be trusted, but the issuer needs to have a trusted NutsOrganizationCredential (chain of trust). // Issue() automatically trusts the issuer, so untrust it for asserting trust chain behavior - nutsOrgCred, err := vcrContext.VCR.Issuer().Issue(audit.TestContext(), createOrganizationCredential(issuerDID), false, false) + nutsOrgCred, err := vcrContext.VCR.Issuer().Issue(audit.TestContext(), createOrganizationCredential(issuerDID), issuer.CredentialOptions{ + Publish: false, + Public: false, + }) require.NoError(t, err) err = vcrContext.VCR.StoreCredential(*nutsOrgCred, nil) // Need to explicitly store, since we didn't publish it. require.NoError(t, err) @@ -203,7 +206,10 @@ func TestValidator_VerifyVP(t *testing.T) { // Otherwise, the NutsOrganizationCredential is not yet valid or might be expired. return vpValidTime.Add(-1 * time.Hour) } - nutsOrgCred, err := vcrContext.VCR.Issuer().Issue(audit.TestContext(), createOrganizationCredential(didDocument.ID.String()), false, false) + nutsOrgCred, err := vcrContext.VCR.Issuer().Issue(audit.TestContext(), createOrganizationCredential(didDocument.ID.String()), issuer.CredentialOptions{ + Publish: false, + Public: false, + }) require.NoError(t, err) err = vcrContext.VCR.StoreCredential(*nutsOrgCred, &vpValidTime) // Need to explicitly store, since we didn't publish it. require.NoError(t, err) diff --git a/cmd/root.go b/cmd/root.go index a01eaaa9f6..4c3c6b302e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -214,7 +214,7 @@ func CreateSystem(shutdownCallback context.CancelFunc) *core.System { system.RegisterRoutes(statusEngine.(core.Routable)) system.RegisterRoutes(metricsEngine.(core.Routable)) system.RegisterRoutes(&authAPIv1.Wrapper{Auth: authInstance, CredentialResolver: credentialInstance}) - system.RegisterRoutes(authIAMAPI.New(authInstance, credentialInstance, vdrInstance)) + system.RegisterRoutes(authIAMAPI.New(authInstance, credentialInstance, vdrInstance, storageInstance)) system.RegisterRoutes(&authMeansAPI.Wrapper{Auth: authInstance}) system.RegisterRoutes(&didmanAPI.Wrapper{Didman: didmanInstance}) diff --git a/crypto/jwx.go b/crypto/jwx.go index 25c015cf4a..29c022c605 100644 --- a/crypto/jwx.go +++ b/crypto/jwx.go @@ -32,16 +32,16 @@ import ( "github.com/lestrrat-go/jwx/jwk" "github.com/lestrrat-go/jwx/jws" "github.com/lestrrat-go/jwx/jwt" + "github.com/mr-tron/base58" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/crypto/log" "github.com/nuts-foundation/nuts-node/crypto/storage/spi" - "github.com/shengdoushi/base58" ) // ErrUnsupportedSigningKey is returned when an unsupported private key is used to sign. Currently only ecdsa and rsa keys are supported var ErrUnsupportedSigningKey = errors.New("signing key algorithm not supported") -var supportedAlgorithms = []jwa.SignatureAlgorithm{jwa.PS256, jwa.PS384, jwa.PS512, jwa.ES256, jwa.ES384, jwa.ES512} +var supportedAlgorithms = []jwa.SignatureAlgorithm{jwa.PS256, jwa.PS384, jwa.PS512, jwa.ES256, jwa.EdDSA, jwa.ES384, jwa.ES512} const defaultRsaEncryptionAlgorithm = jwa.RSA_OAEP_256 const defaultEcEncryptionAlgorithm = jwa.ECDH_ES_A256KW @@ -449,5 +449,5 @@ func Thumbprint(key jwk.Key) (string, error) { if err != nil { return "", err } - return base58.Encode(pkHash[:], base58.BitcoinAlphabet), nil + return base58.EncodeAlphabet(pkHash[:], base58.BTCAlphabet), nil } diff --git a/crypto/jwx_test.go b/crypto/jwx_test.go index da6d7d8128..4d52f532be 100644 --- a/crypto/jwx_test.go +++ b/crypto/jwx_test.go @@ -36,7 +36,7 @@ import ( "time" "github.com/lestrrat-go/jwx/jwt" - "github.com/shengdoushi/base58" + "github.com/mr-tron/base58" "github.com/lestrrat-go/jwx/jwa" "github.com/lestrrat-go/jwx/jwk" @@ -542,7 +542,7 @@ func TestThumbprint(t *testing.T) { t.Run("rsa", func(t *testing.T) { // example from https://tools.ietf.org/html/rfc7638#page-3 testRsa := "{\"e\":\"AQAB\",\"kty\":\"RSA\",\"n\":\"0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw\"}" - expectedThumbPrint := base58.Encode([]byte{55, 54, 203, 177, 120, 124, 184, 48, 156, 119, 238, 140, 55, 5, 197, 225, 111, 251, 158, 133, 151, 21, 144, 31, 30, 76, 89, 177, 17, 130, 245, 123}, base58.BitcoinAlphabet) + expectedThumbPrint := base58.EncodeAlphabet([]byte{55, 54, 203, 177, 120, 124, 184, 48, 156, 119, 238, 140, 55, 5, 197, 225, 111, 251, 158, 133, 151, 21, 144, 31, 30, 76, 89, 177, 17, 130, 245, 123}, base58.BTCAlphabet) set, err := jwk.ParseString(testRsa) require.NoError(t, err) diff --git a/didman/didman.go b/didman/didman.go index 11822bd95b..0ff4584f0b 100644 --- a/didman/didman.go +++ b/didman/didman.go @@ -30,6 +30,7 @@ import ( "net/url" "sync" + "github.com/mr-tron/base58" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -38,7 +39,6 @@ import ( "github.com/nuts-foundation/nuts-node/jsonld" "github.com/nuts-foundation/nuts-node/vcr" "github.com/nuts-foundation/nuts-node/vcr/credential" - "github.com/shengdoushi/base58" ) // ModuleName contains the name of this module: Didman @@ -601,7 +601,7 @@ func generateIDForService(id did.DID, service did.Service) ssi.URI { bytes, _ := json.Marshal(service) shaBytes := sha256.Sum256(bytes) d := id.URI() - d.Fragment = base58.Encode(shaBytes[:], base58.BitcoinAlphabet) + d.Fragment = base58.EncodeAlphabet(shaBytes[:], base58.BTCAlphabet) return d } diff --git a/docs/_static/vcr/vcr_v2.yaml b/docs/_static/vcr/vcr_v2.yaml index 052b134bdd..a037c1ff0b 100644 --- a/docs/_static/vcr/vcr_v2.yaml +++ b/docs/_static/vcr/vcr_v2.yaml @@ -455,6 +455,13 @@ components: description: RFC3339 time string until when the credential is valid. type: string example: "2012-01-02T12:00:00Z" + format: + description: Proof format for the credential (ldp_vc for JSON-LD or jwt_vc for JWT). If not set, it defaults to JSON-LD. + default: ldp_vc + type: string + enum: + - ldp_vc + - jwt_vc publishToNetwork: description: | If set, the node publishes this credential to the network. This is the default behaviour. @@ -587,6 +594,13 @@ components: type: string description: Date and time at which proof will expire. If omitted, the proof does not have an end date. example: '2021-12-20T09:00:00Z' + format: + description: Proof format for the presentation (JSON-LD or JWT). If not set, it defaults to JSON-LD. + default: ldp_vp + type: string + enum: + - ldp_vp + - jwt_vp VPVerificationRequest: required: diff --git a/docs/index.rst b/docs/index.rst index 3f6970f16a..131c752e84 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -19,6 +19,7 @@ Nuts documentation pages/integrating/api.rst pages/integrating/api-authentication.rst pages/integrating/vc.rst + pages/integrating/supported-protocols-formats.rst pages/integrating/faq-errors.rst pages/release_notes.rst pages/roadmap.rst diff --git a/docs/pages/integrating/supported-protocols-formats.rst b/docs/pages/integrating/supported-protocols-formats.rst new file mode 100644 index 0000000000..9389388a2f --- /dev/null +++ b/docs/pages/integrating/supported-protocols-formats.rst @@ -0,0 +1,40 @@ +.. _supported_protocols_and_formats: + +Supported Protocols and Formats +=============================== + +This page documents which cryptographic algorithms, key types and SSI formats and protocols are supported. + +Cryptographic Algorithms +************************ +The following cryptographic signing algorithms are supported: + +- ECDSA with the NIST P-256, P-384 and P-512 curves. +- EdDSA with Ed25519 curves. +- RSASSA-PSS RSA with keys of at least 2048 bits. + +The following encryption algorithms are supported: + +- RSA-OAEP-SHA256 (min. 2048 bits) +- ECDH-ES+A256KW +- AES-GCM-256 + +DID methods +*********** + +The following DID methods are supported: + +- ``did:nuts`` (creating and resolving) +- ``did:web`` (creating and resolving) +- ``did:key`` (resolving) +- ``did:jwk`` (resolving) + +Credentials +*********** + +`W3C Verifiable Credentials v1 `_ and Presentations are supported (issuing and verifying) in JSON-LD and JWT format. + +The following protocols are being implemented (work in progress): +- OpenID4VP verifier and SIOPv2 relying party for requesting a presentation from a wallet. +- OpenID4VCI issuer for issuing a credential to a wallet. +- OpenID4VCI wallet for receiving a credential from an issuer. \ No newline at end of file diff --git a/docs/pages/integrating/vc.rst b/docs/pages/integrating/vc.rst index 9c1f60b0d6..4cce4c018a 100644 --- a/docs/pages/integrating/vc.rst +++ b/docs/pages/integrating/vc.rst @@ -70,6 +70,9 @@ Will be expanded by the node to: The `visibility` property indicates the contents of the VC are published on the network, so it can be read by everyone. +By default, the node will create credentials in JSON-LD format. +You can specify the format by passing the `format` parameter (``jwt_vc`` or ``ldp_vc``). + .. _searching-vcs: Searching VCs diff --git a/go.mod b/go.mod index 3f75f649d0..480aa31554 100644 --- a/go.mod +++ b/go.mod @@ -4,34 +4,35 @@ go 1.21 require ( github.com/PaesslerAG/jsonpath v0.1.2-0.20230323094847-3484786d6f97 - github.com/alicebob/miniredis/v2 v2.30.5 + github.com/alicebob/miniredis/v2 v2.31.0 github.com/avast/retry-go/v4 v4.5.0 github.com/cbroglie/mustache v1.4.0 - github.com/chromedp/chromedp v0.9.2 + github.com/chromedp/chromedp v0.9.3 github.com/dlclark/regexp2 v1.10.0 github.com/golang-jwt/jwt v3.2.2+incompatible github.com/goodsign/monday v1.0.1 github.com/google/uuid v1.3.1 github.com/hashicorp/vault/api v1.10.0 github.com/knadh/koanf v1.5.0 - github.com/labstack/echo/v4 v4.11.1 + github.com/labstack/echo/v4 v4.11.2 github.com/lestrrat-go/jwx v1.2.26 github.com/magiconair/properties v1.8.7 github.com/mdp/qrterminal/v3 v3.1.1 - github.com/nats-io/nats-server/v2 v2.10.1 - github.com/nats-io/nats.go v1.30.2 + github.com/mr-tron/base58 v1.2.0 + github.com/multiformats/go-multicodec v0.9.0 + github.com/nats-io/nats-server/v2 v2.10.3 + github.com/nats-io/nats.go v1.31.0 github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b - github.com/nuts-foundation/go-did v0.6.5 + github.com/nuts-foundation/go-did v0.7.1 github.com/nuts-foundation/go-leia/v4 v4.0.0 github.com/nuts-foundation/go-stoabs v1.9.0 // check the oapi-codegen tool version in the makefile when upgrading the runtime github.com/oapi-codegen/runtime v1.0.0 github.com/piprate/json-gold v0.5.1-0.20230111113000-6ddbe6e6f19f github.com/privacybydesign/irmago v0.12.6 - github.com/prometheus/client_golang v1.16.0 - github.com/prometheus/client_model v0.4.0 + github.com/prometheus/client_golang v1.17.0 + github.com/prometheus/client_model v0.5.0 github.com/redis/go-redis/v9 v9.2.1 - github.com/shengdoushi/base58 v1.0.0 github.com/sirupsen/logrus v1.9.3 github.com/spf13/cobra v1.7.0 github.com/spf13/pflag v1.0.5 @@ -39,17 +40,19 @@ require ( github.com/twmb/murmur3 v1.1.8 go.etcd.io/bbolt v1.3.7 go.uber.org/atomic v1.11.0 - go.uber.org/goleak v1.2.1 + go.uber.org/goleak v1.3.0 go.uber.org/mock v0.3.0 - golang.org/x/crypto v0.13.0 + golang.org/x/crypto v0.14.0 golang.org/x/time v0.3.0 - google.golang.org/grpc v1.58.2 + google.golang.org/grpc v1.59.0 google.golang.org/protobuf v1.31.0 gopkg.in/Regis24GmbH/go-phonetics.v2 v2.0.3 gopkg.in/yaml.v3 v3.0.1 schneider.vip/problem v1.8.1 ) +require github.com/santhosh-tekuri/jsonschema v1.2.4 + require ( github.com/PaesslerAG/gval v1.2.2 // indirect github.com/alexandrevicenzi/go-sse v1.6.0 // indirect @@ -65,7 +68,7 @@ require ( github.com/cenkalti/backoff/v3 v3.0.0 // indirect github.com/cespare/xxhash v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect - github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89 // indirect + github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998 // indirect github.com/chromedp/sysutil v1.0.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 // indirect @@ -85,7 +88,7 @@ require ( github.com/go-sql-driver/mysql v1.6.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect - github.com/gobwas/ws v1.2.1 // indirect + github.com/gobwas/ws v1.3.0 // indirect github.com/goccy/go-json v0.10.2 // indirect github.com/golang-jwt/jwt/v4 v4.4.1 // indirect github.com/golang/protobuf v1.5.3 // indirect @@ -123,7 +126,9 @@ require ( github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect - github.com/mr-tron/base58 v1.1.3 // indirect + github.com/multiformats/go-base32 v0.0.3 // indirect + github.com/multiformats/go-base36 v0.1.0 // indirect + github.com/multiformats/go-multibase v0.2.0 // indirect github.com/multiformats/go-multihash v0.0.11 // indirect github.com/nats-io/jwt/v2 v2.5.2 // indirect github.com/nats-io/nkeys v0.4.5 // indirect @@ -133,11 +138,12 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect github.com/privacybydesign/gabi v0.0.0-20221012093643-8e978bfbb252 // indirect - github.com/prometheus/common v0.42.0 // indirect - github.com/prometheus/procfs v0.10.1 // indirect + github.com/prometheus/common v0.44.0 // indirect + github.com/prometheus/procfs v0.11.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rogpeppe/go-internal v1.11.0 // indirect github.com/ryanuber/go-glob v1.0.0 // indirect + github.com/shengdoushi/base58 v1.0.0 // indirect github.com/shopspring/decimal v1.3.1 // indirect github.com/sietseringers/go-sse v0.0.0-20200801161811-e2cf2c63ca50 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect @@ -152,12 +158,12 @@ require ( github.com/x-cray/logrus-prefixed-formatter v0.5.2 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/yuin/gopher-lua v1.1.0 // indirect - golang.org/x/net v0.14.0 // indirect + golang.org/x/net v0.17.0 // indirect golang.org/x/sync v0.3.0 // indirect - golang.org/x/sys v0.12.0 // indirect - golang.org/x/term v0.12.0 // indirect + golang.org/x/sys v0.13.0 // indirect + golang.org/x/term v0.13.0 // indirect golang.org/x/text v0.13.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d // indirect gopkg.in/Regis24GmbH/go-diacritics.v2 v2.0.3 // indirect rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index ad8991a683..875a707ce3 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,7 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/DmitriyVTitov/size v1.5.0/go.mod h1:le6rNI4CoLQV1b9gzp1+3d7hMAD/uu2QcJ+aYbNgiU0= github.com/OneOfOne/xxhash v1.2.2 h1:KMrpdQIwFcEqXDklaen+P1axHaj9BSKzvpUUfnHldSE= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/PaesslerAG/gval v1.2.2 h1:Y7iBzhgE09IGTt5QgGQ2IdaYYYOU134YGHBThD+wm9E= @@ -19,8 +20,8 @@ github.com/alexandrevicenzi/go-sse v1.6.0 h1:3KvOzpuY7UrbqZgAtOEmub9/V5ykr7Myudw github.com/alexandrevicenzi/go-sse v1.6.0/go.mod h1:jdrNAhMgVqP7OfcUuM8eJx0sOY17wc+girs5utpFZUU= github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a h1:HbKu58rmZpUGpz5+4FfNmIU+FmZg2P3Xaj2v2bfNWmk= github.com/alicebob/gopher-json v0.0.0-20200520072559-a9ecdc9d1d3a/go.mod h1:SGnFV6hVsYE877CKEZ6tDNTjaSXYUk6QqoIK6PrAtcc= -github.com/alicebob/miniredis/v2 v2.30.5 h1:3r6kTHdKnuP4fkS8k2IrvSfxpxUTcW1SOL0wN7b7Dt0= -github.com/alicebob/miniredis/v2 v2.30.5/go.mod h1:b25qWj4fCEsBeAAR2mlb0ufImGC6uH3VlUfb/HS5zKg= +github.com/alicebob/miniredis/v2 v2.31.0 h1:ObEFUNlJwoIiyjxdrYF0QIDE7qXcLc7D3WpSH4c22PU= +github.com/alicebob/miniredis/v2 v2.31.0/go.mod h1:UB/T2Uztp7MlFSDakaX1sTXUv5CASoprx0wulRT6HBg= github.com/alvaroloes/enumer v1.1.2/go.mod h1:FxrjvuXoDAx9isTJrv4c+T410zFi0DtXIT0m65DJ+Wo= github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= @@ -77,10 +78,10 @@ github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XL github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89 h1:aPflPkRFkVwbW6dmcVqfgwp1i+UWGFH6VgR1Jim5Ygc= -github.com/chromedp/cdproto v0.0.0-20230802225258-3cf4e6d46a89/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= -github.com/chromedp/chromedp v0.9.2 h1:dKtNz4kApb06KuSXoTQIyUC2TrA0fhGDwNZf3bcgfKw= -github.com/chromedp/chromedp v0.9.2/go.mod h1:LkSXJKONWTCHAfQasKFUZI+mxqS4tZqhmtGzzhLsnLs= +github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998 h1:2zipcnjfFdqAjOQa8otCCh0Lk1M7RBzciy3s80YAKHk= +github.com/chromedp/cdproto v0.0.0-20231011050154-1d073bb38998/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= +github.com/chromedp/chromedp v0.9.3 h1:Wq58e0dZOdHsxaj9Owmfcf+ibtpYN1N0FWVbaxa/esg= +github.com/chromedp/chromedp v0.9.3/go.mod h1:NipeUkUcuzIdFbBP8eNNvl9upcceOfWzoJn6cRe4ksA= github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= @@ -173,8 +174,8 @@ github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= -github.com/gobwas/ws v1.2.1 h1:F2aeBZrm2NDsc7vbovKrWSogd4wvfAxg0FQ89/iqOTk= -github.com/gobwas/ws v1.2.1/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= +github.com/gobwas/ws v1.3.0 h1:sbeU3Y4Qzlb+MOzIe6mQGf7QR4Hkv6ZD0qhGkBFL2O0= +github.com/gobwas/ws v1.3.0/go.mod h1:hRKAFb8wOxFROYNsT1bqfWnhX+b5MFeJM9r2ZSwg/KY= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -188,10 +189,10 @@ github.com/golang-jwt/jwt/v4 v4.4.1/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= -github.com/golang/glog v1.1.0 h1:/d3pCKDPWNnvIWe0vVUpNP32qc8U3PDVxySP/y360qE= -github.com/golang/glog v1.1.0/go.mod h1:pfYeQZ3JWZoXTV5sFc986z3HTpwQs9At6P4ImfuP3NQ= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6 h1:ZgQEtGgCBiWRM39fZuwSd1LwSqqSW0hOdXCYYDX0R3I= -github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/glog v1.1.2 h1:DVjP2PbBOzHyzA+dn3WhHIq4NdVu3Q+pvivFICf/7fo= +github.com/golang/glog v1.1.2/go.mod h1:zR+okUeTbrL6EL3xHUDxZuEtGv04p5shwip1+mL/rLQ= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -331,8 +332,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= -github.com/labstack/echo/v4 v4.11.1 h1:dEpLU2FLg4UVmvCGPuk/APjlH6GDpbEPti61srUUUs4= -github.com/labstack/echo/v4 v4.11.1/go.mod h1:YuYRTSM3CHs2ybfrL8Px48bO6BAnYIN4l8wSTMP6BDQ= +github.com/labstack/echo/v4 v4.11.2 h1:T+cTLQxWCDfqDEoydYm5kCobjmHwOwcv4OJAPHilmdE= +github.com/labstack/echo/v4 v4.11.2/go.mod h1:UcGuQ8V6ZNRmSweBIJkPvGfwCMIlFmiqrPqiEBfPYws= github.com/labstack/gommon v0.4.0 h1:y7cvthEAEbU0yHOf4axH8ZG2NH8knB9iNSoTO8dyIk8= github.com/labstack/gommon v0.4.0/go.mod h1:uW6kP17uPlLJsD3ijUYn3/M5bAxtlZhMI6m3MFxTMTM= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= @@ -414,18 +415,27 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= -github.com/mr-tron/base58 v1.1.3 h1:v+sk57XuaCKGXpWtVBX8YJzO7hMGx4Aajh4TQbdEFdc= github.com/mr-tron/base58 v1.1.3/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= +github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= +github.com/multiformats/go-base32 v0.0.3 h1:tw5+NhuwaOjJCC5Pp82QuXbrmLzWg7uxlMFp8Nq/kkI= +github.com/multiformats/go-base32 v0.0.3/go.mod h1:pLiuGC8y0QR3Ue4Zug5UzK9LjgbkL8NSQj0zQ5Nz/AA= +github.com/multiformats/go-base36 v0.1.0 h1:JR6TyF7JjGd3m6FbLU2cOxhC0Li8z8dLNGQ89tUg4F4= +github.com/multiformats/go-base36 v0.1.0/go.mod h1:kFGE83c6s80PklsHO9sRn2NCoffoRdUUOENyW/Vv6sM= +github.com/multiformats/go-multibase v0.2.0 h1:isdYCVLvksgWlMW9OZRYJEa9pZETFivncJHmHnnd87g= +github.com/multiformats/go-multibase v0.2.0/go.mod h1:bFBZX4lKCA/2lyOFSAoKH5SS6oPyjtnzK/XTFDPkNuk= +github.com/multiformats/go-multicodec v0.9.0 h1:pb/dlPnzee/Sxv/j4PmkDRxCOi3hXTz3IbPKOXWJkmg= +github.com/multiformats/go-multicodec v0.9.0/go.mod h1:L3QTQvMIaVBkXOXXtVmYE+LI16i14xuaojr/H7Ai54k= github.com/multiformats/go-multihash v0.0.11 h1:yEyBxwoR/7vBM5NfLVXRnpQNVLrMhpS6MRb7Z/1pnzc= github.com/multiformats/go-multihash v0.0.11/go.mod h1:LXRDJcYYY+9BjlsFe6i5LV7uekf0OoEJdnRmitUshxk= github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= github.com/nats-io/jwt/v2 v2.5.2 h1:DhGH+nKt+wIkDxM6qnVSKjokq5t59AZV5HRcFW0zJwU= github.com/nats-io/jwt/v2 v2.5.2/go.mod h1:24BeQtRwxRV8ruvC4CojXlx/WQ/VjuwlYiH+vu/+ibI= -github.com/nats-io/nats-server/v2 v2.10.1 h1:MIJ614dhOIdo71iSzY8ln78miXwrYvlvXHUyS+XdKZQ= -github.com/nats-io/nats-server/v2 v2.10.1/go.mod h1:3PMvMSu2cuK0J9YInRLWdFpFsswKKGUS77zVSAudRto= -github.com/nats-io/nats.go v1.30.2 h1:aloM0TGpPorZKQhbAkdCzYDj+ZmsJDyeo3Gkbr72NuY= -github.com/nats-io/nats.go v1.30.2/go.mod h1:dcfhUgmQNN4GJEfIb2f9R7Fow+gzBF4emzDHrVBd5qM= +github.com/nats-io/nats-server/v2 v2.10.3 h1:nk2QVLpJUh3/AhZCJlQdTfj2oeLDvWnn1Z6XzGlNFm0= +github.com/nats-io/nats-server/v2 v2.10.3/go.mod h1:lzrskZ/4gyMAh+/66cCd+q74c6v7muBypzfWhP/MAaM= +github.com/nats-io/nats.go v1.31.0 h1:/WFBHEc/dOKBF6qf1TZhrdEfTmOZ5JzdJ+Y3m6Y/p7E= +github.com/nats-io/nats.go v1.31.0/go.mod h1:di3Bm5MLsoB4Bx61CBTsxuarI36WbhAwOm8QrW39+i8= github.com/nats-io/nkeys v0.4.5 h1:Zdz2BUlFm4fJlierwvGK+yl20IAKUm7eV6AAZXEhkPk= github.com/nats-io/nkeys v0.4.5/go.mod h1:XUkxdLPTufzlihbamfzQ7mw/VGx6ObUs+0bN5sNvt64= github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw= @@ -435,8 +445,8 @@ github.com/nightlyone/lockfile v1.0.0/go.mod h1:rywoIealpdNse2r832aiD9jRk8ErCatR github.com/npillmayer/nestext v0.1.3/go.mod h1:h2lrijH8jpicr25dFY+oAJLyzlya6jhnuG+zWp9L0Uk= github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b h1:80icUxWHwE1MrIOOEK5rxrtyKOgZeq5Iu1IjAEkggTY= github.com/nuts-foundation/crypto-ecies v0.0.0-20211207143025-5b84f9efce2b/go.mod h1:6YUioYirD6/8IahZkoS4Ypc8xbeJW76Xdk1QKcziNTM= -github.com/nuts-foundation/go-did v0.6.5 h1:y2gPygRN1gBeMI9y8OIWwARp8NpHHheqnbpLwCxajFw= -github.com/nuts-foundation/go-did v0.6.5/go.mod h1:Jb3IgnO2Zeed970JMIlfjr4g1kvikmgWUJA0EfeDEFE= +github.com/nuts-foundation/go-did v0.7.1 h1:JKn9QMuOq4eXHPGdYgsSk3XOxRDBWHkPPFYLFbYut0Y= +github.com/nuts-foundation/go-did v0.7.1/go.mod h1:fq65EPzzpdxD+WG5VFqMfbVaADMwbEUB4CpgPajp1LM= github.com/nuts-foundation/go-leia/v4 v4.0.0 h1:/unYCk18qGG2HWcJK4ld4CaM6k7Tdr0bR1vQd1Jwfcg= github.com/nuts-foundation/go-leia/v4 v4.0.0/go.mod h1:A246dA4nhY99OPCQpG/XbQ/iPyyfSaJchanivuPWpao= github.com/nuts-foundation/go-stoabs v1.9.0 h1:zK+ugfolaJYyBvGwsRuavLVdycXk4Yw/1gI+tz17lWQ= @@ -488,25 +498,25 @@ github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXP github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= github.com/prometheus/client_golang v1.11.1/go.mod h1:Z6t4BnS23TR94PD6BsDNk8yVqroYurpAkEiz0P2BEV0= -github.com/prometheus/client_golang v1.16.0 h1:yk/hx9hDbrGHovbci4BY+pRMfSuuat626eFsHb7tmT8= -github.com/prometheus/client_golang v1.16.0/go.mod h1:Zsulrv/L9oM40tJ7T815tM89lFEugiJ9HzIqaAx4LKc= +github.com/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= +github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo= github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/client_model v0.2.0/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= -github.com/prometheus/client_model v0.4.0 h1:5lQXD3cAg1OXBf4Wq03gTrXHeaV0TQvGfUooCfx1yqY= -github.com/prometheus/client_model v0.4.0/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/client_model v0.5.0 h1:VQw1hfvPvk3Uv6Qf29VrPF32JB6rtbgI6cYPYQjL0Qw= +github.com/prometheus/client_model v0.5.0/go.mod h1:dTiFglRmd66nLR9Pv9f0mZi7B7fk5Pm3gvsjB5tr+kI= github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4= github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo= github.com/prometheus/common v0.26.0/go.mod h1:M7rCNAaPfAosfx8veZJCuw84e35h3Cfd9VFqTh1DIvc= -github.com/prometheus/common v0.42.0 h1:EKsfXEYo4JpWMHH5cg+KOUWeuJSov1Id8zGR8eeI1YM= -github.com/prometheus/common v0.42.0/go.mod h1:xBwqVerjNdUDjgODMpudtOMwlOwf2SaTr1yjz4b7Zbc= +github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= +github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/procfs v0.1.3/go.mod h1:lV6e/gmhEcM9IjHGsFOCxxuZ+z1YqCvr4OA4YeYWdaU= github.com/prometheus/procfs v0.6.0/go.mod h1:cz+aTbrPOrUb4q7XlbU9ygM+/jj0fzG6c1xBZuNvfVA= -github.com/prometheus/procfs v0.10.1 h1:kYK1Va/YMlutzCGazswoHKo//tZVlFpKYh+PymziUAg= -github.com/prometheus/procfs v0.10.1/go.mod h1:nwNm2aOCAYw8uTR/9bWRREkZFxAUcWzPHWJq+XBB/FM= +github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= +github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= github.com/redis/go-redis/v9 v9.0.2/go.mod h1:/xDTe9EF1LM61hek62Poq2nzQSGj0xSrEtEHbBQevps= github.com/redis/go-redis/v9 v9.2.1 h1:WlYJg71ODF0dVspZZCpYmoF1+U1Jjk9Rwd7pq6QmlCg= github.com/redis/go-redis/v9 v9.2.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= @@ -521,6 +531,8 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/santhosh-tekuri/jsonschema v1.2.4 h1:hNhW8e7t+H1vgY+1QeEQpveR6D4+OwKPXCfD2aieJis= +github.com/santhosh-tekuri/jsonschema v1.2.4/go.mod h1:TEAUOeZSmIxTTuHatJzrvARHiuO9LYd+cIxzgEHCQI4= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= github.com/shengdoushi/base58 v1.0.0 h1:tGe4o6TmdXFJWoI31VoSWvuaKxf0Px3gqa3sUWhAxBs= github.com/shengdoushi/base58 v1.0.0/go.mod h1:m5uIILfzcKMw6238iWAhP4l3s5+uXyF3+bJKUNhAL9I= @@ -603,8 +615,8 @@ go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk= go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= -go.uber.org/goleak v1.2.1 h1:NBol2c7O1ZokfZ0LEU9K6Whx/KnwvepVetCUhtKja4A= -go.uber.org/goleak v1.2.1/go.mod h1:qlT2yGI9QafXHhZZLxlSuNsMw3FFLxBr+tBRlmO1xH4= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/mock v0.3.0 h1:3mUxI1No2/60yUYax92Pt8eNOEecx2D3lcXZh2NEZJo= go.uber.org/mock v0.3.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU= @@ -623,8 +635,8 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y golang.org/x/crypto v0.0.0-20220518034528-6f7dac969898/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.9.0/go.mod h1:yrmDGqONDYtNj3tH8X9dzUun2m2lzPa9ngI6/RUPGR0= -golang.org/x/crypto v0.13.0 h1:mvySKfSWJ+UKUii46M40LOvyWfN0s2U+46/jDd0e6Ck= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= @@ -665,8 +677,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.14.0 h1:BONx9s002vGdD9umnlX1Po8vOZmrgH34qlHcD1MfK14= -golang.org/x/net v0.14.0/go.mod h1:PpSgVXXLK0OxS0F31C1/tv6XNguvCrnXIDrFMspZIUI= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -738,14 +750,14 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0 h1:CM0HF96J0hcLAwsHPJZjfdNzs0gftsLfgKt57wWHJ0o= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0 h1:/ZfYdc3zq+q02Rv9vGqTeSItdzZTSNDmfTi0mBAuidU= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20181227161524-e6919f6577db/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= @@ -787,8 +799,8 @@ google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98 google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c= google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= google.golang.org/genproto v0.0.0-20210602131652-f16073e35f0c/go.mod h1:UODoCrxHCcBojKKwX1terBiRUaqAsFqJiF615XL43r0= -google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98 h1:bVf09lpb+OJbByTj913DRJioFFAjf/ZGxEz7MajTp2U= -google.golang.org/genproto/googleapis/rpc v0.0.0-20230711160842-782d3b101e98/go.mod h1:TUfxEVdsvPg18p6AslUXFoLdpED4oBnGwyqk3dV1XzM= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d h1:uvYuEyMHKNt+lT4K3bN6fGswmK8qSvcreM3BwjDh+y4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20230822172742-b8732ec3820d/go.mod h1:+Bk1OCOj40wS2hwAMA+aCW9ypzm63QTBBHp6lQ3p+9M= google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.22.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= @@ -797,8 +809,8 @@ google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQ google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTpR3n0= google.golang.org/grpc v1.38.0/go.mod h1:NREThFqKR1f3iQ6oBuvc5LadQuXVGo9rkm5ZGrQdJfM= -google.golang.org/grpc v1.58.2 h1:SXUpjxeVF3FKrTYQI4f4KvbGD5u2xccdYdurwowix5I= -google.golang.org/grpc v1.58.2/go.mod h1:tgX3ZQDlNJGU96V6yHh1T/JeoBQ2TXdr43YbYSsCJk0= +google.golang.org/grpc v1.59.0 h1:Z5Iec2pjwb+LEOqzpB2MR12/eKFhDPhuqW91O+4bwUk= +google.golang.org/grpc v1.59.0/go.mod h1:aUPDwccQo6OTjy7Hct4AfBPD1GptF4fyUjIkQ9YtF98= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= diff --git a/jsonld/test.go b/jsonld/test.go index 9cfd9384f2..996d40e95b 100644 --- a/jsonld/test.go +++ b/jsonld/test.go @@ -154,7 +154,10 @@ func NewTestJSONLDManager(t *testing.T) JSONLD { t.Helper() contextConfig := DefaultContextConfig() + contextConfig.RemoteAllowList = nil contextConfig.LocalFileMapping["http://example.org/credentials/V1"] = "test_assets/contexts/test.ldjson" + contextConfig.LocalFileMapping["https://www.w3.org/2018/credentials/examples/v1"] = "test_assets/contexts/examples.ldjson" + contextConfig.LocalFileMapping["https://www.w3.org/ns/odrl.jsonld"] = "test_assets/contexts/odrl.ldjson" loader := NewMappedDocumentLoader(contextConfig.LocalFileMapping, NewEmbeddedFSDocumentLoader(assets.Assets, @@ -162,7 +165,7 @@ func NewTestJSONLDManager(t *testing.T) JSONLD { NewEmbeddedFSDocumentLoader(assets.TestAssets, // Last in the chain is the defaultLoader which can resolve // local files and remote (via http) context documents - ld.NewDefaultDocumentLoader(nil)))) + nil))) manager := testContextManager{loader: loader} diff --git a/network/dag/consistency_test.go b/network/dag/consistency_test.go index 09f9614cf9..a13387b9df 100644 --- a/network/dag/consistency_test.go +++ b/network/dag/consistency_test.go @@ -35,9 +35,7 @@ import ( ) func TestXorTreeRepair(t *testing.T) { - t.Cleanup(func() { - goleak.VerifyNone(t) - }) + defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) tx, _, _ := CreateTestTransaction(1) t.Run("xor tree repaired after 2 signals", func(t *testing.T) { diff --git a/network/transport/grpc/connection_manager_test.go b/network/transport/grpc/connection_manager_test.go index acc5080394..2db69baa0b 100644 --- a/network/transport/grpc/connection_manager_test.go +++ b/network/transport/grpc/connection_manager_test.go @@ -215,7 +215,7 @@ func Test_grpcConnectionManager_hasActiveConnection(t *testing.T) { func Test_grpcConnectionManager_dialerLoop(t *testing.T) { // make sure connectLoop only returns after all of its goroutines are closed - defer goleak.VerifyNone(t) + defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) targetAddress := "bootstrap" var capturedAddress string diff --git a/network/transport/v2/gossip/manager_test.go b/network/transport/v2/gossip/manager_test.go index d92d8a56a9..85c9b6e5d4 100644 --- a/network/transport/v2/gossip/manager_test.go +++ b/network/transport/v2/gossip/manager_test.go @@ -97,7 +97,7 @@ func TestManager_PeerDisconnected(t *testing.T) { t.Run("stops ticker", func(t *testing.T) { // Use uber/goleak to assert the goroutine started by PeerConnected is stopped when PeerDisconnected is called - defer goleak.VerifyNone(t) + defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) gMan := giveMeAgMan(t) gMan.interval = time.Millisecond diff --git a/network/transport/v2/protocol_test.go b/network/transport/v2/protocol_test.go index 1912e6b273..00a28f43c7 100644 --- a/network/transport/v2/protocol_test.go +++ b/network/transport/v2/protocol_test.go @@ -202,7 +202,7 @@ func TestProtocol_Start(t *testing.T) { func TestProtocol_Stop(t *testing.T) { t.Run("waits until goroutines have finished", func(t *testing.T) { - defer goleak.VerifyNone(t) + defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) // Use waitgroup to make sure the goroutine that blocks has started wg := &sync.WaitGroup{} diff --git a/network/transport/v2/transactionlist_handler_test.go b/network/transport/v2/transactionlist_handler_test.go index 16142ce84a..0971900cbc 100644 --- a/network/transport/v2/transactionlist_handler_test.go +++ b/network/transport/v2/transactionlist_handler_test.go @@ -37,9 +37,7 @@ import ( ) func TestTransactionListHandler(t *testing.T) { - t.Cleanup(func() { - goleak.VerifyNone(t) - }) + defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) t.Run("fn is called", func(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) diff --git a/pki/validator_test.go b/pki/validator_test.go index 8115e426d2..c2581cece7 100644 --- a/pki/validator_test.go +++ b/pki/validator_test.go @@ -57,7 +57,7 @@ var crlPathMap = map[string]string{ } func TestValidator_Start(t *testing.T) { - defer goleak.VerifyNone(t) + defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) store, err := core.LoadTrustStore(truststorePKIo) require.NoError(t, err) ctx, cancel := context.WithCancel(context.Background()) diff --git a/storage/engine.go b/storage/engine.go index 5c8fdd57bb..3a00578de3 100644 --- a/storage/engine.go +++ b/storage/engine.go @@ -36,17 +36,19 @@ const storeShutdownTimeout = 5 * time.Second // New creates a new instance of the storage engine. func New() Engine { return &engine{ - storesMux: &sync.Mutex{}, - stores: map[string]stoabs.Store{}, + storesMux: &sync.Mutex{}, + stores: map[string]stoabs.Store{}, + sessionDatabase: NewInMemorySessionDatabase(), } } type engine struct { - datadir string - storesMux *sync.Mutex - stores map[string]stoabs.Store - databases []database - config Config + datadir string + storesMux *sync.Mutex + stores map[string]stoabs.Store + databases []database + sessionDatabase SessionDatabase + config Config } func (e *engine) Config() interface{} { @@ -84,9 +86,13 @@ func (e engine) Shutdown() error { failures = true } } + if failures { return errors.New("one or more stores failed to close") } + + e.sessionDatabase.close() + return nil } @@ -108,6 +114,7 @@ func (e *engine) Configure(config core.ServerConfig) error { return fmt.Errorf("unable to configure BBolt database: %w", err) } e.databases = append(e.databases, bboltDB) + return nil } @@ -118,6 +125,10 @@ func (e *engine) GetProvider(moduleName string) Provider { } } +func (e *engine) GetSessionDatabase() SessionDatabase { + return e.sessionDatabase +} + type provider struct { moduleName string engine *engine diff --git a/storage/interface.go b/storage/interface.go index f9b3f9b662..e23888542f 100644 --- a/storage/interface.go +++ b/storage/interface.go @@ -19,6 +19,7 @@ package storage import ( + "errors" "github.com/nuts-foundation/go-stoabs" "github.com/nuts-foundation/nuts-node/core" "time" @@ -34,6 +35,8 @@ type Engine interface { // GetProvider returns the Provider for the given module. GetProvider(moduleName string) Provider + // GetSessionDatabase returns the SessionDatabase + GetSessionDatabase() SessionDatabase } // Provider lets callers get access to stores. @@ -59,3 +62,32 @@ type database interface { getClass() Class close() } + +var ErrNotFound = errors.New("not found") + +// SessionDatabase is a non-persistent database that holds session data on a KV basis. +// Keys could be access tokens, nonce's, authorization codes, etc. +// All entries are stored with a TTL, so they will be removed automatically. +type SessionDatabase interface { + // GetStore returns a SessionStore with the given keys as key prefixes. + // The keys are used to logically partition the store, eg: tenants and/or flows that are not allowed to overlap like credential issuance and verification. + // The TTL is the time-to-live for the entries in the store. + GetStore(ttl time.Duration, keys ...string) SessionStore + // close stops any background processes and closes the database. + close() +} + +// SessionStore is a key-value store that holds session data. +// The SessionStore is an abstraction for underlying storage, it automatically adds prefixes for logical partitions. +type SessionStore interface { + // Delete deletes the entry for the given key. + // It does not return an error if the key does not exist. + Delete(key string) error + // Exists returns true if the key exists. + Exists(key string) bool + // Get returns the value for the given key. + // Returns ErrNotFound if the key does not exist. + Get(key string, target interface{}) error + // Put stores the given value for the given key. + Put(key string, value interface{}) error +} diff --git a/storage/leia_test.go b/storage/leia_test.go index e331ee4486..c79b308758 100644 --- a/storage/leia_test.go +++ b/storage/leia_test.go @@ -182,7 +182,7 @@ func newStoreInDir(t *testing.T, testDir string, backupConfig LeiaBackupConfigur backupStorePath := path.Join(testDir, "vcr", "backup-private-credentials.db") backupStore, err := bbolt.CreateBBoltStore(backupStorePath) require.NoError(t, err) - leiaStore, err := leia.NewStore(issuerStorePath) + leiaStore, err := leia.NewStore(issuerStorePath, leia.WithDocumentLoader(jsonld.NewTestJSONLDManager(t).DocumentLoader())) require.NoError(t, err) store, err := NewKVBackedLeiaStore(leiaStore, backupStore) require.NoError(t, err) diff --git a/storage/mock.go b/storage/mock.go index f67a88e420..de364cd577 100644 --- a/storage/mock.go +++ b/storage/mock.go @@ -10,6 +10,7 @@ package storage import ( reflect "reflect" + time "time" stoabs "github.com/nuts-foundation/go-stoabs" core "github.com/nuts-foundation/nuts-node/core" @@ -67,6 +68,20 @@ func (mr *MockEngineMockRecorder) GetProvider(moduleName any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetProvider", reflect.TypeOf((*MockEngine)(nil).GetProvider), moduleName) } +// GetSessionDatabase mocks base method. +func (m *MockEngine) GetSessionDatabase() SessionDatabase { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetSessionDatabase") + ret0, _ := ret[0].(SessionDatabase) + return ret0 +} + +// GetSessionDatabase indicates an expected call of GetSessionDatabase. +func (mr *MockEngineMockRecorder) GetSessionDatabase() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSessionDatabase", reflect.TypeOf((*MockEngine)(nil).GetSessionDatabase)) +} + // Shutdown mocks base method. func (m *MockEngine) Shutdown() error { m.ctrl.T.Helper() @@ -196,3 +211,136 @@ func (mr *MockdatabaseMockRecorder) getClass() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "getClass", reflect.TypeOf((*Mockdatabase)(nil).getClass)) } + +// MockSessionDatabase is a mock of SessionDatabase interface. +type MockSessionDatabase struct { + ctrl *gomock.Controller + recorder *MockSessionDatabaseMockRecorder +} + +// MockSessionDatabaseMockRecorder is the mock recorder for MockSessionDatabase. +type MockSessionDatabaseMockRecorder struct { + mock *MockSessionDatabase +} + +// NewMockSessionDatabase creates a new mock instance. +func NewMockSessionDatabase(ctrl *gomock.Controller) *MockSessionDatabase { + mock := &MockSessionDatabase{ctrl: ctrl} + mock.recorder = &MockSessionDatabaseMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSessionDatabase) EXPECT() *MockSessionDatabaseMockRecorder { + return m.recorder +} + +// GetStore mocks base method. +func (m *MockSessionDatabase) GetStore(ttl time.Duration, keys ...string) SessionStore { + m.ctrl.T.Helper() + varargs := []interface{}{ttl} + for _, a := range keys { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "GetStore", varargs...) + ret0, _ := ret[0].(SessionStore) + return ret0 +} + +// GetStore indicates an expected call of GetStore. +func (mr *MockSessionDatabaseMockRecorder) GetStore(ttl interface{}, keys ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ttl}, keys...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetStore", reflect.TypeOf((*MockSessionDatabase)(nil).GetStore), varargs...) +} + +// close mocks base method. +func (m *MockSessionDatabase) close() { + m.ctrl.T.Helper() + m.ctrl.Call(m, "close") +} + +// close indicates an expected call of close. +func (mr *MockSessionDatabaseMockRecorder) close() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "close", reflect.TypeOf((*MockSessionDatabase)(nil).close)) +} + +// MockSessionStore is a mock of SessionStore interface. +type MockSessionStore struct { + ctrl *gomock.Controller + recorder *MockSessionStoreMockRecorder +} + +// MockSessionStoreMockRecorder is the mock recorder for MockSessionStore. +type MockSessionStoreMockRecorder struct { + mock *MockSessionStore +} + +// NewMockSessionStore creates a new mock instance. +func NewMockSessionStore(ctrl *gomock.Controller) *MockSessionStore { + mock := &MockSessionStore{ctrl: ctrl} + mock.recorder = &MockSessionStoreMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSessionStore) EXPECT() *MockSessionStoreMockRecorder { + return m.recorder +} + +// Delete mocks base method. +func (m *MockSessionStore) Delete(key string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Delete", key) + ret0, _ := ret[0].(error) + return ret0 +} + +// Delete indicates an expected call of Delete. +func (mr *MockSessionStoreMockRecorder) Delete(key interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Delete", reflect.TypeOf((*MockSessionStore)(nil).Delete), key) +} + +// Exists mocks base method. +func (m *MockSessionStore) Exists(key string) bool { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Exists", key) + ret0, _ := ret[0].(bool) + return ret0 +} + +// Exists indicates an expected call of Exists. +func (mr *MockSessionStoreMockRecorder) Exists(key interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Exists", reflect.TypeOf((*MockSessionStore)(nil).Exists), key) +} + +// Get mocks base method. +func (m *MockSessionStore) Get(key string, target interface{}) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Get", key, target) + ret0, _ := ret[0].(error) + return ret0 +} + +// Get indicates an expected call of Get. +func (mr *MockSessionStoreMockRecorder) Get(key, target interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockSessionStore)(nil).Get), key, target) +} + +// Put mocks base method. +func (m *MockSessionStore) Put(key string, value interface{}) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Put", key, value) + ret0, _ := ret[0].(error) + return ret0 +} + +// Put indicates an expected call of Put. +func (mr *MockSessionStoreMockRecorder) Put(key, value interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Put", reflect.TypeOf((*MockSessionStore)(nil).Put), key, value) +} diff --git a/storage/session.go b/storage/session.go new file mode 100644 index 0000000000..9ec851284b --- /dev/null +++ b/storage/session.go @@ -0,0 +1,169 @@ +/* + * 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 storage + +import ( + "encoding/json" + "github.com/nuts-foundation/nuts-node/storage/log" + "strings" + "sync" + "time" +) + +var _ SessionDatabase = (*InMemorySessionDatabase)(nil) +var _ SessionStore = (*InMemorySessionStore)(nil) + +var sessionStorePruneInterval = 10 * time.Minute + +type expiringEntry struct { + // Value stores the actual value as JSON + Value string + Expiry time.Time +} + +// InMemorySessionDatabase is an in memory database that holds session data on a KV basis. +// Keys could be access tokens, nonce's, authorization codes, etc. +// All entries are stored with a TTL, so they will be removed automatically. +type InMemorySessionDatabase struct { + done chan struct{} + mux sync.RWMutex + routines sync.WaitGroup + entries map[string]expiringEntry +} + +// NewInMemorySessionDatabase creates a new in memory session database. +func NewInMemorySessionDatabase() *InMemorySessionDatabase { + result := &InMemorySessionDatabase{ + entries: map[string]expiringEntry{}, + done: make(chan struct{}, 10), + } + result.startPruning(sessionStorePruneInterval) + return result +} + +func (i *InMemorySessionDatabase) GetStore(ttl time.Duration, keys ...string) SessionStore { + return InMemorySessionStore{ + ttl: ttl, + prefixes: keys, + db: i, + } +} + +func (i *InMemorySessionDatabase) close() { + // Signal pruner to stop and wait for it to finish + i.done <- struct{}{} +} + +func (i *InMemorySessionDatabase) startPruning(interval time.Duration) { + ticker := time.NewTicker(interval) + i.routines.Add(1) + go func() { + defer i.routines.Done() + for { + select { + case <-i.done: + ticker.Stop() + return + case <-ticker.C: + valsPruned := i.prune() + if valsPruned > 0 { + log.Logger().Debugf("Pruned %d expired session variables", valsPruned) + } + } + } + }() +} + +func (i *InMemorySessionDatabase) prune() int { + i.mux.Lock() + defer i.mux.Unlock() + + moment := time.Now() + + // Find expired flows and delete them + var count int + for key, entry := range i.entries { + if entry.Expiry.Before(moment) { + count++ + delete(i.entries, key) + } + } + + return count +} + +type InMemorySessionStore struct { + ttl time.Duration + prefixes []string + db *InMemorySessionDatabase +} + +func (i InMemorySessionStore) Delete(key string) error { + i.db.mux.Lock() + defer i.db.mux.Unlock() + + delete(i.db.entries, i.getFullKey(key)) + return nil +} + +func (i InMemorySessionStore) Exists(key string) bool { + i.db.mux.Lock() + defer i.db.mux.Unlock() + + _, ok := i.db.entries[i.getFullKey(key)] + return ok +} + +func (i InMemorySessionStore) Get(key string, target interface{}) error { + i.db.mux.Lock() + defer i.db.mux.Unlock() + + fullKey := i.getFullKey(key) + entry, ok := i.db.entries[fullKey] + if !ok { + return ErrNotFound + } + if entry.Expiry.Before(time.Now()) { + delete(i.db.entries, fullKey) + return ErrNotFound + } + + return json.Unmarshal([]byte(entry.Value), target) +} + +func (i InMemorySessionStore) Put(key string, value interface{}) error { + i.db.mux.Lock() + defer i.db.mux.Unlock() + + bytes, err := json.Marshal(value) + if err != nil { + return err + } + entry := expiringEntry{ + Value: string(bytes), + Expiry: time.Now().Add(i.ttl), + } + + i.db.entries[i.getFullKey(key)] = entry + return nil +} + +func (i InMemorySessionStore) getFullKey(key string) string { + return strings.Join(append(i.prefixes, key), "/") +} diff --git a/storage/session_test.go b/storage/session_test.go new file mode 100644 index 0000000000..473b63c775 --- /dev/null +++ b/storage/session_test.go @@ -0,0 +1,239 @@ +/* + * 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 storage + +import ( + "github.com/nuts-foundation/nuts-node/test" + "go.uber.org/goleak" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNewInMemorySessionDatabase(t *testing.T) { + db := createDatabase(t) + + assert.NotNil(t, db) +} + +func TestInMemorySessionDatabase_GetStore(t *testing.T) { + db := createDatabase(t) + + store := db.GetStore(time.Minute, "key1", "key2").(InMemorySessionStore) + + require.NotNil(t, store) + assert.Equal(t, time.Minute, store.ttl) + assert.Equal(t, []string{"key1", "key2"}, store.prefixes) +} + +func TestInMemorySessionStore_Put(t *testing.T) { + db := createDatabase(t) + store := db.GetStore(time.Minute, "prefix").(InMemorySessionStore) + + t.Run("string value is stored", func(t *testing.T) { + err := store.Put("key", "value") + + require.NoError(t, err) + assert.Equal(t, `"value"`, store.db.entries["prefix/key"].Value) + }) + + t.Run("float value is stored", func(t *testing.T) { + err := store.Put("key", 1.23) + + require.NoError(t, err) + assert.Equal(t, "1.23", store.db.entries["prefix/key"].Value) + }) + + t.Run("struct value is stored", func(t *testing.T) { + value := testStruct{ + Field1: "value", + } + + err := store.Put("key", value) + + require.NoError(t, err) + assert.Equal(t, "{\"field1\":\"value\"}", store.db.entries["prefix/key"].Value) + }) + + t.Run("value is not JSON", func(t *testing.T) { + err := store.Put("key", make(chan int)) + + assert.Error(t, err) + }) +} + +func TestInMemorySessionStore_Get(t *testing.T) { + db := createDatabase(t) + store := db.GetStore(time.Minute, "prefix").(InMemorySessionStore) + + t.Run("string value is retrieved correctly", func(t *testing.T) { + _ = store.Put(t.Name(), "value") + var actual string + + err := store.Get(t.Name(), &actual) + + require.NoError(t, err) + assert.Equal(t, "value", actual) + }) + + t.Run("float value is retrieved correctly", func(t *testing.T) { + _ = store.Put(t.Name(), 1.23) + var actual float64 + + err := store.Get(t.Name(), &actual) + + require.NoError(t, err) + assert.Equal(t, 1.23, actual) + }) + + t.Run("struct value is retrieved correctly", func(t *testing.T) { + value := testStruct{ + Field1: "value", + } + _ = store.Put(t.Name(), value) + var actual testStruct + + err := store.Get(t.Name(), &actual) + + require.NoError(t, err) + assert.Equal(t, value, actual) + }) + + t.Run("value is not found", func(t *testing.T) { + var actual string + + err := store.Get(t.Name(), &actual) + + assert.Equal(t, ErrNotFound, err) + }) + + t.Run("value is expired", func(t *testing.T) { + store.db.entries["prefix/key"] = expiringEntry{ + Value: "", + Expiry: time.Now().Add(-time.Minute), + } + var actual string + + err := store.Get("key", &actual) + + assert.Equal(t, ErrNotFound, err) + }) + + t.Run("value is not JSON", func(t *testing.T) { + store.db.entries["prefix/key"] = expiringEntry{ + Value: "not JSON", + Expiry: time.Now().Add(time.Minute), + } + var actual string + + err := store.Get("key", &actual) + + assert.Error(t, err) + }) + + t.Run("value is not a pointer", func(t *testing.T) { + _ = store.Put(t.Name(), "value") + + err := store.Get(t.Name(), "not a pointer") + + assert.Error(t, err) + }) +} + +func TestInMemorySessionStore_Delete(t *testing.T) { + db := createDatabase(t) + store := db.GetStore(time.Minute, "prefix").(InMemorySessionStore) + + t.Run("value is deleted", func(t *testing.T) { + _ = store.Put(t.Name(), "value") + + err := store.Delete(t.Name()) + + require.NoError(t, err) + _, ok := store.db.entries["prefix/key"] + assert.False(t, ok) + }) + + t.Run("value is not found", func(t *testing.T) { + err := store.Delete(t.Name()) + + assert.NoError(t, err) + }) +} + +func TestInMemorySessionDatabase_Close(t *testing.T) { + defer goleak.VerifyNone(t, goleak.IgnoreCurrent()) + + t.Run("assert Close() waits for pruning to finish to avoid leaking goroutines", func(t *testing.T) { + sessionStorePruneInterval = 10 * time.Millisecond + defer func() { + sessionStorePruneInterval = 10 * time.Minute + }() + store := NewInMemorySessionDatabase() + time.Sleep(50 * time.Millisecond) // make sure pruning is running + store.close() + }) +} + +func Test_memoryStore_prune(t *testing.T) { + t.Run("automatic", func(t *testing.T) { + store := createDatabase(t) + // we call startPruning a second time ourselves to speed things up, make sure not to leak the original goroutine + defer func() { + store.done <- struct{}{} + }() + store.startPruning(10 * time.Millisecond) + + err := store.GetStore(time.Millisecond).Put("key", "value") + require.NoError(t, err) + + test.WaitFor(t, func() (bool, error) { + store.mux.Lock() + defer store.mux.Unlock() + _, exists := store.entries["key"] + return !exists, nil + }, time.Second, "time-out waiting for entry to be pruned") + }) + t.Run("prunes expired flows", func(t *testing.T) { + store := createDatabase(t) + defer store.close() + + _ = store.GetStore(0).Put("key1", "value") + _ = store.GetStore(time.Minute).Put("key2", "value") + + count := store.prune() + + assert.Equal(t, 1, count) + + // Second round to assert there's nothing to prune now + count = store.prune() + + assert.Equal(t, 0, count) + }) +} + +type testStruct struct { + Field1 string `json:"field1"` +} + +func createDatabase(t *testing.T) *InMemorySessionDatabase { + return NewTestInMemorySessionDatabase(t) +} diff --git a/storage/test.go b/storage/test.go index eba2f9a337..95fb052a18 100644 --- a/storage/test.go +++ b/storage/test.go @@ -34,7 +34,7 @@ func NewTestStorageEngineInDir(dir string) Engine { return result } -func NewTestStorageEngine(t *testing.T) Engine { +func NewTestStorageEngine(t testing.TB) Engine { oldOpts := append(DefaultBBoltOptions[:]) t.Cleanup(func() { DefaultBBoltOptions = oldOpts @@ -67,3 +67,11 @@ func (p *StaticKVStoreProvider) GetKVStore(_ string, _ Class) (stoabs.KVStore, e } return p.Store, nil } + +func NewTestInMemorySessionDatabase(t *testing.T) *InMemorySessionDatabase { + db := NewInMemorySessionDatabase() + t.Cleanup(func() { + db.close() + }) + return db +} diff --git a/vcr/api/vcr/v2/api.go b/vcr/api/vcr/v2/api.go index a7d9204289..d77368d0d6 100644 --- a/vcr/api/vcr/v2/api.go +++ b/vcr/api/vcr/v2/api.go @@ -24,6 +24,7 @@ import ( "errors" "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/vcr/holder" + "github.com/nuts-foundation/nuts-node/vcr/issuer" "github.com/nuts-foundation/nuts-node/vdr/resolver" "net/http" @@ -86,27 +87,25 @@ func (w *Wrapper) ResolveStatusCode(err error) int { // IssueVC handles the API request for credential issuing. func (w Wrapper) IssueVC(ctx context.Context, request IssueVCRequestObject) (IssueVCResponseObject, error) { - var ( - publish bool - public bool - ) - - // publish is true by default + options := issuer.CredentialOptions{ + Publish: true, + } if request.Body.PublishToNetwork != nil { - publish = *request.Body.PublishToNetwork - } else { - publish = true + options.Publish = *request.Body.PublishToNetwork + } + if request.Body.Format != nil { + options.Format = string(*request.Body.Format) } // Check param constraints: if request.Body.Visibility == nil || *request.Body.Visibility == "" { - if publish { + if options.Publish { return nil, core.InvalidInputError("visibility must be set when publishing credential") } } else { // visibility is set // Visibility can only be used when publishing - if !publish { + if !options.Publish { return nil, core.InvalidInputError("visibility setting is only allowed when publishing to the network") } // Check if the values are in range @@ -114,7 +113,7 @@ func (w Wrapper) IssueVC(ctx context.Context, request IssueVCRequestObject) (Iss return nil, core.InvalidInputError("invalid value for visibility") } // Set the actual value - public = *request.Body.Visibility == Public + options.Public = *request.Body.Visibility == Public } // Set default context, if not set @@ -136,8 +135,17 @@ func (w Wrapper) IssueVC(ctx context.Context, request IssueVCRequestObject) (Iss if err := json.Unmarshal(rawRequest, &requestedVC); err != nil { return nil, err } + // Copy parsed credential to keep control over what we pass to the issuer, + // (and also makes unit testing easier since vc.VerifiableCredential has unexported fields that can't be set). + template := vc.VerifiableCredential{ + Context: requestedVC.Context, + Type: requestedVC.Type, + Issuer: requestedVC.Issuer, + ExpirationDate: requestedVC.ExpirationDate, + CredentialSubject: requestedVC.CredentialSubject, + } - vcCreated, err := w.VCR.Issuer().Issue(ctx, requestedVC, publish, public) + vcCreated, err := w.VCR.Issuer().Issue(ctx, template, options) if err != nil { return nil, err } @@ -253,6 +261,10 @@ func (w *Wrapper) CreateVP(ctx context.Context, request CreateVPRequestObject) ( presentationOptions.ProofOptions.ProofPurpose = string(purpose) } + if request.Body.Format != nil { + presentationOptions.Format = string(*request.Body.Format) + } + // pass context and type as ssi.URI if request.Body.Context != nil { for _, sc := range *request.Body.Context { diff --git a/vcr/api/vcr/v2/api_test.go b/vcr/api/vcr/v2/api_test.go index 33eb6f98b9..0a417864ed 100644 --- a/vcr/api/vcr/v2/api_test.go +++ b/vcr/api/vcr/v2/api_test.go @@ -68,7 +68,10 @@ func TestWrapper_IssueVC(t *testing.T) { Visibility: &public, } // assert that credential.NutsV1ContextURI is added if the request does not contain @context - testContext.mockIssuer.EXPECT().Issue(testContext.requestCtx, gomock.Eq(expectedRequestedVC), true, true).Return(&expectedRequestedVC, nil) + testContext.mockIssuer.EXPECT().Issue(testContext.requestCtx, expectedRequestedVC, issuer.CredentialOptions{ + Publish: true, + Public: true, + }).Return(&expectedRequestedVC, nil) response, err := testContext.client.IssueVC(testContext.requestCtx, IssueVCRequestObject{Body: &request}) @@ -100,9 +103,8 @@ func TestWrapper_IssueVC(t *testing.T) { public := Public request := IssueVCRequest{ - Type: expectedRequestedVC.Type[0].String(), - Issuer: expectedRequestedVC.Issuer.String(), - //CredentialSubject: expectedRequestedVC.CredentialSubject, + Type: expectedRequestedVC.Type[0].String(), + Issuer: expectedRequestedVC.Issuer.String(), Visibility: &public, } @@ -129,7 +131,10 @@ func TestWrapper_IssueVC(t *testing.T) { } expectedVC := vc.VerifiableCredential{} expectedResponse := IssueVC200JSONResponse(expectedVC) - testContext.mockIssuer.EXPECT().Issue(testContext.requestCtx, gomock.Any(), true, false).Return(&expectedVC, nil) + testContext.mockIssuer.EXPECT().Issue(testContext.requestCtx, gomock.Any(), issuer.CredentialOptions{ + Publish: true, + Public: false, + }).Return(&expectedVC, nil) response, err := testContext.client.IssueVC(testContext.requestCtx, IssueVCRequestObject{Body: &request}) @@ -150,7 +155,10 @@ func TestWrapper_IssueVC(t *testing.T) { } expectedVC := vc.VerifiableCredential{} expectedResponse := IssueVC200JSONResponse(expectedVC) - testContext.mockIssuer.EXPECT().Issue(testContext.requestCtx, gomock.Any(), true, true).Return(&expectedVC, nil) + testContext.mockIssuer.EXPECT().Issue(testContext.requestCtx, gomock.Any(), issuer.CredentialOptions{ + Publish: true, + Public: true, + }).Return(&expectedVC, nil) response, err := testContext.client.IssueVC(testContext.requestCtx, IssueVCRequestObject{Body: &request}) @@ -219,7 +227,10 @@ func TestWrapper_IssueVC(t *testing.T) { } expectedVC := vc.VerifiableCredential{} expectedResponse := IssueVC200JSONResponse(expectedVC) - testContext.mockIssuer.EXPECT().Issue(testContext.requestCtx, gomock.Any(), false, false).Return(&expectedVC, nil) + testContext.mockIssuer.EXPECT().Issue(testContext.requestCtx, gomock.Any(), issuer.CredentialOptions{ + Publish: false, + Public: false, + }).Return(&expectedVC, nil) response, err := testContext.client.IssueVC(testContext.requestCtx, IssueVCRequestObject{Body: &request}) @@ -267,7 +278,7 @@ func TestWrapper_IssueVC(t *testing.T) { t.Run(test.name, func(t *testing.T) { testContext := newMockContext(t) - testContext.mockIssuer.EXPECT().Issue(testContext.requestCtx, gomock.Any(), gomock.Any(), gomock.Any()).Return(nil, test.err) + testContext.mockIssuer.EXPECT().Issue(testContext.requestCtx, gomock.Any(), gomock.Any()).Return(nil, test.err) _, err := testContext.client.IssueVC(testContext.requestCtx, IssueVCRequestObject{Body: &validIssueRequest}) diff --git a/vcr/api/vcr/v2/generated.go b/vcr/api/vcr/v2/generated.go index 82e78fd22f..1f2c316ddb 100644 --- a/vcr/api/vcr/v2/generated.go +++ b/vcr/api/vcr/v2/generated.go @@ -22,6 +22,12 @@ const ( JwtBearerAuthScopes = "jwtBearerAuth.Scopes" ) +// Defines values for CreateVPRequestFormat. +const ( + JwtVp CreateVPRequestFormat = "jwt_vp" + LdpVp CreateVPRequestFormat = "ldp_vp" +) + // Defines values for CreateVPRequestProofPurpose. const ( AssertionMethod CreateVPRequestProofPurpose = "assertionMethod" @@ -31,6 +37,12 @@ const ( KeyAgreement CreateVPRequestProofPurpose = "keyAgreement" ) +// Defines values for IssueVCRequestFormat. +const ( + JwtVc IssueVCRequestFormat = "jwt_vc" + LdpVc IssueVCRequestFormat = "ldp_vc" +) + // Defines values for IssueVCRequestVisibility. const ( Private IssueVCRequestVisibility = "private" @@ -54,6 +66,9 @@ type CreateVPRequest struct { // Expires Date and time at which proof will expire. If omitted, the proof does not have an end date. Expires *string `json:"expires,omitempty"` + // Format Proof format for the presentation (JSON-LD or JWT). If not set, it defaults to JSON-LD. + Format *CreateVPRequestFormat `json:"format,omitempty"` + // ProofPurpose The 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 *CreateVPRequestProofPurpose `json:"proofPurpose,omitempty"` @@ -68,6 +83,9 @@ type CreateVPRequest struct { VerifiableCredentials []VerifiableCredential `json:"verifiableCredentials"` } +// CreateVPRequestFormat Proof format for the presentation (JSON-LD or JWT). If not set, it defaults to JSON-LD. +type CreateVPRequestFormat string + // CreateVPRequestProofPurpose The 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. type CreateVPRequestProofPurpose string @@ -93,6 +111,9 @@ type IssueVCRequest struct { // ExpirationDate RFC3339 time string until when the credential is valid. ExpirationDate *string `json:"expirationDate,omitempty"` + // Format Proof format for the credential (ldp_vc for JSON-LD or jwt_vc for JWT). If not set, it defaults to JSON-LD. + Format *IssueVCRequestFormat `json:"format,omitempty"` + // Issuer DID according to Nuts specification. Issuer string `json:"issuer"` @@ -110,6 +131,9 @@ type IssueVCRequest struct { Visibility *IssueVCRequestVisibility `json:"visibility,omitempty"` } +// IssueVCRequestFormat Proof format for the credential (ldp_vc for JSON-LD or jwt_vc for JWT). If not set, it defaults to JSON-LD. +type IssueVCRequestFormat string + // IssueVCRequestVisibility When publishToNetwork is true, the credential can be published publicly or privately to the holder. // This field is mandatory if publishToNetwork is true to prevent accidents. It defaults to "private". type IssueVCRequestVisibility string diff --git a/vcr/api/vcr/v2/types.go b/vcr/api/vcr/v2/types.go index bf0d66d2a8..6b13a861ce 100644 --- a/vcr/api/vcr/v2/types.go +++ b/vcr/api/vcr/v2/types.go @@ -41,7 +41,7 @@ var _ json.Marshaler = (*IssueVC200JSONResponse)(nil) var _ json.Marshaler = (*ResolveVC200JSONResponse)(nil) var _ json.Marshaler = (*CreateVP200JSONResponse)(nil) -// MarshalJSON forwards the call to the underlying VerifiableCredential to make sure the expected JSON-LD is returned. +// MarshalJSON forwards the call to the underlying VerifiableCredential to make sure the credential is returned in the expected format (JSON-LD or JWT). func (r IssueVC200JSONResponse) MarshalJSON() ([]byte, error) { return vc.VerifiableCredential(r).MarshalJSON() } diff --git a/vcr/assets/test_assets/contexts/examples.ldjson b/vcr/assets/test_assets/contexts/examples.ldjson new file mode 100644 index 0000000000..173626c762 --- /dev/null +++ b/vcr/assets/test_assets/contexts/examples.ldjson @@ -0,0 +1,53 @@ +{ + "@context": [{ + "@version": 1.1 + },"https://www.w3.org/ns/odrl.jsonld", { + "ex": "https://example.org/examples#", + "schema": "http://schema.org/", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + + "3rdPartyCorrelation": "ex:3rdPartyCorrelation", + "AllVerifiers": "ex:AllVerifiers", + "Archival": "ex:Archival", + "BachelorDegree": "ex:BachelorDegree", + "Child": "ex:Child", + "CLCredentialDefinition2019": "ex:CLCredentialDefinition2019", + "CLSignature2019": "ex:CLSignature2019", + "IssuerPolicy": "ex:IssuerPolicy", + "HolderPolicy": "ex:HolderPolicy", + "Mother": "ex:Mother", + "RelationshipCredential": "ex:RelationshipCredential", + "UniversityDegreeCredential": "ex:UniversityDegreeCredential", + "AlumniCredential": "ex:AlumniCredential", + "DisputeCredential": "ex:DisputeCredential", + "PrescriptionCredential": "ex:PrescriptionCredential", + "ZkpExampleSchema2018": "ex:ZkpExampleSchema2018", + + "issuerData": "ex:issuerData", + "attributes": "ex:attributes", + "signature": "ex:signature", + "signatureCorrectnessProof": "ex:signatureCorrectnessProof", + "primaryProof": "ex:primaryProof", + "nonRevocationProof": "ex:nonRevocationProof", + + "alumniOf": {"@id": "schema:alumniOf", "@type": "rdf:HTML"}, + "child": {"@id": "ex:child", "@type": "@id"}, + "degree": "ex:degree", + "degreeType": "ex:degreeType", + "degreeSchool": "ex:degreeSchool", + "college": "ex:college", + "name": {"@id": "schema:name", "@type": "rdf:HTML"}, + "givenName": "schema:givenName", + "familyName": "schema:familyName", + "parent": {"@id": "ex:parent", "@type": "@id"}, + "referenceId": "ex:referenceId", + "documentPresence": "ex:documentPresence", + "evidenceDocument": "ex:evidenceDocument", + "spouse": "schema:spouse", + "subjectPresence": "ex:subjectPresence", + "verifier": {"@id": "ex:verifier", "@type": "@id"}, + "currentStatus": "ex:currentStatus", + "statusReason": "ex:statusReason", + "prescription": "ex:prescription" + }] +} diff --git a/vcr/assets/test_assets/contexts/odrl.ldjson b/vcr/assets/test_assets/contexts/odrl.ldjson new file mode 100644 index 0000000000..e779e87f7e --- /dev/null +++ b/vcr/assets/test_assets/contexts/odrl.ldjson @@ -0,0 +1,200 @@ +{ + "@context": { + "odrl": "http://www.w3.org/ns/odrl/2/", + "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#", + "rdfs": "http://www.w3.org/2000/01/rdf-schema#", + "owl": "http://www.w3.org/2002/07/owl#", + "skos": "http://www.w3.org/2004/02/skos/core#", + "dct": "http://purl.org/dc/terms/", + "xsd": "http://www.w3.org/2001/XMLSchema#", + "vcard": "http://www.w3.org/2006/vcard/ns#", + "foaf": "http://xmlns.com/foaf/0.1/", + "schema": "http://schema.org/", + "cc": "http://creativecommons.org/ns#", + + "uid": "@id", + "type": "@type", + + "Policy": "odrl:Policy", + "Rule": "odrl:Rule", + "profile": {"@type": "@id", "@id": "odrl:profile"}, + + "inheritFrom": {"@type": "@id", "@id": "odrl:inheritFrom"}, + + "ConflictTerm": "odrl:ConflictTerm", + "conflict": {"@type": "@vocab", "@id": "odrl:conflict"}, + "perm": "odrl:perm", + "prohibit": "odrl:prohibit", + "invalid": "odrl:invalid", + + "Agreement": "odrl:Agreement", + "Assertion": "odrl:Assertion", + "Offer": "odrl:Offer", + "Privacy": "odrl:Privacy", + "Request": "odrl:Request", + "Set": "odrl:Set", + "Ticket": "odrl:Ticket", + + "Asset": "odrl:Asset", + "AssetCollection": "odrl:AssetCollection", + "relation": {"@type": "@id", "@id": "odrl:relation"}, + "hasPolicy": {"@type": "@id", "@id": "odrl:hasPolicy"}, + + "target": {"@type": "@id", "@id": "odrl:target"}, + "output": {"@type": "@id", "@id": "odrl:output"}, + + "partOf": {"@type": "@id", "@id": "odrl:partOf"}, + "source": {"@type": "@id", "@id": "odrl:source"}, + + "Party": "odrl:Party", + "PartyCollection": "odrl:PartyCollection", + "function": {"@type": "@vocab", "@id": "odrl:function"}, + "PartyScope": "odrl:PartyScope", + + "assignee": {"@type": "@id", "@id": "odrl:assignee"}, + "assigner": {"@type": "@id", "@id": "odrl:assigner"}, + "assigneeOf": {"@type": "@id", "@id": "odrl:assigneeOf"}, + "assignerOf": {"@type": "@id", "@id": "odrl:assignerOf"}, + "attributedParty": {"@type": "@id", "@id": "odrl:attributedParty"}, + "attributingParty": {"@type": "@id", "@id": "odrl:attributingParty"}, + "compensatedParty": {"@type": "@id", "@id": "odrl:compensatedParty"}, + "compensatingParty": {"@type": "@id", "@id": "odrl:compensatingParty"}, + "consentingParty": {"@type": "@id", "@id": "odrl:consentingParty"}, + "consentedParty": {"@type": "@id", "@id": "odrl:consentedParty"}, + "informedParty": {"@type": "@id", "@id": "odrl:informedParty"}, + "informingParty": {"@type": "@id", "@id": "odrl:informingParty"}, + "trackingParty": {"@type": "@id", "@id": "odrl:trackingParty"}, + "trackedParty": {"@type": "@id", "@id": "odrl:trackedParty"}, + "contractingParty": {"@type": "@id", "@id": "odrl:contractingParty"}, + "contractedParty": {"@type": "@id", "@id": "odrl:contractedParty"}, + + "Action": "odrl:Action", + "action": {"@type": "@vocab", "@id": "odrl:action"}, + "includedIn": {"@type": "@id", "@id": "odrl:includedIn"}, + "implies": {"@type": "@id", "@id": "odrl:implies"}, + + "Permission": "odrl:Permission", + "permission": {"@type": "@id", "@id": "odrl:permission"}, + + "Prohibition": "odrl:Prohibition", + "prohibition": {"@type": "@id", "@id": "odrl:prohibition"}, + + "obligation": {"@type": "@id", "@id": "odrl:obligation"}, + + "use": "odrl:use", + "grantUse": "odrl:grantUse", + "aggregate": "odrl:aggregate", + "annotate": "odrl:annotate", + "anonymize": "odrl:anonymize", + "archive": "odrl:archive", + "concurrentUse": "odrl:concurrentUse", + "derive": "odrl:derive", + "digitize": "odrl:digitize", + "display": "odrl:display", + "distribute": "odrl:distribute", + "execute": "odrl:execute", + "extract": "odrl:extract", + "give": "odrl:give", + "index": "odrl:index", + "install": "odrl:install", + "modify": "odrl:modify", + "move": "odrl:move", + "play": "odrl:play", + "present": "odrl:present", + "print": "odrl:print", + "read": "odrl:read", + "reproduce": "odrl:reproduce", + "sell": "odrl:sell", + "stream": "odrl:stream", + "textToSpeech": "odrl:textToSpeech", + "transfer": "odrl:transfer", + "transform": "odrl:transform", + "translate": "odrl:translate", + + "Duty": "odrl:Duty", + "duty": {"@type": "@id", "@id": "odrl:duty"}, + "consequence": {"@type": "@id", "@id": "odrl:consequence"}, + "remedy": {"@type": "@id", "@id": "odrl:remedy"}, + + "acceptTracking": "odrl:acceptTracking", + "attribute": "odrl:attribute", + "compensate": "odrl:compensate", + "delete": "odrl:delete", + "ensureExclusivity": "odrl:ensureExclusivity", + "include": "odrl:include", + "inform": "odrl:inform", + "nextPolicy": "odrl:nextPolicy", + "obtainConsent": "odrl:obtainConsent", + "reviewPolicy": "odrl:reviewPolicy", + "uninstall": "odrl:uninstall", + "watermark": "odrl:watermark", + + "Constraint": "odrl:Constraint", + "LogicalConstraint": "odrl:LogicalConstraint", + "constraint": {"@type": "@id", "@id": "odrl:constraint"}, + "refinement": {"@type": "@id", "@id": "odrl:refinement"}, + "Operator": "odrl:Operator", + "operator": {"@type": "@vocab", "@id": "odrl:operator"}, + "RightOperand": "odrl:RightOperand", + "rightOperand": "odrl:rightOperand", + "rightOperandReference":{"@type": "xsd:anyURI", "@id": "odrl:rightOperandReference"}, + "LeftOperand": "odrl:LeftOperand", + "leftOperand": {"@type": "@vocab", "@id": "odrl:leftOperand"}, + "unit": "odrl:unit", + "dataType": {"@type": "xsd:anyType", "@id": "odrl:datatype"}, + "status": "odrl:status", + + "absolutePosition": "odrl:absolutePosition", + "absoluteSpatialPosition": "odrl:absoluteSpatialPosition", + "absoluteTemporalPosition":"odrl:absoluteTemporalPosition", + "absoluteSize": "odrl:absoluteSize", + "count": "odrl:count", + "dateTime": "odrl:dateTime", + "delayPeriod": "odrl:delayPeriod", + "deliveryChannel": "odrl:deliveryChannel", + "elapsedTime": "odrl:elapsedTime", + "event": "odrl:event", + "fileFormat": "odrl:fileFormat", + "industry": "odrl:industry:", + "language": "odrl:language", + "media": "odrl:media", + "meteredTime": "odrl:meteredTime", + "payAmount": "odrl:payAmount", + "percentage": "odrl:percentage", + "product": "odrl:product", + "purpose": "odrl:purpose", + "recipient": "odrl:recipient", + "relativePosition": "odrl:relativePosition", + "relativeSpatialPosition": "odrl:relativeSpatialPosition", + "relativeTemporalPosition":"odrl:relativeTemporalPosition", + "relativeSize": "odrl:relativeSize", + "resolution": "odrl:resolution", + "spatial": "odrl:spatial", + "spatialCoordinates": "odrl:spatialCoordinates", + "systemDevice": "odrl:systemDevice", + "timeInterval": "odrl:timeInterval", + "unitOfCount": "odrl:unitOfCount", + "version": "odrl:version", + "virtualLocation": "odrl:virtualLocation", + + "eq": "odrl:eq", + "gt": "odrl:gt", + "gteq": "odrl:gteq", + "lt": "odrl:lt", + "lteq": "odrl:lteq", + "neq": "odrl:neg", + "isA": "odrl:isA", + "hasPart": "odrl:hasPart", + "isPartOf": "odrl:isPartOf", + "isAllOf": "odrl:isAllOf", + "isAnyOf": "odrl:isAnyOf", + "isNoneOf": "odrl:isNoneOf", + "or": "odrl:or", + "xone": "odrl:xone", + "and": "odrl:and", + "andSequence": "odrl:andSequence", + + "policyUsage": "odrl:policyUsage" + + } +} diff --git a/vcr/credential/validator.go b/vcr/credential/validator.go index 7aa72d7266..39803f709b 100644 --- a/vcr/credential/validator.go +++ b/vcr/credential/validator.go @@ -105,8 +105,8 @@ func (d defaultCredentialValidator) Validate(credential vc.VerifiableCredential) return failure("'issuanceDate' is required") } - if credential.Proof == nil { - return failure("'proof' is required") + if credential.Format() == vc.JSONLDCredentialProofFormat && credential.Proof == nil { + return failure("'proof' is required for JSON-LD credentials") } return nil diff --git a/vcr/credential/validator_test.go b/vcr/credential/validator_test.go index 0e9644afa2..76093f1c04 100644 --- a/vcr/credential/validator_test.go +++ b/vcr/credential/validator_test.go @@ -434,7 +434,7 @@ func TestDefaultCredentialValidator(t *testing.T) { err := validator.Validate(*v) - assert.EqualError(t, err, "validation failed: 'proof' is required") + assert.EqualError(t, err, "validation failed: 'proof' is required for JSON-LD credentials") }) t.Run("failed - missing default context", func(t *testing.T) { diff --git a/vcr/holder/interface.go b/vcr/holder/interface.go index a0129daa19..0b11ba7e43 100644 --- a/vcr/holder/interface.go +++ b/vcr/holder/interface.go @@ -33,6 +33,11 @@ var VerifiableCredentialLDContextV1 = ssi.MustParseURI("https://www.w3.org/2018/ // VerifiablePresentationLDType holds the JSON-LD type for Verifiable Presentations. var VerifiablePresentationLDType = ssi.MustParseURI("VerifiablePresentation") +const ( + JSONLDPresentationFormat = vc.JSONLDPresentationProofFormat + JWTPresentationFormat = vc.JWTPresentationProofFormat +) + // Wallet holds Verifiable Credentials and can present them. type Wallet interface { core.Diagnosable @@ -62,4 +67,7 @@ type PresentationOptions struct { AdditionalTypes []ssi.URI // ProofOptions contains the options for a specific proof. ProofOptions proof.ProofOptions + // Format contains the requested format for the VerifiablePresentation. If not set, it defaults to JSON-LD. + // Valid options are: ldp_vp or jwt_vp + Format string } diff --git a/vcr/holder/wallet.go b/vcr/holder/wallet.go index 24d2657939..372603dfb0 100644 --- a/vcr/holder/wallet.go +++ b/vcr/holder/wallet.go @@ -24,6 +24,8 @@ import ( "encoding/json" "errors" "fmt" + "github.com/lestrrat-go/jwx/jws" + "github.com/lestrrat-go/jwx/jwt" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" @@ -36,6 +38,7 @@ import ( "github.com/nuts-foundation/nuts-node/vcr/signature/proof" "github.com/nuts-foundation/nuts-node/vcr/verifier" "github.com/nuts-foundation/nuts-node/vdr/resolver" + "time" ) const statsShelf = "stats" @@ -90,6 +93,48 @@ func (h wallet) BuildPresentation(ctx context.Context, credentials []vc.Verifiab } } + switch options.Format { + case JWTPresentationFormat: + return h.buildJWTPresentation(ctx, *signerDID, credentials, options, key) + case "": + fallthrough + case JSONLDPresentationFormat: + return h.buildJSONLDPresentation(ctx, credentials, options, key) + default: + return nil, errors.New("unsupported presentation proof format") + } +} + +// buildJWTPresentation builds a JWT presentation according to https://www.w3.org/TR/vc-data-model/#json-web-token +func (h wallet) buildJWTPresentation(ctx context.Context, subjectDID did.DID, credentials []vc.VerifiableCredential, options PresentationOptions, key crypto.Key) (*vc.VerifiablePresentation, error) { + headers := map[string]interface{}{ + jws.TypeKey: "JWT", + } + claims := map[string]interface{}{ + jwt.IssuerKey: subjectDID.String(), + jwt.SubjectKey: subjectDID.String(), + "vp": vc.VerifiablePresentation{ + Context: append([]ssi.URI{VerifiableCredentialLDContextV1}, options.AdditionalContexts...), + Type: append([]ssi.URI{VerifiablePresentationLDType}, options.AdditionalTypes...), + VerifiableCredential: credentials, + }, + } + if options.ProofOptions.Created.IsZero() { + claims[jwt.NotBeforeKey] = time.Now().Unix() + } else { + claims[jwt.NotBeforeKey] = int(options.ProofOptions.Created.Unix()) + } + if options.ProofOptions.Expires != nil { + claims[jwt.ExpirationKey] = int(options.ProofOptions.Expires.Unix()) + } + token, err := h.keyStore.SignJWT(ctx, claims, headers, key) + if err != nil { + return nil, fmt.Errorf("unable to sign JWT presentation: %w", err) + } + return vc.ParseVerifiablePresentation(token) +} + +func (h wallet) buildJSONLDPresentation(ctx context.Context, credentials []vc.VerifiableCredential, options PresentationOptions, key crypto.Key) (*vc.VerifiablePresentation, error) { ldContext := []ssi.URI{VerifiableCredentialLDContextV1, signature.JSONWebSignature2020Context} ldContext = append(ldContext, options.AdditionalContexts...) types := []ssi.URI{VerifiablePresentationLDType} @@ -119,15 +164,8 @@ func (h wallet) BuildPresentation(ctx context.Context, credentials []vc.Verifiab if err != nil { return nil, fmt.Errorf("unable to sign VP with LD proof: %w", err) } - - var signedVP vc.VerifiablePresentation - signedVPData, _ := json.Marshal(signingResult) - err = json.Unmarshal(signedVPData, &signedVP) - if err != nil { - return nil, err - } - - return &signedVP, nil + resultJSON, _ := json.Marshal(signingResult) + return vc.ParseVerifiablePresentation(string(resultJSON)) } func (h wallet) Put(ctx context.Context, credentials ...vc.VerifiableCredential) error { diff --git a/vcr/holder/wallet_test.go b/vcr/holder/wallet_test.go index a2b9dfca02..a4c9d30dfc 100644 --- a/vcr/holder/wallet_test.go +++ b/vcr/holder/wallet_test.go @@ -58,60 +58,137 @@ func TestWallet_BuildPresentation(t *testing.T) { _ = keyStorage.SavePrivateKey(ctx, key.KID(), key.PrivateKey) keyStore := crypto.NewTestCryptoInstance(keyStorage) - options := PresentationOptions{ProofOptions: proof.ProofOptions{}} + t.Run("JSON-LD", func(t *testing.T) { + t.Run("is default", func(t *testing.T) { + ctrl := gomock.NewController(t) - t.Run("ok - one VC", func(t *testing.T) { - ctrl := gomock.NewController(t) + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) - keyResolver := resolver.NewMockKeyResolver(ctrl) - keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) + w := New(keyResolver, keyStore, nil, jsonldManager, nil) - w := New(keyResolver, keyStore, nil, jsonldManager, nil) + result, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential}, PresentationOptions{}, &testDID, false) - resultingPresentation, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential}, options, &testDID, false) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, JSONLDPresentationFormat, result.Format()) + }) + t.Run("ok - one VC", func(t *testing.T) { + ctrl := gomock.NewController(t) - require.NoError(t, err) - assert.NotNil(t, resultingPresentation) - }) - t.Run("ok - custom options", func(t *testing.T) { - ctrl := gomock.NewController(t) - specialType := ssi.MustParseURI("SpecialPresentation") - options := PresentationOptions{ - AdditionalContexts: []ssi.URI{credential.NutsV1ContextURI}, - AdditionalTypes: []ssi.URI{specialType}, - ProofOptions: proof.ProofOptions{ - ProofPurpose: "authentication", - }, - } - keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) - keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) + w := New(keyResolver, keyStore, nil, jsonldManager, nil) - w := New(keyResolver, keyStore, nil, jsonldManager, nil) + result, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential}, PresentationOptions{Format: JSONLDPresentationFormat}, &testDID, false) - resultingPresentation, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential}, options, &testDID, false) + require.NoError(t, err) + assert.NotNil(t, result) + assert.Equal(t, JSONLDPresentationFormat, result.Format()) + }) + t.Run("ok - custom options", func(t *testing.T) { + ctrl := gomock.NewController(t) + specialType := ssi.MustParseURI("SpecialPresentation") + options := PresentationOptions{ + AdditionalContexts: []ssi.URI{credential.NutsV1ContextURI}, + AdditionalTypes: []ssi.URI{specialType}, + ProofOptions: proof.ProofOptions{ + ProofPurpose: "authentication", + }, + Format: JSONLDPresentationFormat, + } + keyResolver := resolver.NewMockKeyResolver(ctrl) - require.NoError(t, err) - require.NotNil(t, resultingPresentation) - assert.True(t, resultingPresentation.IsType(specialType)) - assert.True(t, resultingPresentation.ContainsContext(credential.NutsV1ContextURI)) - proofs, _ := resultingPresentation.Proofs() - require.Len(t, proofs, 1) - assert.Equal(t, proofs[0].ProofPurpose, "authentication") + keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) + + w := New(keyResolver, keyStore, nil, jsonldManager, nil) + + result, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential}, options, &testDID, false) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.IsType(specialType)) + assert.True(t, result.ContainsContext(credential.NutsV1ContextURI)) + proofs, _ := result.Proofs() + require.Len(t, proofs, 1) + assert.Equal(t, proofs[0].ProofPurpose, "authentication") + assert.Equal(t, JSONLDPresentationFormat, result.Format()) + }) + t.Run("ok - multiple VCs", func(t *testing.T) { + ctrl := gomock.NewController(t) + + keyResolver := resolver.NewMockKeyResolver(ctrl) + + keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(vdr.TestMethodDIDA.URI(), key.Public(), nil) + + w := New(keyResolver, keyStore, nil, jsonldManager, nil) + + resultingPresentation, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential, testCredential}, PresentationOptions{Format: JSONLDPresentationFormat}, &testDID, false) + + assert.NoError(t, err) + assert.NotNil(t, resultingPresentation) + }) }) - t.Run("ok - multiple VCs", func(t *testing.T) { - ctrl := gomock.NewController(t) + t.Run("JWT", func(t *testing.T) { + options := PresentationOptions{Format: JWTPresentationFormat} + t.Run("ok - one VC", func(t *testing.T) { + ctrl := gomock.NewController(t) - keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) - keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(vdr.TestMethodDIDA.URI(), key.Public(), nil) + w := New(keyResolver, keyStore, nil, jsonldManager, nil) - w := New(keyResolver, keyStore, nil, jsonldManager, nil) + result, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential}, options, &testDID, false) - resultingPresentation, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential, testCredential}, options, &testDID, false) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, JWTPresentationFormat, result.Format()) + assert.NotNil(t, result.JWT()) + }) + t.Run("ok - multiple VCs", func(t *testing.T) { + ctrl := gomock.NewController(t) - assert.NoError(t, err) - assert.NotNil(t, resultingPresentation) + keyResolver := resolver.NewMockKeyResolver(ctrl) + + keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(vdr.TestMethodDIDA.URI(), key.Public(), nil) + + w := New(keyResolver, keyStore, nil, jsonldManager, nil) + + result, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential, testCredential}, options, &testDID, false) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, JWTPresentationFormat, result.Format()) + assert.NotNil(t, result.JWT()) + }) + t.Run("optional proof options", func(t *testing.T) { + exp := time.Now().Local().Truncate(time.Second) + options := PresentationOptions{ + Format: JWTPresentationFormat, + ProofOptions: proof.ProofOptions{ + Expires: &exp, + Created: exp.Add(-1 * time.Hour), + }, + } + + ctrl := gomock.NewController(t) + + keyResolver := resolver.NewMockKeyResolver(ctrl) + keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) + + w := New(keyResolver, keyStore, nil, jsonldManager, nil) + + result, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential}, options, &testDID, false) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, JWTPresentationFormat, result.Format()) + 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()) + }) }) t.Run("validation", func(t *testing.T) { created := time.Now() @@ -149,6 +226,22 @@ func TestWallet_BuildPresentation(t *testing.T) { assert.EqualError(t, err, "invalid credential (id="+testCredential.ID.String()+"): failed") assert.Nil(t, resultingPresentation) }) + t.Run("unsupported format", func(t *testing.T) { + ctrl := gomock.NewController(t) + + keyResolver := resolver.NewMockKeyResolver(ctrl) + mockVerifier := verifier.NewMockVerifier(ctrl) + mockVerifier.EXPECT().Validate(gomock.Any(), gomock.Any()) + + keyResolver.EXPECT().ResolveKey(testDID, nil, resolver.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) + + w := New(keyResolver, keyStore, mockVerifier, jsonldManager, nil) + + result, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential}, PresentationOptions{Format: "paper"}, &testDID, true) + + assert.EqualError(t, err, "unsupported presentation proof format") + assert.Nil(t, result) + }) }) t.Run("deriving signer from VCs", func(t *testing.T) { options := PresentationOptions{ProofOptions: proof.ProofOptions{}} diff --git a/vcr/issuer/interface.go b/vcr/issuer/interface.go index f6e55c002a..f8c3a54324 100644 --- a/vcr/issuer/interface.go +++ b/vcr/issuer/interface.go @@ -47,9 +47,7 @@ type keyResolver interface { // Issuer is a role in the network for a party who issues credentials about a subject to a holder. type Issuer interface { // Issue issues a credential by signing an unsigned credential. - // The publish param indicates if the credendential should be published to the network. - // The public param instructs the Publisher to publish the param with a certain visibility. - Issue(ctx context.Context, unsignedCredential vc.VerifiableCredential, publish, public bool) (*vc.VerifiableCredential, error) + Issue(ctx context.Context, template vc.VerifiableCredential, options CredentialOptions) (*vc.VerifiableCredential, error) // Revoke revokes a credential by the provided type. // It requires access to the private key of the issuer which will be used to sign the revocation. // It returns an error when the credential is not issued by this node or is already revoked. @@ -86,3 +84,21 @@ type CredentialSearcher interface { // If the passed context is empty, it'll not be part of the search query on the DB. SearchCredential(credentialType ssi.URI, issuer did.DID, subject *ssi.URI) ([]vc.VerifiableCredential, error) } + +const ( + JSONLDCredentialFormat = vc.JSONLDCredentialProofFormat + JWTCredentialFormat = vc.JWTCredentialProofFormat + JSONLDPresentationFormat = vc.JSONLDPresentationProofFormat + JWTPresentationFormat = vc.JWTPresentationProofFormat +) + +// CredentialOptions specifies options for issuing a credential. +type CredentialOptions struct { + // Format specifies the proof format for the issued credential. If not set, it defaults to JSON-LD. + // Valid options are: ldp_vc or jwt_vc + Format string + // Publish param indicates if the credential should be published to the network. + Publish bool + // Public param instructs the Publisher to publish the param with a certain visibility. + Public bool +} diff --git a/vcr/issuer/issuer.go b/vcr/issuer/issuer.go index 100c6062e0..620d5a02c6 100644 --- a/vcr/issuer/issuer.go +++ b/vcr/issuer/issuer.go @@ -21,6 +21,7 @@ package issuer import ( "context" "encoding/json" + "errors" "fmt" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vdr/resolver" @@ -89,8 +90,13 @@ type issuer struct { // Issue creates a new credential, signs, stores it. // If publish is true, it publishes the credential to the network using the configured Publisher // Use the public flag to pass the visibility settings to the Publisher. -func (i issuer) Issue(ctx context.Context, credentialOptions vc.VerifiableCredential, publish, public bool) (*vc.VerifiableCredential, error) { - createdVC, err := i.buildVC(ctx, credentialOptions) +func (i issuer) Issue(ctx context.Context, template vc.VerifiableCredential, options CredentialOptions) (*vc.VerifiableCredential, error) { + // Until further notice we don't support publishing JWT VCs, since they're not officially supported by Nuts yet. + if options.Publish && options.Format == JWTCredentialFormat { + return nil, errors.New("publishing VC JWTs is not supported") + } + + createdVC, err := i.buildVC(ctx, template, options) if err != nil { return nil, err } @@ -123,10 +129,10 @@ func (i issuer) Issue(ctx context.Context, credentialOptions vc.VerifiableCreden return nil, fmt.Errorf("unable to store the issued credential: %w", err) } - if publish { + if options.Publish { // Try to issue over OpenID4VCI if it's enabled and if the credential is not public // (public credentials are always published on the network). - if i.openidHandlerFn != nil && !public { + if i.openidHandlerFn != nil && !options.Public { success, err := i.issueUsingOpenID4VCI(ctx, *createdVC) if err != nil { // An error occurred, but it's not because the wallet/issuer doesn't support OpenID4VCI. @@ -145,7 +151,7 @@ func (i issuer) Issue(ctx context.Context, credentialOptions vc.VerifiableCreden Info("Wallet or issuer does not support OpenID4VCI, fallback to publish over Nuts network") } } - if err := i.networkPublisher.PublishCredential(ctx, *createdVC, public); err != nil { + if err := i.networkPublisher.PublishCredential(ctx, *createdVC, options.Public); err != nil { return nil, fmt.Errorf("unable to publish the issued credential: %w", err) } } @@ -179,12 +185,12 @@ func (i issuer) issueUsingOpenID4VCI(ctx context.Context, credential vc.Verifiab return true, i.vcrStore.StoreCredential(credential, nil) } -func (i issuer) buildVC(ctx context.Context, credentialOptions vc.VerifiableCredential) (*vc.VerifiableCredential, error) { - if len(credentialOptions.Type) != 1 { +func (i issuer) buildVC(ctx context.Context, template vc.VerifiableCredential, options CredentialOptions) (*vc.VerifiableCredential, error) { + if len(template.Type) != 1 { return nil, core.InvalidInputError("can only issue credential with 1 type") } - issuerDID, err := did.ParseDID(credentialOptions.Issuer.String()) + issuerDID, err := did.ParseDID(template.Issuer.String()) if err != nil { return nil, fmt.Errorf("failed to parse issuer: %w", err) } @@ -201,13 +207,16 @@ func (i issuer) buildVC(ctx context.Context, credentialOptions vc.VerifiableCred credentialID := ssi.MustParseURI(fmt.Sprintf("%s#%s", issuerDID.String(), uuid.New().String())) unsignedCredential := vc.VerifiableCredential{ - Context: credentialOptions.Context, + Context: template.Context, ID: &credentialID, - Type: credentialOptions.Type, - CredentialSubject: credentialOptions.CredentialSubject, - Issuer: credentialOptions.Issuer, - ExpirationDate: credentialOptions.ExpirationDate, - IssuanceDate: TimeFunc(), + Type: template.Type, + CredentialSubject: template.CredentialSubject, + Issuer: template.Issuer, + ExpirationDate: template.ExpirationDate, + IssuanceDate: template.IssuanceDate, + } + if unsignedCredential.IssuanceDate.IsZero() { + unsignedCredential.IssuanceDate = TimeFunc() } if !unsignedCredential.ContainsContext(vc.VCContextV1URI()) { unsignedCredential.Context = append(unsignedCredential.Context, vc.VCContextV1URI()) @@ -218,28 +227,34 @@ func (i issuer) buildVC(ctx context.Context, credentialOptions vc.VerifiableCred unsignedCredential.Type = append(unsignedCredential.Type, defaultType) } + switch options.Format { + case JWTCredentialFormat: + return vc.CreateJWTVerifiableCredential(ctx, unsignedCredential, func(ctx context.Context, claims map[string]interface{}, headers map[string]interface{}) (string, error) { + return i.keyStore.SignJWT(ctx, claims, headers, key) + }) + case "": + fallthrough + case JSONLDCredentialFormat: + return i.buildJSONLDCredential(ctx, unsignedCredential, key) + default: + return nil, errors.New("unsupported credential proof format") + } +} + +func (i issuer) buildJSONLDCredential(ctx context.Context, unsignedCredential vc.VerifiableCredential, key crypto.Key) (*vc.VerifiableCredential, error) { credentialAsMap := map[string]interface{}{} b, _ := json.Marshal(unsignedCredential) _ = json.Unmarshal(b, &credentialAsMap) - // Set created date to the issuanceDate if set - created := TimeFunc() - if !credentialOptions.IssuanceDate.IsZero() { - created = credentialOptions.IssuanceDate - } - proofOptions := proof.ProofOptions{Created: created} + proofOptions := proof.ProofOptions{Created: unsignedCredential.IssuanceDate} webSig := signature.JSONWebSignature2020{ContextLoader: i.jsonldManager.DocumentLoader(), Signer: i.keyStore} signingResult, err := proof.NewLDProof(proofOptions).Sign(ctx, credentialAsMap, webSig, key) if err != nil { return nil, err } - - b, _ = json.Marshal(signingResult) - signedCredential := &vc.VerifiableCredential{} - _ = json.Unmarshal(b, signedCredential) - - return signedCredential, nil + credentialJSON, _ := json.Marshal(signingResult) + return vc.ParseVerifiableCredential(string(credentialJSON)) } func (i issuer) Revoke(ctx context.Context, credentialID ssi.URI) (*credential.Revocation, error) { diff --git a/vcr/issuer/issuer_test.go b/vcr/issuer/issuer_test.go index ab5f4b3569..d978de26c7 100644 --- a/vcr/issuer/issuer_test.go +++ b/vcr/issuer/issuer_test.go @@ -20,6 +20,7 @@ package issuer import ( "context" + crypt "crypto" "encoding/json" "errors" "fmt" @@ -54,57 +55,110 @@ func Test_issuer_buildVC(t *testing.T) { issuerDID, _ := did.ParseDID(issuerID.String()) ctx := audit.TestContext() - t.Run("it builds and signs a VC", func(t *testing.T) { - ctrl := gomock.NewController(t) - kid := "did:nuts:123#abc" + const kid = "did:nuts:123#abc" + const subjectDID = "did:nuts:456" + schemaOrgContext := ssi.MustParseURI("https://schema.org") + issuance, err := time.Parse(time.RFC3339, "2022-01-02T12:00:00Z") + require.NoError(t, err) + + expirationDate := issuance.Add(time.Hour) + template := vc.VerifiableCredential{ + Context: []ssi.URI{schemaOrgContext}, + Type: []ssi.URI{credentialType}, + Issuer: issuerID, + IssuanceDate: issuance, + ExpirationDate: &expirationDate, + CredentialSubject: []interface{}{map[string]interface{}{ + "id": subjectDID, + }}, + } + keyStore := crypto.NewMemoryCryptoInstance() + signingKey, err := keyStore.New(ctx, func(key crypt.PublicKey) (string, error) { + return kid, nil + }) + require.NoError(t, err) - keyResolverMock := NewMockkeyResolver(ctrl) - keyResolverMock.EXPECT().ResolveAssertionKey(ctx, gomock.Any()).Return(crypto.NewTestKey(kid), nil) - jsonldManager := jsonld.NewTestJSONLDManager(t) - sut := issuer{keyResolver: keyResolverMock, jsonldManager: jsonldManager, keyStore: crypto.NewMemoryCryptoInstance()} - schemaOrgContext := ssi.MustParseURI("https://schema.org") + t.Run("JSON-LD", func(t *testing.T) { + t.Run("ok", func(t *testing.T) { + ctrl := gomock.NewController(t) + keyResolverMock := NewMockkeyResolver(ctrl) + keyResolverMock.EXPECT().ResolveAssertionKey(ctx, gomock.Any()).Return(signingKey, nil) + jsonldManager := jsonld.NewTestJSONLDManager(t) + sut := issuer{keyResolver: keyResolverMock, jsonldManager: jsonldManager, keyStore: keyStore} - issuance, err := time.Parse(time.RFC3339, "2022-01-02T12:00:00Z") - assert.NoError(t, err) + result, err := sut.buildVC(ctx, template, CredentialOptions{Format: JSONLDCredentialFormat}) + require.NoError(t, err) + require.NotNil(t, result) + assert.Contains(t, result.Type, credentialType, "expected vc to be of right type") + assert.Equal(t, JSONLDCredentialFormat, result.Format()) + assert.Equal(t, issuerID.String(), result.Issuer.String(), "expected correct issuer") + assert.Contains(t, result.Context, schemaOrgContext) + assert.Contains(t, result.Context, vc.VCContextV1URI()) + // Assert proof + proofs, _ := result.Proofs() + assert.Equal(t, kid, proofs[0].VerificationMethod.String(), "expected to be signed with the kid") + assert.Equal(t, issuance, proofs[0].Created) + }) + t.Run("is default", func(t *testing.T) { + ctrl := gomock.NewController(t) - credentialOptions := vc.VerifiableCredential{ - Context: []ssi.URI{schemaOrgContext}, - Type: []ssi.URI{credentialType}, - Issuer: issuerID, - IssuanceDate: issuance, - CredentialSubject: []interface{}{map[string]interface{}{ - "id": "did:nuts:456", - }}, - } - result, err := sut.buildVC(ctx, credentialOptions) - require.NoError(t, err) - require.NotNil(t, result) - assert.Contains(t, result.Type, credentialType, "expected vc to be of right type") - proofs, _ := result.Proofs() - assert.Equal(t, kid, proofs[0].VerificationMethod.String(), "expected to be signed with the kid") - assert.Equal(t, issuerID.String(), result.Issuer.String(), "expected correct issuer") - assert.Contains(t, result.Context, schemaOrgContext) - assert.Contains(t, result.Context, vc.VCContextV1URI()) - assert.Equal(t, issuance, proofs[0].Created) + keyResolverMock := NewMockkeyResolver(ctrl) + keyResolverMock.EXPECT().ResolveAssertionKey(ctx, gomock.Any()).Return(signingKey, nil) + jsonldManager := jsonld.NewTestJSONLDManager(t) + sut := issuer{keyResolver: keyResolverMock, jsonldManager: jsonldManager, keyStore: keyStore} + + result, err := sut.buildVC(ctx, template, CredentialOptions{}) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, JSONLDCredentialFormat, result.Format()) + }) + }) + t.Run("JWT", func(t *testing.T) { + t.Run("ok", func(t *testing.T) { + ctrl := gomock.NewController(t) + keyResolverMock := NewMockkeyResolver(ctrl) + keyResolverMock.EXPECT().ResolveAssertionKey(ctx, gomock.Any()).Return(signingKey, nil) + jsonldManager := jsonld.NewTestJSONLDManager(t) + sut := issuer{keyResolver: keyResolverMock, jsonldManager: jsonldManager, keyStore: keyStore} + + result, err := sut.buildVC(ctx, template, CredentialOptions{Format: JWTCredentialFormat}) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, JWTCredentialFormat, result.Format()) + assert.Contains(t, result.Type, credentialType, "expected vc to be of right type") + assert.Contains(t, result.Context, schemaOrgContext) + assert.Contains(t, result.Context, vc.VCContextV1URI()) + assert.Equal(t, template.IssuanceDate.Local(), result.IssuanceDate.Local()) + assert.Equal(t, template.ExpirationDate.Local(), result.ExpirationDate.Local()) + assert.Equal(t, template.Issuer, result.Issuer) + assert.Equal(t, template.CredentialSubject, result.CredentialSubject) + assert.Empty(t, result.Proof) + // Assert JWT + require.NotNil(t, result.JWT()) + assert.Equal(t, subjectDID, result.JWT().Subject()) + assert.Equal(t, result.IssuanceDate, result.JWT().NotBefore()) + assert.Equal(t, *result.ExpirationDate, result.JWT().Expiration()) + assert.Equal(t, result.ID.String(), result.JWT().JwtID()) + }) }) t.Run("it does not add the default context twice", func(t *testing.T) { ctrl := gomock.NewController(t) - kid := "did:nuts:123#abc" keyResolverMock := NewMockkeyResolver(ctrl) - keyResolverMock.EXPECT().ResolveAssertionKey(ctx, gomock.Any()).Return(crypto.NewTestKey(kid), nil) + keyResolverMock.EXPECT().ResolveAssertionKey(ctx, gomock.Any()).Return(signingKey, nil) jsonldManager := jsonld.NewTestJSONLDManager(t) - sut := issuer{keyResolver: keyResolverMock, jsonldManager: jsonldManager, keyStore: crypto.NewMemoryCryptoInstance()} + sut := issuer{keyResolver: keyResolverMock, jsonldManager: jsonldManager, keyStore: keyStore} - credentialOptions := vc.VerifiableCredential{ + template := vc.VerifiableCredential{ Context: []ssi.URI{vc.VCContextV1URI()}, Type: []ssi.URI{credentialType}, Issuer: issuerID, IssuanceDate: time.Now(), } - result, err := sut.buildVC(ctx, credentialOptions) + result, err := sut.buildVC(ctx, template, CredentialOptions{}) require.NoError(t, err) require.NotNil(t, result) @@ -116,10 +170,10 @@ func Test_issuer_buildVC(t *testing.T) { t.Run("wrong amount of credential types", func(t *testing.T) { sut := issuer{} - credentialOptions := vc.VerifiableCredential{ + template := vc.VerifiableCredential{ Type: []ssi.URI{}, } - result, err := sut.buildVC(ctx, credentialOptions) + result, err := sut.buildVC(ctx, template, CredentialOptions{}) assert.ErrorIs(t, err, core.InvalidInputError("can only issue credential with 1 type")) assert.Nil(t, result) @@ -128,14 +182,27 @@ func Test_issuer_buildVC(t *testing.T) { t.Run("missing issuer", func(t *testing.T) { sut := issuer{} - credentialOptions := vc.VerifiableCredential{ + template := vc.VerifiableCredential{ Type: []ssi.URI{credentialType}, } - result, err := sut.buildVC(ctx, credentialOptions) + result, err := sut.buildVC(ctx, template, CredentialOptions{}) assert.ErrorIs(t, err, did.ErrInvalidDID) assert.Nil(t, result) }) + t.Run("unsupported proof format", func(t *testing.T) { + ctrl := gomock.NewController(t) + + keyResolverMock := NewMockkeyResolver(ctrl) + keyResolverMock.EXPECT().ResolveAssertionKey(ctx, gomock.Any()).Return(signingKey, nil) + jsonldManager := jsonld.NewTestJSONLDManager(t) + sut := issuer{keyResolver: keyResolverMock, jsonldManager: jsonldManager, keyStore: keyStore} + + result, err := sut.buildVC(ctx, template, CredentialOptions{Format: "paper"}) + + assert.EqualError(t, err, "unsupported credential proof format") + assert.Nil(t, result) + }) }) t.Run("error - returned from used services", func(t *testing.T) { @@ -146,11 +213,11 @@ func Test_issuer_buildVC(t *testing.T) { keyResolverMock.EXPECT().ResolveAssertionKey(ctx, *issuerDID).Return(nil, errors.New("b00m!")) sut := issuer{keyResolver: keyResolverMock} - credentialOptions := vc.VerifiableCredential{ + template := vc.VerifiableCredential{ Type: []ssi.URI{credentialType}, Issuer: issuerID, } - _, err := sut.buildVC(ctx, credentialOptions) + _, err := sut.buildVC(ctx, template, CredentialOptions{}) assert.EqualError(t, err, "failed to sign credential: could not resolve an assertionKey for issuer: b00m!") }) @@ -161,11 +228,11 @@ func Test_issuer_buildVC(t *testing.T) { keyResolverMock.EXPECT().ResolveAssertionKey(ctx, *issuerDID).Return(nil, resolver.ErrNotFound) sut := issuer{keyResolver: keyResolverMock} - credentialOptions := vc.VerifiableCredential{ + template := vc.VerifiableCredential{ Type: []ssi.URI{credentialType}, Issuer: issuerID, } - _, err := sut.buildVC(ctx, credentialOptions) + _, err := sut.buildVC(ctx, template, CredentialOptions{}) assert.ErrorIs(t, err, core.InvalidInputError("failed to sign credential: could not resolve an assertionKey for issuer: unable to find the DID document")) }) }) @@ -177,7 +244,7 @@ func Test_issuer_Issue(t *testing.T) { issuerKeyID := issuerDID.String() + "#abc" holderDID := did.MustParseDID("did:nuts:456") - credentialOptions := vc.VerifiableCredential{ + template := vc.VerifiableCredential{ Context: []ssi.URI{credential.NutsV1ContextURI}, Type: []ssi.URI{credentialType}, Issuer: issuerDID.URI(), @@ -202,7 +269,10 @@ func Test_issuer_Issue(t *testing.T) { keyStore: crypto.NewMemoryCryptoInstance(), } - result, err := sut.Issue(ctx, credentialOptions, false, true) + result, err := sut.Issue(ctx, template, CredentialOptions{ + Publish: false, + Public: true, + }) require.NoError(t, err) assert.Contains(t, result.Type, credentialType, "expected vc to be of right type") proofs, _ := result.Proofs() @@ -214,6 +284,18 @@ func Test_issuer_Issue(t *testing.T) { assert.True(t, trustConfig.IsTrusted(credentialType, result.Issuer)) }) + t.Run("publishing JWT VCs is disallowed", func(t *testing.T) { + sut := issuer{} + + result, err := sut.Issue(ctx, template, CredentialOptions{ + Publish: true, + Public: true, + Format: JWTCredentialFormat, + }) + require.EqualError(t, err, "publishing VC JWTs is not supported") + assert.Nil(t, result) + }) + t.Run("OpenID4VCI", func(t *testing.T) { const walletIdentifier = "http://example.com/wallet" t.Run("ok - publish over OpenID4VCI fails - fallback to network", func(t *testing.T) { @@ -244,7 +326,10 @@ func Test_issuer_Issue(t *testing.T) { networkPublisher: publisher, } - result, err := sut.Issue(ctx, credentialOptions, true, false) + result, err := sut.Issue(ctx, template, CredentialOptions{ + Publish: true, + Public: false, + }) require.NoError(t, err) assert.NotNil(t, result) @@ -266,7 +351,10 @@ func Test_issuer_Issue(t *testing.T) { networkPublisher: publisher, } - result, err := sut.Issue(ctx, credentialOptions, true, false) + result, err := sut.Issue(ctx, template, CredentialOptions{ + Publish: true, + Public: false, + }) require.NoError(t, err) assert.NotNil(t, result) @@ -291,7 +379,10 @@ func Test_issuer_Issue(t *testing.T) { networkPublisher: publisher, } - result, err := sut.Issue(ctx, credentialOptions, true, false) + result, err := sut.Issue(ctx, template, CredentialOptions{ + Publish: true, + Public: false, + }) require.NoError(t, err) assert.NotNil(t, result) @@ -324,7 +415,10 @@ func Test_issuer_Issue(t *testing.T) { vcrStore: vcrStore, } - result, err := sut.Issue(ctx, credentialOptions, true, false) + result, err := sut.Issue(ctx, template, CredentialOptions{ + Publish: true, + Public: false, + }) require.NoError(t, err) assert.NotNil(t, result) @@ -346,7 +440,10 @@ func Test_issuer_Issue(t *testing.T) { keyStore: crypto.NewMemoryCryptoInstance(), } - result, err := sut.Issue(ctx, credentialOptions, false, true) + result, err := sut.Issue(ctx, template, CredentialOptions{ + Publish: false, + Public: true, + }) assert.EqualError(t, err, "unable to store the issued credential: b00m!") assert.Nil(t, result) }) @@ -366,7 +463,10 @@ func Test_issuer_Issue(t *testing.T) { keyStore: crypto.NewMemoryCryptoInstance(), } - result, err := sut.Issue(ctx, credentialOptions, true, true) + result, err := sut.Issue(ctx, template, CredentialOptions{ + Publish: true, + Public: true, + }) assert.EqualError(t, err, "unable to publish the issued credential: b00m!") assert.Nil(t, result) }) @@ -379,7 +479,10 @@ func Test_issuer_Issue(t *testing.T) { Issuer: issuerDID.URI(), } - result, err := sut.Issue(ctx, credentialOptions, true, true) + result, err := sut.Issue(ctx, credentialOptions, CredentialOptions{ + Publish: true, + Public: true, + }) assert.EqualError(t, err, "can only issue credential with 1 type") assert.Nil(t, result) @@ -393,12 +496,15 @@ func Test_issuer_Issue(t *testing.T) { mockStore := NewMockStore(ctrl) sut := issuer{keyResolver: keyResolverMock, store: mockStore, jsonldManager: jsonldManager, keyStore: crypto.NewMemoryCryptoInstance()} - invalidCred := credentialOptions + invalidCred := template invalidCred.CredentialSubject = []interface{}{ map[string]interface{}{"foo": "bar"}, } - result, err := sut.Issue(ctx, invalidCred, true, true) + result, err := sut.Issue(ctx, invalidCred, CredentialOptions{ + Publish: true, + Public: true, + }) assert.EqualError(t, err, "validation failed: invalid property: Dropping property that did not expand into an absolute IRI or keyword.") assert.Nil(t, result) }) diff --git a/vcr/issuer/leia_store_test.go b/vcr/issuer/leia_store_test.go index af7d8162f2..d12a8b6ace 100644 --- a/vcr/issuer/leia_store_test.go +++ b/vcr/issuer/leia_store_test.go @@ -88,51 +88,52 @@ func TestLeiaIssuerStore_StoreCredential(t *testing.T) { } func Test_leiaStore_StoreAndSearchCredential(t *testing.T) { - vcToStore := vc.VerifiableCredential{} - _ = json.Unmarshal([]byte(jsonld.TestCredential), &vcToStore) + expected := vc.VerifiableCredential{} + _ = json.Unmarshal([]byte(jsonld.TestCredential), &expected) + expectedJSON, _ := expected.MarshalJSON() t.Run("store", func(t *testing.T) { sut := newStore(t) - err := sut.StoreCredential(vcToStore) + err := sut.StoreCredential(expected) assert.NoError(t, err) t.Run("and search", func(t *testing.T) { - issuerDID, _ := did.ParseDID(vcToStore.Issuer.String()) + issuerDID, _ := did.ParseDID(expected.Issuer.String()) subjectID := ssi.MustParseURI("did:nuts:GvkzxsezHvEc8nGhgz6Xo3jbqkHwswLmWw3CYtCm7hAW") t.Run("for all issued credentials for a issuer", func(t *testing.T) { - res, err := sut.SearchCredential(vcToStore.Type[0], *issuerDID, nil) + res, err := sut.SearchCredential(expected.Type[0], *issuerDID, nil) assert.NoError(t, err) require.Len(t, res, 1) foundVC := res[0] - assert.Equal(t, vcToStore, foundVC) + require.NoError(t, err) + foundJSON, _ := foundVC.MarshalJSON() + assert.JSONEq(t, string(expectedJSON), string(foundJSON)) }) t.Run("for all issued credentials for a issuer and subject", func(t *testing.T) { - res, err := sut.SearchCredential(vcToStore.Type[0], *issuerDID, &subjectID) + res, err := sut.SearchCredential(expected.Type[0], *issuerDID, &subjectID) assert.NoError(t, err) require.Len(t, res, 1) - - foundVC := res[0] - assert.Equal(t, vcToStore, foundVC) + foundJSON, _ := res[0].MarshalJSON() + assert.JSONEq(t, string(expectedJSON), string(foundJSON)) }) t.Run("without context", func(t *testing.T) { - res, err := sut.SearchCredential(vcToStore.Type[0], *issuerDID, nil) + res, err := sut.SearchCredential(expected.Type[0], *issuerDID, nil) assert.NoError(t, err) require.Len(t, res, 1) - - foundVC := res[0] - assert.Equal(t, vcToStore, foundVC) + foundJSON, _ := res[0].MarshalJSON() + assert.JSONEq(t, string(expectedJSON), string(foundJSON)) }) t.Run("no results", func(t *testing.T) { t.Run("unknown issuer", func(t *testing.T) { unknownIssuerDID, _ := did.ParseDID("did:nuts:123") - res, err := sut.SearchCredential(vcToStore.Type[0], *unknownIssuerDID, nil) + res, err := sut.SearchCredential(expected.Type[0], *unknownIssuerDID, nil) assert.NoError(t, err) require.Len(t, res, 0) }) @@ -146,7 +147,7 @@ func Test_leiaStore_StoreAndSearchCredential(t *testing.T) { t.Run("unknown subject", func(t *testing.T) { unknownSubject := ssi.MustParseURI("did:nuts:unknown") - res, err := sut.SearchCredential(vcToStore.Type[0], *issuerDID, &unknownSubject) + res, err := sut.SearchCredential(expected.Type[0], *issuerDID, &unknownSubject) assert.NoError(t, err) require.Len(t, res, 0) }) @@ -158,22 +159,24 @@ func Test_leiaStore_StoreAndSearchCredential(t *testing.T) { } func Test_leiaStore_GetCredential(t *testing.T) { - vcToGet := vc.VerifiableCredential{} - _ = json.Unmarshal([]byte(jsonld.TestCredential), &vcToGet) + expected := vc.VerifiableCredential{} + _ = json.Unmarshal([]byte(jsonld.TestCredential), &expected) + expectedJSON, _ := expected.MarshalJSON() t.Run("with a known credential", func(t *testing.T) { store := newStore(t) - assert.NoError(t, store.StoreCredential(vcToGet)) + assert.NoError(t, store.StoreCredential(expected)) t.Run("it finds the credential by id", func(t *testing.T) { - foundCredential, err := store.GetCredential(*vcToGet.ID) - assert.NoError(t, err) - assert.Equal(t, *foundCredential, vcToGet) + foundCredential, err := store.GetCredential(*expected.ID) + require.NoError(t, err) + foundJSON, _ := foundCredential.MarshalJSON() + assert.JSONEq(t, string(expectedJSON), string(foundJSON)) }) }) t.Run("no results", func(t *testing.T) { store := newStore(t) - foundCredential, err := store.GetCredential(*vcToGet.ID) + foundCredential, err := store.GetCredential(*expected.ID) assert.ErrorIs(t, err, types.ErrNotFound) assert.Nil(t, foundCredential) }) @@ -181,17 +184,17 @@ func Test_leiaStore_GetCredential(t *testing.T) { t.Run("multiple results", func(t *testing.T) { store := newStore(t) // store once - assert.NoError(t, store.StoreCredential(vcToGet)) + assert.NoError(t, store.StoreCredential(expected)) // store twice lstore := store.(*leiaIssuerStore) rawStructWithSameID := struct { ID *ssi.URI `json:"id,omitempty"` - }{ID: vcToGet.ID} + }{ID: expected.ID} asBytes, _ := json.Marshal(rawStructWithSameID) lstore.issuedCollection().Add([]leia.Document{asBytes}) t.Run("it fails", func(t *testing.T) { - foundCredential, err := store.GetCredential(*vcToGet.ID) + foundCredential, err := store.GetCredential(*expected.ID) assert.ErrorIs(t, err, types.ErrMultipleFound) assert.Nil(t, foundCredential) }) diff --git a/vcr/issuer/mock.go b/vcr/issuer/mock.go index d41c9326d3..5724b935df 100644 --- a/vcr/issuer/mock.go +++ b/vcr/issuer/mock.go @@ -134,18 +134,18 @@ func (m *MockIssuer) EXPECT() *MockIssuerMockRecorder { } // Issue mocks base method. -func (m *MockIssuer) Issue(ctx context.Context, unsignedCredential vc.VerifiableCredential, publish, public bool) (*vc.VerifiableCredential, error) { +func (m *MockIssuer) Issue(ctx context.Context, template vc.VerifiableCredential, options CredentialOptions) (*vc.VerifiableCredential, error) { m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Issue", ctx, unsignedCredential, publish, public) + ret := m.ctrl.Call(m, "Issue", ctx, template, options) ret0, _ := ret[0].(*vc.VerifiableCredential) ret1, _ := ret[1].(error) return ret0, ret1 } // Issue indicates an expected call of Issue. -func (mr *MockIssuerMockRecorder) Issue(ctx, unsignedCredential, publish, public any) *gomock.Call { +func (mr *MockIssuerMockRecorder) Issue(ctx, template, options any) *gomock.Call { mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Issue", reflect.TypeOf((*MockIssuer)(nil).Issue), ctx, unsignedCredential, publish, public) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Issue", reflect.TypeOf((*MockIssuer)(nil).Issue), ctx, template, options) } // Revoke mocks base method. diff --git a/vcr/issuer/openid.go b/vcr/issuer/openid.go index a8ee1ec298..18b3d68752 100644 --- a/vcr/issuer/openid.go +++ b/vcr/issuer/openid.go @@ -34,6 +34,7 @@ import ( "github.com/nuts-foundation/nuts-node/audit" "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/issuer/assets" "github.com/nuts-foundation/nuts-node/vcr/log" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" @@ -57,14 +58,6 @@ type Flow struct { // Credentials is the list of Verifiable Credentials that be issued to the wallet through this flow. // It might be pre-determined (in the issuer-initiated flow) or determined during the flow execution (in the wallet-initiated flow). Credentials []vc.VerifiableCredential `json:"credentials"` - Expiry time.Time `json:"exp"` -} - -// Nonce is a nonce that has been issued for an OpenID4VCI flow, to be used by the wallet when requesting credentials. -// A nonce can only be used once (doh), and is only valid for a certain period of time. -type Nonce struct { - Nonce string `json:"nonce"` - Expiry time.Time `json:"exp"` } // Grant is a grant that has been issued for an OAuth2 state. @@ -75,8 +68,6 @@ type Grant struct { Params map[string]interface{} `json:"params"` } -// ErrUnknownIssuer is returned when the given issuer is unknown. -var ErrUnknownIssuer = errors.New("unknown OpenID4VCI issuer") var _ OpenIDHandler = (*openidHandler)(nil) // TokenTTL is the time-to-live for issuance flows, access tokens and nonces. @@ -105,7 +96,7 @@ type OpenIDHandler interface { } // NewOpenIDHandler creates a new OpenIDHandler instance. The identifier is the Credential Issuer Identifier, e.g. https://example.com/issuer/ -func NewOpenIDHandler(issuerDID did.DID, issuerIdentifierURL string, definitionsDIR string, httpClient core.HTTPRequestDoer, keyResolver resolver.KeyResolver, store OpenIDStore) (OpenIDHandler, error) { +func NewOpenIDHandler(issuerDID did.DID, issuerIdentifierURL string, definitionsDIR string, httpClient core.HTTPRequestDoer, keyResolver resolver.KeyResolver, sessionDatabase storage.SessionDatabase) (OpenIDHandler, error) { i := &openidHandler{ issuerIdentifierURL: issuerIdentifierURL, issuerDID: issuerDID, @@ -113,7 +104,7 @@ func NewOpenIDHandler(issuerDID did.DID, issuerIdentifierURL string, definitions httpClient: httpClient, keyResolver: keyResolver, walletClientCreator: openid4vci.NewWalletAPIClient, - store: store, + store: NewOpenIDMemoryStore(sessionDatabase), } // load the credential definitions. This is done to halt startup procedure if needed. @@ -174,12 +165,12 @@ func (i *openidHandler) HandleAccessTokenRequest(ctx context.Context, preAuthori } } accessToken := generateCode() - err = i.store.StoreReference(ctx, flow.ID, accessTokenRefType, accessToken, time.Now().Add(TokenTTL)) + err = i.store.StoreReference(ctx, flow.ID, accessTokenRefType, accessToken) if err != nil { return "", "", err } cNonce := generateCode() - err = i.store.StoreReference(ctx, flow.ID, cNonceRefType, cNonce, time.Now().Add(TokenTTL)) + err = i.store.StoreReference(ctx, flow.ID, cNonceRefType, cNonce) if err != nil { return "", "", err } @@ -294,7 +285,7 @@ func (i *openidHandler) validateProof(ctx context.Context, flow *Flow, request o // augment invalid_proof errors according to §7.3.2 of openid4vci spec generateProofError := func(err openid4vci.Error) error { cnonce := generateCode() - if err := i.store.StoreReference(ctx, flow.ID, cNonceRefType, cnonce, time.Now().Add(TokenTTL)); err != nil { + if err := i.store.StoreReference(ctx, flow.ID, cNonceRefType, cnonce); err != nil { return err } expiry := int(TokenTTL.Seconds()) @@ -438,7 +429,6 @@ func (i *openidHandler) createOffer(ctx context.Context, credential vc.Verifiabl ID: uuid.NewString(), IssuerID: credential.Issuer.String(), WalletID: subjectDID.String(), - Expiry: time.Now().Add(TokenTTL), Credentials: []vc.VerifiableCredential{credential}, Grants: []Grant{ { @@ -449,7 +439,7 @@ func (i *openidHandler) createOffer(ctx context.Context, credential vc.Verifiabl } err := i.store.Store(ctx, flow) if err == nil { - err = i.store.StoreReference(ctx, flow.ID, preAuthCodeRefType, preAuthorizedCode, time.Now().Add(TokenTTL)) + err = i.store.StoreReference(ctx, flow.ID, preAuthCodeRefType, preAuthorizedCode) } if err != nil { return nil, fmt.Errorf("unable to store credential offer: %w", err) diff --git a/vcr/issuer/openid_store.go b/vcr/issuer/openid_store.go index 689556cae8..0471301164 100644 --- a/vcr/issuer/openid_store.go +++ b/vcr/issuer/openid_store.go @@ -21,9 +21,7 @@ package issuer import ( "context" "errors" - "github.com/nuts-foundation/nuts-node/vcr/log" - "sync" - "time" + "github.com/nuts-foundation/nuts-node/storage" ) // OpenIDStore defines the storage API for OpenID Credential Issuance flows. @@ -35,164 +33,71 @@ type OpenIDStore interface { // like a database index. The reference must be unique for all flows. // The expiry is the time-to-live for the reference. After this time, the reference is automatically deleted. // If the flow does not exist, or the reference does already exist, it returns an error. - StoreReference(ctx context.Context, flowID string, refType string, reference string, expiry time.Time) error + StoreReference(ctx context.Context, flowID string, refType string, reference string) error // FindByReference finds a Flow by its reference. // If the flow does not exist, it returns nil. FindByReference(ctx context.Context, refType string, reference string) (*Flow, error) // DeleteReference deletes the reference from the store. // It does not return an error if it doesn't exist anymore. DeleteReference(ctx context.Context, refType string, reference string) error - // Close signals the store to close any owned resources. - Close() } var _ OpenIDStore = (*openidMemoryStore)(nil) -var openidStorePruneInterval = 10 * time.Minute - type openidMemoryStore struct { - mux *sync.RWMutex - flows map[string]Flow - refs map[string]map[string]referenceValue - routines *sync.WaitGroup - ctx context.Context - cancel context.CancelFunc + sessionDatabase storage.SessionDatabase } // NewOpenIDMemoryStore creates a new in-memory OpenIDStore. -func NewOpenIDMemoryStore() OpenIDStore { - result := &openidMemoryStore{ - mux: &sync.RWMutex{}, - flows: map[string]Flow{}, - refs: map[string]map[string]referenceValue{}, - routines: &sync.WaitGroup{}, +func NewOpenIDMemoryStore(sessionDatabase storage.SessionDatabase) OpenIDStore { + return &openidMemoryStore{ + sessionDatabase: sessionDatabase, } - result.ctx, result.cancel = context.WithCancel(context.Background()) - result.startPruning(openidStorePruneInterval) - return result -} - -type referenceValue struct { - FlowID string `json:"flow_id"` - Expiry time.Time `json:"exp"` } func (o *openidMemoryStore) Store(_ context.Context, flow Flow) error { if len(flow.ID) == 0 { return errors.New("invalid flow ID") } - o.mux.Lock() - defer o.mux.Unlock() - if o.flows[flow.ID].ID != "" { + store := o.sessionDatabase.GetStore(TokenTTL, "openid4vci", "flow") + if store.Exists(flow.ID) { return errors.New("OAuth2 flow with this ID already exists") } - o.flows[flow.ID] = flow - return nil + return store.Put(flow.ID, flow) } -func (o *openidMemoryStore) StoreReference(_ context.Context, flowID string, refType string, reference string, expiry time.Time) error { +func (o *openidMemoryStore) StoreReference(_ context.Context, flowID string, refType string, reference string) error { if len(reference) == 0 { return errors.New("invalid reference") } - o.mux.Lock() - defer o.mux.Unlock() - if o.flows[flowID].ID == "" { + refStore := o.sessionDatabase.GetStore(TokenTTL, "openid4vci", refType) + flowStore := o.sessionDatabase.GetStore(TokenTTL, "openid4vci", "flow") + if !flowStore.Exists(flowID) { return errors.New("OAuth2 flow with this ID does not exist") } - if o.refs[refType] == nil { - o.refs[refType] = map[string]referenceValue{} - } - if _, ok := o.refs[refType][reference]; ok { + if refStore.Exists(reference) { return errors.New("reference already exists") } - o.refs[refType][reference] = referenceValue{FlowID: flowID, Expiry: expiry} - return nil + return refStore.Put(reference, flowID) } func (o *openidMemoryStore) FindByReference(_ context.Context, refType string, reference string) (*Flow, error) { - o.mux.RLock() - defer o.mux.RUnlock() - - refMap := o.refs[refType] - if refMap == nil { - return nil, nil - } - value, ok := refMap[reference] - if !ok { - return nil, nil - } - if value.Expiry.Before(time.Now()) { + refStore := o.sessionDatabase.GetStore(TokenTTL, "openid4vci", refType) + flowStore := o.sessionDatabase.GetStore(TokenTTL, "openid4vci", "flow") + if !refStore.Exists(reference) { return nil, nil } - - flow := o.flows[value.FlowID] - if flow.Expiry.Before(time.Now()) { - return nil, nil + var flowID string + err := refStore.Get(reference, &flowID) + if err != nil { + return nil, err } - return &flow, nil + var flow Flow + err = flowStore.Get(flowID, &flow) + return &flow, err } func (o *openidMemoryStore) DeleteReference(_ context.Context, refType string, reference string) error { - o.mux.Lock() - defer o.mux.Unlock() - - if o.refs[refType] == nil { - return nil - } - delete(o.refs[refType], reference) - return nil -} - -func (o *openidMemoryStore) Close() { - // Signal pruner to stop and wait for it to finish - o.cancel() - o.routines.Wait() -} - -func (o *openidMemoryStore) startPruning(interval time.Duration) { - ticker := time.NewTicker(interval) - o.routines.Add(1) - go func(ctx context.Context) { - defer o.routines.Done() - for { - select { - case <-ctx.Done(): - ticker.Stop() - return - case <-ticker.C: - flowsPruned, refsPruned := o.prune() - if flowsPruned > 0 || refsPruned > 0 { - log.Logger().Debugf("Pruned %d expired OpenID4VCI flows and %d expired refs", flowsPruned, refsPruned) - } - } - } - }(o.ctx) -} - -func (o *openidMemoryStore) prune() (int, int) { - o.mux.Lock() - defer o.mux.Unlock() - - moment := time.Now() - - // Find expired flows and delete them - var flowCount int - for id, flow := range o.flows { - if flow.Expiry.Before(moment) { - flowCount++ - delete(o.flows, id) - } - } - // Find expired refs and delete them - var refCount int - for _, refMap := range o.refs { - for reference, value := range refMap { - if value.Expiry.Before(moment) { - refCount++ - delete(refMap, reference) - } - } - } - - return flowCount, refCount + refStore := o.sessionDatabase.GetStore(TokenTTL, "openid4vci", refType) + return refStore.Delete(reference) } diff --git a/vcr/issuer/openid_store_test.go b/vcr/issuer/openid_store_test.go index a779e949ce..9fcbf80109 100644 --- a/vcr/issuer/openid_store_test.go +++ b/vcr/issuer/openid_store_test.go @@ -20,11 +20,9 @@ package issuer import ( "context" - "github.com/nuts-foundation/nuts-node/test" + "github.com/nuts-foundation/nuts-node/storage" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" "testing" - "time" ) const refType = "ref-type" @@ -34,12 +32,11 @@ func Test_memoryStore_DeleteReference(t *testing.T) { t.Run("ok", func(t *testing.T) { store := createStore(t) expected := Flow{ - ID: "flow-id", - Expiry: futureExpiry(), + ID: "flow-id", } err := store.Store(context.Background(), expected) assert.NoError(t, err) - err = store.StoreReference(context.Background(), expected.ID, refType, ref, futureExpiry()) + err = store.StoreReference(context.Background(), expected.ID, refType, ref) assert.NoError(t, err) err = store.DeleteReference(context.Background(), refType, ref) @@ -63,49 +60,31 @@ func Test_memoryStore_FindByReference(t *testing.T) { t.Run("reference already exists", func(t *testing.T) { store := createStore(t) expected := Flow{ - ID: "flow-id", - Expiry: futureExpiry(), + ID: "flow-id", } err := store.Store(context.Background(), expected) assert.NoError(t, err) - err = store.StoreReference(context.Background(), expected.ID, refType, ref, futureExpiry()) + err = store.StoreReference(context.Background(), expected.ID, refType, ref) assert.NoError(t, err) - err = store.StoreReference(context.Background(), expected.ID, refType, ref, futureExpiry()) + err = store.StoreReference(context.Background(), expected.ID, refType, ref) assert.EqualError(t, err, "reference already exists") }) t.Run("invalid reference", func(t *testing.T) { store := createStore(t) - err := store.StoreReference(context.Background(), "unknown", refType, "", futureExpiry()) + err := store.StoreReference(context.Background(), "unknown", refType, "") assert.EqualError(t, err, "invalid reference") }) t.Run("unknown flow", func(t *testing.T) { store := createStore(t) - err := store.StoreReference(context.Background(), "unknown", refType, ref, futureExpiry()) + err := store.StoreReference(context.Background(), "unknown", refType, ref) assert.EqualError(t, err, "OAuth2 flow with this ID does not exist") }) - t.Run("reference has expired", func(t *testing.T) { - store := createStore(t) - expected := Flow{ - ID: "flow-id", - Expiry: futureExpiry(), - } - - err := store.Store(context.Background(), expected) - assert.NoError(t, err) - // We need a reference to resolve it - err = store.StoreReference(context.Background(), expected.ID, refType, ref, pastExpiry()) - assert.NoError(t, err) - - actual, err := store.FindByReference(context.Background(), refType, ref) - assert.NoError(t, err) - assert.Nil(t, actual) - }) } func Test_memoryStore_Store(t *testing.T) { @@ -113,14 +92,13 @@ func Test_memoryStore_Store(t *testing.T) { t.Run("write, then read", func(t *testing.T) { store := createStore(t) expected := Flow{ - ID: "flow-id", - Expiry: futureExpiry(), + ID: "flow-id", } err := store.Store(ctx, expected) assert.NoError(t, err) // We need a reference to resolve it - err = store.StoreReference(ctx, expected.ID, refType, ref, futureExpiry()) + err = store.StoreReference(ctx, expected.ID, refType, ref) assert.NoError(t, err) actual, err := store.FindByReference(ctx, refType, ref) @@ -130,8 +108,7 @@ func Test_memoryStore_Store(t *testing.T) { t.Run("already exists", func(t *testing.T) { store := createStore(t) expected := Flow{ - ID: "flow-id", - Expiry: futureExpiry(), + ID: "flow-id", } err := store.Store(ctx, expected) @@ -140,124 +117,10 @@ func Test_memoryStore_Store(t *testing.T) { assert.EqualError(t, err, "OAuth2 flow with this ID already exists") }) - t.Run("flow has expired", func(t *testing.T) { - store := createStore(t) - expected := Flow{ - ID: "flow-id", - Expiry: pastExpiry(), - } - - err := store.Store(ctx, expected) - assert.NoError(t, err) - // We need a reference to resolve it - err = store.StoreReference(ctx, expected.ID, refType, ref, futureExpiry()) - assert.NoError(t, err) - - actual, err := store.FindByReference(ctx, refType, ref) - assert.NoError(t, err) - assert.Nil(t, actual) - }) -} - -func Test_memoryStore_Close(t *testing.T) { - t.Run("assert Close() waits for pruning to finish to avoid leaking goroutines", func(t *testing.T) { - openidStorePruneInterval = 10 * time.Millisecond - store := createStore(t) - time.Sleep(50 * time.Millisecond) // make sure pruning is running - store.Close() - }) -} - -func Test_memoryStore_prune(t *testing.T) { - ctx := context.Background() - t.Run("automatic", func(t *testing.T) { - store := createStore(t) - // we call startPruning a second time ourselves, make sure not to leak the original goroutine - cancelFunc := store.cancel - defer cancelFunc() - store.startPruning(10 * time.Millisecond) - - // Feed it something to prune - expiredFlow := Flow{ - ID: "expired", - } - err := store.Store(ctx, expiredFlow) - require.NoError(t, err) - - test.WaitFor(t, func() (bool, error) { - store.mux.Lock() - defer store.mux.Unlock() - _, exists := store.flows[expiredFlow.ID] - return !exists, nil - }, time.Second, "time-out waiting for flow to be pruned") - }) - t.Run("prunes expired flows", func(t *testing.T) { - store := createStore(t) - - expiredFlow := Flow{ - ID: "expired", - } - unexpiredFlow := Flow{ - ID: "unexpired", - Expiry: futureExpiry(), - } - _ = store.Store(ctx, expiredFlow) - _ = store.Store(ctx, unexpiredFlow) - - flows, refs := store.prune() - - assert.Equal(t, 1, flows) - assert.Equal(t, 0, refs) - - // Second round to assert there's nothing to prune now - flows, refs = store.prune() - - assert.Equal(t, 0, flows) - assert.Equal(t, 0, refs) - }) - t.Run("prunes expired refs", func(t *testing.T) { - store := createStore(t) - - flow := Flow{ - ID: "f", - Expiry: futureExpiry(), - } - err := store.Store(ctx, flow) - require.NoError(t, err) - err = store.StoreReference(ctx, flow.ID, refType, "expired", pastExpiry()) - require.NoError(t, err) - err = store.StoreReference(ctx, flow.ID, refType, "unexpired", futureExpiry()) - require.NoError(t, err) - - flows, refs := store.prune() - - assert.Equal(t, 0, flows) - assert.Equal(t, 1, refs) - - // Second round to assert there's nothing to prune now - flows, refs = store.prune() - - assert.NoError(t, err) - assert.Equal(t, 0, flows) - assert.Equal(t, 0, refs) - }) } func createStore(t *testing.T) *openidMemoryStore { - store := NewOpenIDMemoryStore().(*openidMemoryStore) - t.Cleanup(store.Close) + storageDatabase := storage.NewTestInMemorySessionDatabase(t) + store := NewOpenIDMemoryStore(storageDatabase).(*openidMemoryStore) return store } - -func moment() time.Time { - return time.Now().In(time.UTC) -} - -func pastExpiry() time.Time { - return moment().Add(-time.Hour) -} - -func futureExpiry() time.Time { - // truncating makes assertion easier - return moment().Add(time.Hour).Truncate(time.Second) -} diff --git a/vcr/issuer/openid_test.go b/vcr/issuer/openid_test.go index c19199c7e5..281eeaaa0f 100644 --- a/vcr/issuer/openid_test.go +++ b/vcr/issuer/openid_test.go @@ -28,6 +28,7 @@ import ( "github.com/nuts-foundation/nuts-node/audit" "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/openid4vci" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/assert" @@ -65,21 +66,21 @@ var issuedVC = vc.VerifiableCredential{ func TestNew(t *testing.T) { t.Run("custom definitions", func(t *testing.T) { - iss, err := NewOpenIDHandler(issuerDID, issuerIdentifier, "./test/valid", nil, nil, NewOpenIDMemoryStore()) + iss, err := NewOpenIDHandler(issuerDID, issuerIdentifier, "./test/valid", nil, nil, storage.NewTestInMemorySessionDatabase(t)) require.NoError(t, err) assert.Len(t, iss.(*openidHandler).credentialsSupported, 3) }) t.Run("error - invalid json", func(t *testing.T) { - _, err := NewOpenIDHandler(issuerDID, issuerIdentifier, "./test/invalid", nil, nil, NewOpenIDMemoryStore()) + _, err := NewOpenIDHandler(issuerDID, issuerIdentifier, "./test/invalid", nil, nil, storage.NewTestInMemorySessionDatabase(t)) require.Error(t, err) assert.EqualError(t, err, "failed to parse credential definition from test/invalid/invalid.json: unexpected end of JSON input") }) t.Run("error - invalid directory", func(t *testing.T) { - _, err := NewOpenIDHandler(issuerDID, issuerIdentifier, "./test/non_existing", nil, nil, NewOpenIDMemoryStore()) + _, err := NewOpenIDHandler(issuerDID, issuerIdentifier, "./test/non_existing", nil, nil, storage.NewTestInMemorySessionDatabase(t)) require.Error(t, err) assert.EqualError(t, err, "failed to load credential definitions: lstat ./test/non_existing: no such file or directory") @@ -396,7 +397,7 @@ func Test_memoryIssuer_HandleAccessTokenRequest(t *testing.T) { assert.NotEmpty(t, accessToken) }) t.Run("pre-authorized code issued by other issuer", func(t *testing.T) { - store := NewOpenIDMemoryStore() + store := storage.NewTestInMemorySessionDatabase(t) service, err := NewOpenIDHandler(issuerDID, issuerIdentifier, definitionsDIR, &http.Client{}, nil, store) require.NoError(t, err) _, err = service.(*openidHandler).createOffer(ctx, issuedVC, "code") @@ -435,7 +436,7 @@ func assertProtocolError(t *testing.T, err error, statusCode int, message string } func requireNewTestHandler(t *testing.T, keyResolver resolver.KeyResolver) *openidHandler { - service, err := NewOpenIDHandler(issuerDID, issuerIdentifier, definitionsDIR, &http.Client{}, keyResolver, NewOpenIDMemoryStore()) + service, err := NewOpenIDHandler(issuerDID, issuerIdentifier, definitionsDIR, &http.Client{}, keyResolver, storage.NewTestInMemorySessionDatabase(t)) require.NoError(t, err) return service.(*openidHandler) } diff --git a/vcr/pe/schema/README.md b/vcr/pe/schema/README.md index fac22a7041..bf6b681e8b 100644 --- a/vcr/pe/schema/README.md +++ b/vcr/pe/schema/README.md @@ -1,11 +1,3 @@ -# generate structs from JSON schema - -From this directory, run: - -```shell -go run . -``` - -It'll generate `generated.go` within the `pe` package. -The generated code is not really useful, but it could serve as a guide for the types that are expected by the API. -The output of `generated.go` is copied to `types.go` \ No newline at end of file +Schemas files were taken from: +- https://github.com/decentralized-identity/presentation-exchange/tree/main/schemas +- https://github.com/decentralized-identity/claim-format-registry/tree/main/schemas \ No newline at end of file diff --git a/vcr/pe/schema/gen/README.md b/vcr/pe/schema/gen/README.md new file mode 100644 index 0000000000..fac22a7041 --- /dev/null +++ b/vcr/pe/schema/gen/README.md @@ -0,0 +1,11 @@ +# generate structs from JSON schema + +From this directory, run: + +```shell +go run . +``` + +It'll generate `generated.go` within the `pe` package. +The generated code is not really useful, but it could serve as a guide for the types that are expected by the API. +The output of `generated.go` is copied to `types.go` \ No newline at end of file diff --git a/vcr/pe/schema/go.mod b/vcr/pe/schema/gen/go.mod similarity index 60% rename from vcr/pe/schema/go.mod rename to vcr/pe/schema/gen/go.mod index db1192449c..b872a8ae8e 100644 --- a/vcr/pe/schema/go.mod +++ b/vcr/pe/schema/gen/go.mod @@ -1,4 +1,4 @@ -module github.com/nuts-foundation/nuts-node/vcr/pe/schema +module github.com/nuts-foundation/nuts-node/vcr/pe/gen/schema go 1.21 diff --git a/vcr/pe/schema/go.sum b/vcr/pe/schema/gen/go.sum similarity index 100% rename from vcr/pe/schema/go.sum rename to vcr/pe/schema/gen/go.sum diff --git a/vcr/pe/schema/main.go b/vcr/pe/schema/gen/main.go similarity index 96% rename from vcr/pe/schema/main.go rename to vcr/pe/schema/gen/main.go index 2eb767ecd4..7b3e4f0faf 100644 --- a/vcr/pe/schema/main.go +++ b/vcr/pe/schema/gen/main.go @@ -44,7 +44,7 @@ func main() { os.Exit(1) } - f, err := os.OpenFile("../generated.go", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) + f, err := os.OpenFile("generated.go", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644) if err != nil { fmt.Fprintln(os.Stderr, "Error opening output file: ", err) diff --git a/vcr/pe/schema/v2/input-descriptor.json b/vcr/pe/schema/v2/input-descriptor.json new file mode 100644 index 0000000000..2112e900f8 --- /dev/null +++ b/vcr/pe/schema/v2/input-descriptor.json @@ -0,0 +1,220 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Input Descriptor", + "definitions": { + "status_directive": { + "type": "object", + "additionalProperties": false, + "properties": { + "directive": { + "type": "string", + "enum": [ + "required", + "allowed", + "disallowed" + ] + }, + "type": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + } + }, + "field": { + "type": "object", + "oneOf": [ + { + "properties": { + "id": { + "type": "string" + }, + "optional": { + "type": "boolean" + }, + "path": { + "type": "array", + "items": { + "type": "string" + } + }, + "purpose": { + "type": "string" + }, + "intent_to_retain": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "filter": { + "$ref": "http://json-schema.org/draft-07/schema#" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + { + "properties": { + "id": { + "type": "string" + }, + "optional": { + "type": "boolean" + }, + "path": { + "type": "array", + "items": { + "type": "string" + } + }, + "purpose": { + "type": "string" + }, + "intent_to_retain": { + "type": "boolean" + }, + "filter": { + "$ref": "http://json-schema.org/draft-07/schema#" + }, + "name": { + "type": "string" + }, + "predicate": { + "type": "string", + "enum": [ + "required", + "preferred" + ] + } + }, + "required": [ + "path", + "filter", + "predicate" + ], + "additionalProperties": false + } + ] + } + }, + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "purpose": { + "type": "string" + }, + "group": { + "type": "array", + "items": { + "type": "string" + } + }, + "constraints": { + "type": "object", + "additionalProperties": false, + "properties": { + "limit_disclosure": { + "type": "string", + "enum": [ + "required", + "preferred" + ] + }, + "statuses": { + "type": "object", + "additionalProperties": false, + "properties": { + "active": { + "$ref": "#/definitions/status_directive" + }, + "suspended": { + "$ref": "#/definitions/status_directive" + }, + "revoked": { + "$ref": "#/definitions/status_directive" + } + } + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/field" + } + }, + "subject_is_issuer": { + "type": "string", + "enum": [ + "required", + "preferred" + ] + }, + "is_holder": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "field_id": { + "type": "array", + "items": { + "type": "string" + } + }, + "directive": { + "type": "string", + "enum": [ + "required", + "preferred" + ] + } + }, + "required": [ + "field_id", + "directive" + ] + } + }, + "same_subject": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "field_id": { + "type": "array", + "items": { + "type": "string" + } + }, + "directive": { + "type": "string", + "enum": [ + "required", + "preferred" + ] + } + }, + "required": [ + "field_id", + "directive" + ] + } + } + } + } + }, + "required": [ + "id" + ] +} diff --git a/vcr/pe/schema/v2/json-schema-draft-07.json b/vcr/pe/schema/v2/json-schema-draft-07.json new file mode 100644 index 0000000000..fb92c7f756 --- /dev/null +++ b/vcr/pe/schema/v2/json-schema-draft-07.json @@ -0,0 +1,172 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "http://json-schema.org/draft-07/schema#", + "title": "Core schema meta-schema", + "definitions": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#" } + }, + "nonNegativeInteger": { + "type": "integer", + "minimum": 0 + }, + "nonNegativeIntegerDefault0": { + "allOf": [ + { "$ref": "#/definitions/nonNegativeInteger" }, + { "default": 0 } + ] + }, + "simpleTypes": { + "enum": [ + "array", + "boolean", + "integer", + "null", + "number", + "object", + "string" + ] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true, + "default": [] + } + }, + "type": ["object", "boolean"], + "properties": { + "$id": { + "type": "string", + "format": "uri-reference" + }, + "$schema": { + "type": "string", + "format": "uri" + }, + "$ref": { + "type": "string", + "format": "uri-reference" + }, + "$comment": { + "type": "string" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": true, + "readOnly": { + "type": "boolean", + "default": false + }, + "writeOnly": { + "type": "boolean", + "default": false + }, + "examples": { + "type": "array", + "items": true + }, + "multipleOf": { + "type": "number", + "exclusiveMinimum": 0 + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "number" + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "number" + }, + "maxLength": { "$ref": "#/definitions/nonNegativeInteger" }, + "minLength": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "additionalItems": { "$ref": "#" }, + "items": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/schemaArray" } + ], + "default": true + }, + "maxItems": { "$ref": "#/definitions/nonNegativeInteger" }, + "minItems": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "contains": { "$ref": "#" }, + "maxProperties": { "$ref": "#/definitions/nonNegativeInteger" }, + "minProperties": { "$ref": "#/definitions/nonNegativeIntegerDefault0" }, + "required": { "$ref": "#/definitions/stringArray" }, + "additionalProperties": { "$ref": "#" }, + "definitions": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "properties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "propertyNames": { "format": "regex" }, + "default": {} + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/stringArray" } + ] + } + }, + "propertyNames": { "$ref": "#" }, + "const": true, + "enum": { + "type": "array", + "items": true, + "minItems": 1, + "uniqueItems": true + }, + "type": { + "anyOf": [ + { "$ref": "#/definitions/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/definitions/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "format": { "type": "string" }, + "contentMediaType": { "type": "string" }, + "contentEncoding": { "type": "string" }, + "if": { "$ref": "#" }, + "then": { "$ref": "#" }, + "else": { "$ref": "#" }, + "allOf": { "$ref": "#/definitions/schemaArray" }, + "anyOf": { "$ref": "#/definitions/schemaArray" }, + "oneOf": { "$ref": "#/definitions/schemaArray" }, + "not": { "$ref": "#" } + }, + "default": true +} diff --git a/vcr/pe/schema/v2/presentation-definition-claim-format-designations.json b/vcr/pe/schema/v2/presentation-definition-claim-format-designations.json new file mode 100644 index 0000000000..d142274c92 --- /dev/null +++ b/vcr/pe/schema/v2/presentation-definition-claim-format-designations.json @@ -0,0 +1,30 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Presentation Definition Claim Format Designations", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^jwt$|^jwt_vc$|^jwt_vp$": { + "type": "object", + "additionalProperties": false, + "properties": { + "alg": { + "type": "array", + "minItems": 1, + "items": { "type": "string" } + } + } + }, + "^ldp_vc$|^ldp_vp$|^ldp$": { + "type": "object", + "additionalProperties": false, + "properties": { + "proof_type": { + "type": "array", + "minItems": 1, + "items": { "type": "string" } + } + } + } + } +} \ No newline at end of file diff --git a/vcr/pe/schema/v2/presentation-definition-envelope.json b/vcr/pe/schema/v2/presentation-definition-envelope.json new file mode 100644 index 0000000000..872a1e3966 --- /dev/null +++ b/vcr/pe/schema/v2/presentation-definition-envelope.json @@ -0,0 +1,353 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Presentation Definition Envelope", + "definitions": { + "status_directive": { + "type": "object", + "additionalProperties": false, + "properties": { + "directive": { + "type": "string", + "enum": [ + "required", + "allowed", + "disallowed" + ] + }, + "type": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + } + }, + "field": { + "type": "object", + "oneOf": [ + { + "properties": { + "id": { + "type": "string" + }, + "optional": { + "type": "boolean" + }, + "path": { + "type": "array", + "items": { + "type": "string" + } + }, + "purpose": { + "type": "string" + }, + "name": { + "type": "string" + }, + "intent_to_retain": { + "type": "boolean" + }, + "filter": { + "$ref": "http://json-schema.org/draft-07/schema#" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + { + "properties": { + "id": { + "type": "string" + }, + "optional": { + "type": "boolean" + }, + "path": { + "type": "array", + "items": { + "type": "string" + } + }, + "purpose": { + "type": "string" + }, + "intent_to_retain": { + "type": "boolean" + }, + "filter": { + "$ref": "http://json-schema.org/draft-07/schema#" + }, + "name": { + "type": "string" + }, + "predicate": { + "type": "string", + "enum": [ + "required", + "preferred" + ] + } + }, + "required": [ + "path", + "filter", + "predicate" + ], + "additionalProperties": false + } + ] + }, + "input_descriptor": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "purpose": { + "type": "string" + }, + "format": { + "$ref": "http://identity.foundation/claim-format-registry/schemas/presentation-definition-claim-format-designations.json" + }, + "group": { + "type": "array", + "items": { + "type": "string" + } + }, + "constraints": { + "type": "object", + "additionalProperties": false, + "properties": { + "limit_disclosure": { + "type": "string", + "enum": [ + "required", + "preferred" + ] + }, + "statuses": { + "type": "object", + "additionalProperties": false, + "properties": { + "active": { + "$ref": "#/definitions/status_directive" + }, + "suspended": { + "$ref": "#/definitions/status_directive" + }, + "revoked": { + "$ref": "#/definitions/status_directive" + } + } + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/field" + } + }, + "subject_is_issuer": { + "type": "string", + "enum": [ + "required", + "preferred" + ] + }, + "is_holder": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "field_id": { + "type": "array", + "items": { + "type": "string" + } + }, + "directive": { + "type": "string", + "enum": [ + "required", + "preferred" + ] + } + }, + "required": [ + "field_id", + "directive" + ] + } + }, + "same_subject": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "field_id": { + "type": "array", + "items": { + "type": "string" + } + }, + "directive": { + "type": "string", + "enum": [ + "required", + "preferred" + ] + } + }, + "required": [ + "field_id", + "directive" + ] + } + } + } + } + }, + "required": [ + "id", + "constraints" + ] + }, + "submission_requirement": { + "type": "object", + "oneOf": [ + { + "properties": { + "name": { + "type": "string" + }, + "purpose": { + "type": "string" + }, + "rule": { + "type": "string", + "enum": [ + "all", + "pick" + ] + }, + "count": { + "type": "integer", + "minimum": 1 + }, + "min": { + "type": "integer", + "minimum": 0 + }, + "max": { + "type": "integer", + "minimum": 0 + }, + "from": { + "type": "string" + } + }, + "required": [ + "rule", + "from" + ], + "additionalProperties": false + }, + { + "properties": { + "name": { + "type": "string" + }, + "purpose": { + "type": "string" + }, + "rule": { + "type": "string", + "enum": [ + "all", + "pick" + ] + }, + "count": { + "type": "integer", + "minimum": 1 + }, + "min": { + "type": "integer", + "minimum": 0 + }, + "max": { + "type": "integer", + "minimum": 0 + }, + "from_nested": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/submission_requirement" + } + } + }, + "required": [ + "rule", + "from_nested" + ], + "additionalProperties": false + } + ] + }, + "presentation_definition": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "purpose": { + "type": "string" + }, + "format": { + "$ref": "http://identity.foundation/claim-format-registry/schemas/presentation-definition-claim-format-designations.json#" + }, + "frame": { + "type": "object", + "additionalProperties": true + }, + "submission_requirements": { + "type": "array", + "items": { + "$ref": "#/definitions/submission_requirement" + } + }, + "input_descriptors": { + "type": "array", + "items": { + "$ref": "#/definitions/input_descriptor" + } + } + }, + "required": [ + "id", + "input_descriptors" + ], + "additionalProperties": false + } + }, + "type": "object", + "properties": { + "presentation_definition": { + "$ref": "#/definitions/presentation_definition" + } + } +} diff --git a/vcr/pe/schema/v2/presentation-definition.json b/vcr/pe/schema/v2/presentation-definition.json new file mode 100644 index 0000000000..7ba8262b5c --- /dev/null +++ b/vcr/pe/schema/v2/presentation-definition.json @@ -0,0 +1,345 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Presentation Definition", + "definitions": { + "status_directive": { + "type": "object", + "additionalProperties": false, + "properties": { + "directive": { + "type": "string", + "enum": [ + "required", + "allowed", + "disallowed" + ] + }, + "type": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + } + }, + "field": { + "type": "object", + "oneOf": [ + { + "properties": { + "id": { + "type": "string" + }, + "optional": { + "type": "boolean" + }, + "path": { + "type": "array", + "items": { + "type": "string" + } + }, + "purpose": { + "type": "string" + }, + "name": { + "type": "string" + }, + "intent_to_retain": { + "type": "boolean" + }, + "filter": { + "$ref": "http://json-schema.org/draft-07/schema#" + } + }, + "required": [ + "path" + ], + "additionalProperties": false + }, + { + "properties": { + "id": { + "type": "string" + }, + "optional": { + "type": "boolean" + }, + "path": { + "type": "array", + "items": { + "type": "string" + } + }, + "purpose": { + "type": "string" + }, + "intent_to_retain": { + "type": "boolean" + }, + "filter": { + "$ref": "http://json-schema.org/draft-07/schema#" + }, + "name": { + "type": "string" + }, + "predicate": { + "type": "string", + "enum": [ + "required", + "preferred" + ] + } + }, + "required": [ + "path", + "filter", + "predicate" + ], + "additionalProperties": false + } + ] + }, + "input_descriptor": { + "type": "object", + "additionalProperties": false, + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "purpose": { + "type": "string" + }, + "format": { + "$ref": "http://identity.foundation/claim-format-registry/schemas/presentation-definition-claim-format-designations.json" + }, + "group": { + "type": "array", + "items": { + "type": "string" + } + }, + "constraints": { + "type": "object", + "additionalProperties": false, + "properties": { + "limit_disclosure": { + "type": "string", + "enum": [ + "required", + "preferred" + ] + }, + "statuses": { + "type": "object", + "additionalProperties": false, + "properties": { + "active": { + "$ref": "#/definitions/status_directive" + }, + "suspended": { + "$ref": "#/definitions/status_directive" + }, + "revoked": { + "$ref": "#/definitions/status_directive" + } + } + }, + "fields": { + "type": "array", + "items": { + "$ref": "#/definitions/field" + } + }, + "subject_is_issuer": { + "type": "string", + "enum": [ + "required", + "preferred" + ] + }, + "is_holder": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "field_id": { + "type": "array", + "items": { + "type": "string" + } + }, + "directive": { + "type": "string", + "enum": [ + "required", + "preferred" + ] + } + }, + "required": [ + "field_id", + "directive" + ] + } + }, + "same_subject": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "field_id": { + "type": "array", + "items": { + "type": "string" + } + }, + "directive": { + "type": "string", + "enum": [ + "required", + "preferred" + ] + } + }, + "required": [ + "field_id", + "directive" + ] + } + } + } + } + }, + "required": [ + "id", + "constraints" + ] + }, + "submission_requirement": { + "type": "object", + "oneOf": [ + { + "properties": { + "name": { + "type": "string" + }, + "purpose": { + "type": "string" + }, + "rule": { + "type": "string", + "enum": [ + "all", + "pick" + ] + }, + "count": { + "type": "integer", + "minimum": 1 + }, + "min": { + "type": "integer", + "minimum": 0 + }, + "max": { + "type": "integer", + "minimum": 0 + }, + "from": { + "type": "string" + } + }, + "required": [ + "rule", + "from" + ], + "additionalProperties": false + }, + { + "properties": { + "name": { + "type": "string" + }, + "purpose": { + "type": "string" + }, + "rule": { + "type": "string", + "enum": [ + "all", + "pick" + ] + }, + "count": { + "type": "integer", + "minimum": 1 + }, + "min": { + "type": "integer", + "minimum": 0 + }, + "max": { + "type": "integer", + "minimum": 0 + }, + "from_nested": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/submission_requirement" + } + } + }, + "required": [ + "rule", + "from_nested" + ], + "additionalProperties": false + } + ] + } + }, + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "purpose": { + "type": "string" + }, + "format": { + "$ref": "http://identity.foundation/claim-format-registry/schemas/presentation-definition-claim-format-designations.json" + }, + "frame": { + "type": "object", + "additionalProperties": true + }, + "submission_requirements": { + "type": "array", + "items": { + "$ref": "#/definitions/submission_requirement" + } + }, + "input_descriptors": { + "type": "array", + "items": { + "$ref": "#/definitions/input_descriptor" + } + } + }, + "required": [ + "id", + "input_descriptors" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/vcr/pe/schema/v2/presentation-submission-claim-format-designations.json b/vcr/pe/schema/v2/presentation-submission-claim-format-designations.json new file mode 100644 index 0000000000..929f5360fc --- /dev/null +++ b/vcr/pe/schema/v2/presentation-submission-claim-format-designations.json @@ -0,0 +1,11 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Presentation Submission Claim Format Designations", + "type": "object", + "definitions": { + "format": { + "type": "string", + "enum": ["jwt", "jwt_vc", "jwt_vp", "ldp", "ldp_vc", "ldp_vp"] + } + } +} \ No newline at end of file diff --git a/vcr/pe/schema/v2/presentation-submission.json b/vcr/pe/schema/v2/presentation-submission.json new file mode 100644 index 0000000000..a97275528f --- /dev/null +++ b/vcr/pe/schema/v2/presentation-submission.json @@ -0,0 +1,40 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Presentation Submission", + "type": "object", + "properties": { + "presentation_submission": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "definition_id": { "type": "string" }, + "descriptor_map": { + "type": "array", + "items": { "$ref": "#/definitions/descriptor" } + } + }, + "required": ["id", "definition_id", "descriptor_map"], + "additionalProperties": false + } + }, + "definitions": { + "descriptor": { + "type": "object", + "properties": { + "id": { "type": "string" }, + "path": { "type": "string" }, + "path_nested": { + "type": "object", + "$ref": "#/definitions/descriptor" + }, + "format": { + "$ref": "http://identity.foundation/claim-format-registry/schemas/presentation-submission-claim-format-designations.json#/definitions/format" + } + }, + "required": ["id", "path", "format"], + "additionalProperties": false + } + }, + "required": ["presentation_submission"], + "additionalProperties": false +} \ No newline at end of file diff --git a/vcr/pe/schema/v2/schema.go b/vcr/pe/schema/v2/schema.go new file mode 100644 index 0000000000..44de55fdd8 --- /dev/null +++ b/vcr/pe/schema/v2/schema.go @@ -0,0 +1,104 @@ +/* + * 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 v2 implements v2.0.0 of the Presentation Exchange specification +package v2 + +import ( + "bytes" + "embed" + _ "embed" + "fmt" + "github.com/santhosh-tekuri/jsonschema" + "github.com/santhosh-tekuri/jsonschema/loader" + "io" + "io/fs" + "strings" +) + +const ( + inputDescriptor = "http://identity.foundation/presentation-exchange/schemas/input-descriptor.json" + presentationDefinitionEnvelope = "http://identity.foundation/presentation-exchange/schemas/presentation-definition-envelope.json" + presentationDefinition = "http://identity.foundation/presentation-exchange/schemas/presentation-definition.json" + presentationSubmission = "http://identity.foundation/presentation-exchange/schemas/presentation-submission.json" + submissionRequirement = "http://identity.foundation/presentation-exchange/schemas/submission-requirement.json" + submissionRequirements = "http://identity.foundation/presentation-exchange/schemas/submission-requirements.json" + presentationSubmissionClaimFormatDesignations = "http://identity.foundation/claim-format-registry/schemas/presentation-submission-claim-format-designations.json" + presentationDefinitionClaimFormatDesignations = "http://identity.foundation/claim-format-registry/schemas/presentation-definition-claim-format-designations.json" +) + +//go:embed *.json +var schemaFiles embed.FS + +// PresentationDefinition is the JSON schema for a presentation definition. +var PresentationDefinition *jsonschema.Schema + +// PresentationSubmission is the JSON schema for a presentation submission. +var PresentationSubmission *jsonschema.Schema + +func init() { + // By default, it loads from filesystem, but that sounds unsafe. + // Since register our schemas, we don't need to allow loading resources. + loader.Load = func(url string) (io.ReadCloser, error) { + return nil, fmt.Errorf("refusing to load unknown schema: %s", url) + } + compiler := jsonschema.NewCompiler() + compiler.Draft = jsonschema.Draft7 + if err := loadSchemas(schemaFiles, compiler); err != nil { + panic(err) + } + PresentationDefinition = compiler.MustCompile(presentationDefinition) + PresentationSubmission = compiler.MustCompile(presentationSubmission) +} + +func loadSchemas(reader fs.ReadFileFS, compiler *jsonschema.Compiler) error { + var resources = map[string]string{ + "http://json-schema.org/draft-07/schema": "json-schema-draft-07.json", + } + schemaURLs := []string{ + inputDescriptor, + presentationDefinitionEnvelope, + presentationDefinition, + presentationSubmission, + submissionRequirement, + submissionRequirements, + presentationSubmissionClaimFormatDesignations, + presentationDefinitionClaimFormatDesignations, + } + for _, schemaURL := range schemaURLs { + // Last part of schema URL matches the embedded file's name + parts := strings.Split(schemaURL, "/") + fileName := parts[len(parts)-1] + resources[schemaURL] = fileName + } + for schemaURL, fileName := range resources { + data, err := reader.ReadFile(fileName) + if err != nil { + return fmt.Errorf("error reading schema file %s: %w", fileName, err) + } + if err := compiler.AddResource(schemaURL, bytes.NewReader(data)); err != nil { + return fmt.Errorf("error compiling schema %s: %w", schemaURL, err) + } + } + return nil +} + +// Validate validates the given data against the given schema. +func Validate(data []byte, schema *jsonschema.Schema) error { + return schema.Validate(bytes.NewReader(data)) +} diff --git a/vcr/pe/schema/v2/schema_test.go b/vcr/pe/schema/v2/schema_test.go new file mode 100644 index 0000000000..fa58d5939f --- /dev/null +++ b/vcr/pe/schema/v2/schema_test.go @@ -0,0 +1,40 @@ +/* + * 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 v2 + +import ( + "github.com/stretchr/testify/assert" + "testing" +) + +func TestSchemaLoading(t *testing.T) { + assert.NotNil(t, PresentationDefinition) +} + +func TestValidate(t *testing.T) { + t.Run("ok", func(t *testing.T) { + err := Validate([]byte(`{"id":"1", "input_descriptors": []}`), PresentationDefinition) + assert.NoError(t, err) + }) + t.Run("invalid", func(t *testing.T) { + err := Validate([]byte(`{}`), PresentationDefinition) + assert.ErrorContains(t, err, "doesn't validate") + assert.ErrorContains(t, err, "missing properties: \"id\"") + }) +} diff --git a/vcr/pe/schema/v2/submission-requirement.json b/vcr/pe/schema/v2/submission-requirement.json new file mode 100644 index 0000000000..9650087ef1 --- /dev/null +++ b/vcr/pe/schema/v2/submission-requirement.json @@ -0,0 +1,50 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Presentation Submission Requirement", + "definitions": { + "submission_requirement": { + "type": "object", + "oneOf": [ + { + "properties": { + "name": { "type": "string" }, + "purpose": { "type": "string" }, + "rule": { + "type": "string", + "enum": ["all", "pick"] + }, + "count": { "type": "integer", "minimum": 1 }, + "min": { "type": "integer", "minimum": 0 }, + "max": { "type": "integer", "minimum": 0 }, + "from": { "type": "string" } + }, + "required": ["rule", "from"], + "additionalProperties": false + }, + { + "properties": { + "name": { "type": "string" }, + "purpose": { "type": "string" }, + "rule": { + "type": "string", + "enum": ["all", "pick"] + }, + "count": { "type": "integer", "minimum": 1 }, + "min": { "type": "integer", "minimum": 0 }, + "max": { "type": "integer", "minimum": 0 }, + "from_nested": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/submission_requirement" + } + } + }, + "required": ["rule", "from_nested"], + "additionalProperties": false + } + ] + } + }, + "$ref": "#/definitions/submission_requirement" +} \ No newline at end of file diff --git a/vcr/pe/schema/v2/submission-requirements.json b/vcr/pe/schema/v2/submission-requirements.json new file mode 100644 index 0000000000..c04701eb81 --- /dev/null +++ b/vcr/pe/schema/v2/submission-requirements.json @@ -0,0 +1,102 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Submission Requirements", + "definitions": { + "submission_requirements": { + "type": "object", + "oneOf": [ + { + "properties": { + "name": { + "type": "string" + }, + "purpose": { + "type": "string" + }, + "rule": { + "type": "string", + "enum": [ + "all", + "pick" + ] + }, + "count": { + "type": "integer", + "minimum": 1 + }, + "min": { + "type": "integer", + "minimum": 0 + }, + "max": { + "type": "integer", + "minimum": 0 + }, + "from": { + "type": "string" + } + }, + "required": [ + "rule", + "from" + ], + "additionalProperties": false + }, + { + "properties": { + "name": { + "type": "string" + }, + "purpose": { + "type": "string" + }, + "rule": { + "type": "string", + "enum": [ + "all", + "pick" + ] + }, + "count": { + "type": "integer", + "minimum": 1 + }, + "min": { + "type": "integer", + "minimum": 0 + }, + "max": { + "type": "integer", + "minimum": 0 + }, + "from_nested": { + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/submission_requirements" + } + } + }, + "required": [ + "rule", + "from_nested" + ], + "additionalProperties": false + } + ] + } + }, + "type": "object", + "properties": { + "submission_requirements": { + "type": "array", + "items": { + "$ref": "#/definitions/submission_requirements" + } + } + }, + "required": [ + "submission_requirements" + ], + "additionalProperties": false +} \ No newline at end of file diff --git a/vcr/pe/store.go b/vcr/pe/store.go index 3c6858687e..11343cd326 100644 --- a/vcr/pe/store.go +++ b/vcr/pe/store.go @@ -20,6 +20,8 @@ package pe import ( "encoding/json" + "fmt" + v2 "github.com/nuts-foundation/nuts-node/vcr/pe/schema/v2" "io" "os" ) @@ -28,7 +30,7 @@ import ( // It loads a file with the mapping from oauth scope to presentation definition type DefinitionResolver struct { // mapping holds the oauth scope to presentation definition mapping - mapping map[string]PresentationDefinition + mapping map[string]validatingPresentationDefinition } // LoadFromFile loads the mapping from the given file @@ -45,8 +47,13 @@ func (s *DefinitionResolver) LoadFromFile(filename string) error { } // unmarshal the bytes into the mapping - s.mapping = make(map[string]PresentationDefinition) - return json.Unmarshal(bytes, &s.mapping) + result := make(map[string]validatingPresentationDefinition) + err = json.Unmarshal(bytes, &result) + if err != nil { + return fmt.Errorf("failed to unmarshal Presentation Exchange mapping file %s: %w", filename, err) + } + s.mapping = result + return nil } // ByScope returns the presentation definition for the given scope. @@ -56,5 +63,16 @@ func (s *DefinitionResolver) ByScope(scope string) *PresentationDefinition { if !ok { return nil } - return &mapping + result := PresentationDefinition(mapping) + return &result +} + +// validatingPresentationDefinition is an alias for PresentationDefinition that validates the JSON on unmarshal. +type validatingPresentationDefinition PresentationDefinition + +func (v *validatingPresentationDefinition) UnmarshalJSON(data []byte) error { + if err := v2.Validate(data, v2.PresentationDefinition); err != nil { + return err + } + return json.Unmarshal(data, (*PresentationDefinition)(v)) } diff --git a/vcr/pe/store_test.go b/vcr/pe/store_test.go index 97e77bdf3f..ebb9ae40cb 100644 --- a/vcr/pe/store_test.go +++ b/vcr/pe/store_test.go @@ -43,6 +43,14 @@ func TestStore_LoadFromFile(t *testing.T) { assert.Error(t, err) }) + + t.Run("returns an error if a presentation definition is invalid", func(t *testing.T) { + store := DefinitionResolver{} + + err := store.LoadFromFile("test/invalid_definition_mapping.json") + + assert.ErrorContains(t, err, "missing properties: \"input_descriptors\"") + }) } func TestStore_ByScope(t *testing.T) { diff --git a/vcr/pe/submission.go b/vcr/pe/submission.go new file mode 100644 index 0000000000..736c70c96c --- /dev/null +++ b/vcr/pe/submission.go @@ -0,0 +1,56 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package pe + +import ( + "encoding/json" + v2 "github.com/nuts-foundation/nuts-node/vcr/pe/schema/v2" +) + +// PresentationSubmission describes how the VCs in the VP match the input descriptors in the PD +type PresentationSubmission struct { + // Id is the id of the presentation submission, which is a UUID + Id string `json:"id"` + // DefinitionId is the id of the presentation definition that this submission is for + DefinitionId string `json:"definition_id"` + // DescriptorMap is a list of mappings from input descriptors to VCs + DescriptorMap []InputDescriptorMappingObject `json:"descriptor_map"` +} + +// InputDescriptorMappingObject +type InputDescriptorMappingObject struct { + Id string `json:"id"` + Path string `json:"path"` + Format string `json:"format"` +} + +// ParsePresentationSubmission validates the given JSON and parses it into a PresentationSubmission. +// It returns an error if the JSON is invalid or doesn't match the JSON schema for a PresentationSubmission. +func ParsePresentationSubmission(raw []byte) (*PresentationSubmission, error) { + enveloped := `{"presentation_submission":` + string(raw) + `}` + if err := v2.Validate([]byte(enveloped), v2.PresentationSubmission); err != nil { + return nil, err + } + var result PresentationSubmission + err := json.Unmarshal(raw, &result) + if err != nil { + return nil, err + } + return &result, nil +} diff --git a/vcr/pe/submission_test.go b/vcr/pe/submission_test.go new file mode 100644 index 0000000000..1cd9aefb21 --- /dev/null +++ b/vcr/pe/submission_test.go @@ -0,0 +1,37 @@ +/* + * 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/assert" + "github.com/stretchr/testify/require" + "testing" +) + +func TestParsePresentationSubmission(t *testing.T) { + t.Run("ok", func(t *testing.T) { + submission, err := ParsePresentationSubmission([]byte(`{"id": "1", "definition_id":"1", "descriptor_map": []}`)) + require.NoError(t, err) + assert.Equal(t, "1", submission.Id) + }) + t.Run("missing id", func(t *testing.T) { + _, err := ParsePresentationSubmission([]byte(`{"definition_id":"1", "descriptor_map": []}`)) + assert.ErrorContains(t, err, `missing properties: "id"`) + }) +} diff --git a/vcr/pe/test/definition_mapping.json b/vcr/pe/test/definition_mapping.json index 284a1253e7..b543faa577 100644 --- a/vcr/pe/test/definition_mapping.json +++ b/vcr/pe/test/definition_mapping.json @@ -1,13 +1,16 @@ { "eOverdracht-overdrachtsbericht": { - "ldp_vc": { - "proof_type": ["JsonWebSignature2020"] + "format": { + "ldp_vc": { + "proof_type": ["JsonWebSignature2020"] + } }, "id": "pd_any_care_organization", "name": "Care organization", "purpose": "Finding a care organization for authorizing access to medical metadata", "input_descriptors": [ { + "id": "id_nuts_care_organization_cred", "constraints": { "fields": [ { diff --git a/vcr/pe/test/invalid_definition_mapping.json b/vcr/pe/test/invalid_definition_mapping.json new file mode 100644 index 0000000000..cfceb8bed5 --- /dev/null +++ b/vcr/pe/test/invalid_definition_mapping.json @@ -0,0 +1,5 @@ +{ + "missing input_descriptors": { + "id": "pd_any_care_organization" + } +} diff --git a/vcr/pe/types.go b/vcr/pe/types.go index 9dea0b1a96..32f4d78a2f 100644 --- a/vcr/pe/types.go +++ b/vcr/pe/types.go @@ -22,23 +22,6 @@ package pe // PresentationDefinitionClaimFormatDesignations (replaces generated one) type PresentationDefinitionClaimFormatDesignations map[string]map[string][]string -// PresentationSubmission describes how the VCs in the VP match the input descriptors in the PD -type PresentationSubmission struct { - // Id is the id of the presentation submission, which is a UUID - Id string `json:"id"` - // DefinitionId is the id of the presentation definition that this submission is for - DefinitionId string `json:"definition_id"` - // DescriptorMap is a list of mappings from input descriptors to VCs - DescriptorMap []InputDescriptorMappingObject `json:"descriptor_map"` -} - -// InputDescriptorMappingObject -type InputDescriptorMappingObject struct { - Id string `json:"id"` - Path string `json:"path"` - Format string `json:"format"` -} - // Constraints type Constraints struct { Fields []Field `json:"fields,omitempty"` diff --git a/vcr/signature/json_web_signature_test.go b/vcr/signature/json_web_signature_test.go index d54fee5d17..c39d3c0e68 100644 --- a/vcr/signature/json_web_signature_test.go +++ b/vcr/signature/json_web_signature_test.go @@ -57,8 +57,7 @@ func TestJsonWebSignature2020_CanonicalizeDocument(t *testing.T) { }) t.Run("simple document with resolvable context", func(t *testing.T) { - contextLoader, err := jsonld.NewContextLoader(false, jsonld.DefaultContextConfig()) - assert.NoError(t, err) + contextLoader := jsonld.NewTestJSONLDManager(t).DocumentLoader() sig := JSONWebSignature2020{ContextLoader: contextLoader} doc := map[string]interface{}{ diff --git a/vcr/signature/proof/jsonld_test.go b/vcr/signature/proof/jsonld_test.go index 365169c445..a1a3a07c78 100644 --- a/vcr/signature/proof/jsonld_test.go +++ b/vcr/signature/proof/jsonld_test.go @@ -81,8 +81,7 @@ func TestLDProof_Verify(t *testing.T) { signedDocument := SignedDocument{} require.NoError(t, json.Unmarshal([]byte(vc_0), &signedDocument)) - contextLoader, err := jsonld.NewContextLoader(true, jsonld.ContextsConfig{}) - require.NoError(t, err) + contextLoader := jsonld.NewTestJSONLDManager(t).DocumentLoader() t.Run("ok - JSONWebSignature2020 test vector", func(t *testing.T) { ldProof := LDProof{} @@ -169,7 +168,7 @@ func TestLDProof_Sign(t *testing.T) { kid := "did:nuts:123#abc" testKey := crypto.NewTestKey(kid) - contextLoader, _ := jsonld.NewContextLoader(false, jsonld.DefaultContextConfig()) + contextLoader := jsonld.NewTestJSONLDManager(t).DocumentLoader() t.Run("sign and verify a document", func(t *testing.T) { now := time.Now() diff --git a/vcr/store.go b/vcr/store.go index 4c7ae0b292..e74c467711 100644 --- a/vcr/store.go +++ b/vcr/store.go @@ -44,7 +44,7 @@ func (c *vcr) StoreCredential(credential vc.VerifiableCredential, validAt *time. if credential.ID != nil { existingCredential, err := c.find(*credential.ID) if err == nil { - if reflect.DeepEqual(existingCredential, credential) { + if credentialsEqual(existingCredential, credential) { log.Logger(). WithField(core.LogFieldCredentialID, *credential.ID). Info("Credential already exists") @@ -64,6 +64,18 @@ func (c *vcr) StoreCredential(credential vc.VerifiableCredential, validAt *time. return c.writeCredential(credential) } +func credentialsEqual(a vc.VerifiableCredential, b vc.VerifiableCredential) bool { + // go-leia returns pretty-printed JSON documents, so the verifiable credentials have different `raw` properties. + // thus we need to unmarshal the verifiable credentials into maps and compare those. + aJSON, _ := json.Marshal(a) + bJSON, _ := json.Marshal(b) + aMap := map[string]interface{}{} + bMap := map[string]interface{}{} + _ = json.Unmarshal(aJSON, &aMap) + _ = json.Unmarshal(bJSON, &bMap) + return reflect.DeepEqual(aMap, bMap) +} + func (c *vcr) writeCredential(subject vc.VerifiableCredential) error { vcType := "VerifiableCredential" customTypes := credential.ExtractTypes(subject) diff --git a/vcr/test/formats_integration_test.go b/vcr/test/formats_integration_test.go new file mode 100644 index 0000000000..f1e726ad04 --- /dev/null +++ b/vcr/test/formats_integration_test.go @@ -0,0 +1,167 @@ +/* + * Copyright (C) 2023 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package test + +import ( + "context" + "encoding/json" + "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/audit" + "github.com/nuts-foundation/nuts-node/test/node" + "github.com/nuts-foundation/nuts-node/vcr" + v2 "github.com/nuts-foundation/nuts-node/vcr/api/vcr/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "net/http/httptest" + "strings" + "testing" +) + +// TestCredentialFormats tests issuing and verifying different VC. +func TestCredentialFormats(t *testing.T) { + ctx := audit.TestContext() + _, system := node.StartServer(t) + + issuerDID := registerDID(t, system) + subjectDID := registerDID(t, system) + publish := false + credentialRequestTemplate := v2.IssueVCRequest{ + Type: "NutsOrganizationCredential", + CredentialSubject: map[string]interface{}{ + "id": subjectDID.String(), + "organization": map[string]interface{}{ + "name": "Nuts Foundation", + "city": "Notendam", + }, + }, + Issuer: issuerDID.String(), + PublishToNetwork: &publish, + } + vcrAPI := v2.Wrapper{VCR: system.FindEngineByName("vcr").(vcr.VCR)} + + t.Run("VC in JSON-LD format", func(t *testing.T) { + // Issuance + credentialRequest := credentialRequestTemplate + credential := issueVC(t, vcrAPI, ctx, credentialRequest) + assert.True(t, strings.HasPrefix(credential.Raw(), "{"), "expected JSON-LD VC response") + // Verification + verifyVC(t, ctx, credential, vcrAPI) + }) + t.Run("VC in JWT format", func(t *testing.T) { + // Issuance + var format v2.IssueVCRequestFormat = "jwt_vc" + credentialRequest := credentialRequestTemplate + credentialRequest.Format = &format + credential := issueVC(t, vcrAPI, ctx, credentialRequest) + assert.True(t, strings.HasPrefix(credential.Raw(), `ey`), "expected JWT VC response") + // Verification + verifyVC(t, ctx, credential, vcrAPI) + }) + t.Run("VP in JSON-LD format, containing VC in JSON-LD format", func(t *testing.T) { + credential := issueVC(t, vcrAPI, ctx, credentialRequestTemplate) + // Issuance + presentation := createVP(t, ctx, credential, "", vcrAPI) // empty string = default format + assert.True(t, strings.HasPrefix(presentation.Raw(), "{"), "expected JSON-LD VP response") + assert.True(t, strings.HasPrefix(presentation.VerifiableCredential[0].Raw(), "{"), "expected JSON-LD VC in VP response") + // Verification + verifyVP(t, ctx, presentation, vcrAPI) + }) + t.Run("VP in JSON-LD format, containing VC in JWT format", func(t *testing.T) { + var format v2.IssueVCRequestFormat = "jwt_vc" + credentialRequest := credentialRequestTemplate + credentialRequest.Format = &format + credential := issueVC(t, vcrAPI, ctx, credentialRequest) + // Issuance + presentation := createVP(t, ctx, credential, "", vcrAPI) // empty string = default format + assert.True(t, strings.HasPrefix(presentation.Raw(), "{"), "expected JSON-LD VP response") + assert.True(t, strings.HasPrefix(presentation.VerifiableCredential[0].Raw(), "ey"), "expected JWT VC in VP response") + // Verification + verifyVP(t, ctx, presentation, vcrAPI) + }) + t.Run("VP in JWT format, containing VC in JWT format", func(t *testing.T) { + var format v2.IssueVCRequestFormat = "jwt_vc" + credentialRequest := credentialRequestTemplate + credentialRequest.Format = &format + credential := issueVC(t, vcrAPI, ctx, credentialRequest) + // Issuance + presentation := createVP(t, ctx, credential, vc.JWTPresentationProofFormat, vcrAPI) + assert.True(t, strings.HasPrefix(presentation.Raw(), "ey"), "expected JWT VP response") + assert.True(t, strings.HasPrefix(presentation.VerifiableCredential[0].Raw(), "ey"), "expected JWT VC in VP response") + // Verification + verifyVP(t, ctx, presentation, vcrAPI) + }) + t.Run("VP in JWT format, containing VC in JSON-LD format", func(t *testing.T) { + credential := issueVC(t, vcrAPI, ctx, credentialRequestTemplate) + // Issuance + presentation := createVP(t, ctx, credential, vc.JWTPresentationProofFormat, vcrAPI) + assert.True(t, strings.HasPrefix(presentation.Raw(), "ey"), "expected JWT VP response") + assert.True(t, strings.HasPrefix(presentation.VerifiableCredential[0].Raw(), "{"), "expected JWT VC in VP response") + // Verification + verifyVP(t, ctx, presentation, vcrAPI) + }) +} + +func createVP(t *testing.T, ctx context.Context, credential v2.VerifiableCredential, format string, vcrAPI v2.Wrapper) vc.VerifiablePresentation { + request := v2.CreateVPJSONRequestBody{ + VerifiableCredentials: []v2.VerifiableCredential{credential}, + } + if format != "" { + f := v2.CreateVPRequestFormat(format) + request.Format = &f + } + response, err := vcrAPI.CreateVP(ctx, v2.CreateVPRequestObject{Body: &request}) + require.NoError(t, err) + httpResponse := httptest.NewRecorder() + require.NoError(t, response.VisitCreateVPResponse(httpResponse)) + var result vc.VerifiablePresentation + err = json.Unmarshal(httpResponse.Body.Bytes(), &result) + require.NoError(t, err) + return result +} + +func issueVC(t *testing.T, vcrAPI v2.Wrapper, ctx context.Context, credentialRequest v2.IssueVCRequest) v2.VerifiableCredential { + response, err := vcrAPI.IssueVC(ctx, v2.IssueVCRequestObject{Body: &credentialRequest}) + require.NoError(t, err) + httpResponse := httptest.NewRecorder() + require.NoError(t, response.VisitIssueVCResponse(httpResponse)) + var credential v2.VerifiableCredential + err = json.Unmarshal(httpResponse.Body.Bytes(), &credential) + require.NoError(t, err) + return credential +} + +func verifyVC(t *testing.T, ctx context.Context, credential vc.VerifiableCredential, vcrAPI v2.Wrapper) { + verifyResponse, err := vcrAPI.VerifyVC(ctx, v2.VerifyVCRequestObject{Body: &v2.VerifyVCJSONRequestBody{ + VerifiableCredential: credential, + }}) + require.NoError(t, err) + assert.True(t, verifyResponse.(v2.VerifyVC200JSONResponse).Validity) + if !assert.Nil(t, verifyResponse.(v2.VerifyVC200JSONResponse).Message) { + t.Log(*(verifyResponse.(v2.VerifyVC200JSONResponse).Message)) + } +} + +func verifyVP(t *testing.T, ctx context.Context, presentation vc.VerifiablePresentation, vcrAPI v2.Wrapper) { + verifyResponse, err := vcrAPI.VerifyVP(ctx, v2.VerifyVPRequestObject{Body: &v2.VerifyVPJSONRequestBody{ + VerifiablePresentation: presentation, + }}) + require.NoError(t, err) + assert.True(t, verifyResponse.(v2.VerifyVP200JSONResponse).Validity) + assert.Nil(t, verifyResponse.(v2.VerifyVP200JSONResponse).Message) +} diff --git a/vcr/test/openid4vci_integration_test.go b/vcr/test/openid4vci_integration_test.go index c1e6d76116..fd9a0f943c 100644 --- a/vcr/test/openid4vci_integration_test.go +++ b/vcr/test/openid4vci_integration_test.go @@ -24,6 +24,7 @@ import ( "github.com/nuts-foundation/nuts-node/core" httpModule "github.com/nuts-foundation/nuts-node/http" "github.com/nuts-foundation/nuts-node/network/log" + "github.com/nuts-foundation/nuts-node/vcr/issuer" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vdr" "github.com/nuts-foundation/nuts-node/vdr/didnuts" @@ -69,7 +70,10 @@ func TestOpenID4VCIHappyFlow(t *testing.T) { "id": holderDID.URI().String(), "purposeOfUse": "test", }) - issuedVC, err := vcrService.Issuer().Issue(ctx, credential, true, false) + issuedVC, err := vcrService.Issuer().Issue(ctx, credential, issuer.CredentialOptions{ + Publish: true, + Public: false, + }) require.NoError(t, err) require.NotNil(t, issuedVC) @@ -124,7 +128,10 @@ func TestOpenID4VCIConnectionReuse(t *testing.T) { wg.Add(1) go func() { defer wg.Done() - _, err := vcrService.Issuer().Issue(ctx, credential, true, false) + _, err := vcrService.Issuer().Issue(ctx, credential, issuer.CredentialOptions{ + Publish: true, + Public: false, + }) if err != nil { errChan <- err return @@ -200,7 +207,10 @@ func TestOpenID4VCIDisabled(t *testing.T) { }) vcrService := system.FindEngineByName("vcr").(vcr.VCR) - _, err := vcrService.Issuer().Issue(audit.TestContext(), credential, true, false) + _, err := vcrService.Issuer().Issue(audit.TestContext(), credential, issuer.CredentialOptions{ + Publish: true, + Public: false, + }) assert.ErrorContains(t, err, "unable to publish the issued credential") }) diff --git a/vcr/vcr.go b/vcr/vcr.go index 81fe82d4b5..ea44364c24 100644 --- a/vcr/vcr.go +++ b/vcr/vcr.go @@ -99,7 +99,7 @@ type vcr struct { jsonldManager jsonld.JSONLD eventManager events.Event storageClient storage.Engine - openidIsssuerStore issuer.OpenIDStore + openidSessionStore storage.SessionDatabase localWalletResolver openid4vci.IdentifierResolver issuerHttpClient core.HTTPRequestDoer walletHttpClient core.HTTPRequestDoer @@ -112,7 +112,7 @@ func (c *vcr) GetOpenIDIssuer(ctx context.Context, id did.DID) (issuer.OpenIDHan if err != nil { return nil, err } - return issuer.NewOpenIDHandler(id, identifier, c.config.OpenID4VCI.DefinitionsDIR, c.issuerHttpClient, c.keyResolver, c.openidIsssuerStore) + return issuer.NewOpenIDHandler(id, identifier, c.config.OpenID4VCI.DefinitionsDIR, c.issuerHttpClient, c.keyResolver, c.openidSessionStore) } func (c *vcr) GetOpenIDHolder(ctx context.Context, id did.DID) (holder.OpenIDHandler, error) { @@ -269,7 +269,7 @@ func (c *vcr) Configure(config core.ServerConfig) error { Timeout: c.config.OpenID4VCI.Timeout, Transport: walletTransport, }) - c.openidIsssuerStore = issuer.NewOpenIDMemoryStore() + c.openidSessionStore = c.storageClient.GetSessionDatabase() } c.issuer = issuer.NewIssuer(c.issuerStore, c, networkPublisher, openidHandlerFn, didResolver, c.keyStore, c.jsonldManager, c.trustConfig) c.verifier = verifier.NewVerifier(c.verifierStore, didResolver, c.keyResolver, c.jsonldManager, c.trustConfig) @@ -329,9 +329,6 @@ func (c *vcr) Start() error { } func (c *vcr) Shutdown() error { - if c.openidIsssuerStore != nil { - c.openidIsssuerStore.Close() - } err := c.issuerStore.Close() if err != nil { log.Logger(). diff --git a/vcr/verifier/verifier.go b/vcr/verifier/verifier.go index 3cb4ec938b..c76887c7ad 100644 --- a/vcr/verifier/verifier.go +++ b/vcr/verifier/verifier.go @@ -19,9 +19,13 @@ package verifier import ( + crypt "crypto" "encoding/json" "errors" "fmt" + "github.com/lestrrat-go/jwx/jwt" + "github.com/nuts-foundation/nuts-node/crypto" + "github.com/nuts-foundation/nuts-node/vcr/issuer" "github.com/nuts-foundation/nuts-node/vdr/resolver" "strings" "time" @@ -43,6 +47,8 @@ const ( maxSkew = 5 * time.Second ) +var errVerificationMethodNotOfIssuer = errors.New("verification method is not of issuer") + // verifier implements the Verifier interface. // It implements the generic methods for verifying verifiable credentials and verifiable presentations. // It does not know anything about the semantics of a credential. It should support a wide range of types. @@ -111,6 +117,17 @@ func (v *verifier) Validate(credentialToVerify vc.VerifiableCredential, at *time return err } + switch credentialToVerify.Format() { + case issuer.JSONLDCredentialFormat: + return v.validateJSONLDCredential(credentialToVerify, at) + case issuer.JWTCredentialFormat: + return v.validateJWTCredential(credentialToVerify, at) + default: + return errors.New("unsupported credential proof format") + } +} + +func (v *verifier) validateJSONLDCredential(credentialToVerify vc.VerifiableCredential, at *time.Time) error { signedDocument, err := proof.NewSignedDocument(credentialToVerify) if err != nil { return fmt.Errorf("unable to build signed document from verifiable credential: %w", err) @@ -124,7 +141,7 @@ func (v *verifier) Validate(credentialToVerify vc.VerifiableCredential, at *time verificationMethod := ldProof.VerificationMethod.String() verificationMethodIssuer := strings.Split(verificationMethod, "#")[0] if verificationMethodIssuer == "" || verificationMethodIssuer != credentialToVerify.Issuer.String() { - return errors.New("verification method is not of issuer") + return errVerificationMethodNotOfIssuer } // find key @@ -140,6 +157,38 @@ func (v *verifier) Validate(credentialToVerify vc.VerifiableCredential, at *time return ldProof.Verify(signedDocument.DocumentWithoutProof(), signature.JSONWebSignature2020{ContextLoader: v.jsonldManager.DocumentLoader()}, pk) } +func (v *verifier) validateJWTCredential(credential vc.VerifiableCredential, at *time.Time) error { + var keyID string + _, err := crypto.ParseJWT(credential.Raw(), func(kid string) (crypt.PublicKey, error) { + keyID = kid + return v.resolveSigningKey(kid, credential.Issuer.String(), at) + }, jwt.WithClock(jwt.ClockFunc(func() time.Time { + if at == nil { + return time.Now() + } + return *at + }))) + if err != nil { + return fmt.Errorf("unable to validate JWT credential: %w", err) + } + if keyID != "" && strings.Split(keyID, "#")[0] != credential.Issuer.String() { + return errVerificationMethodNotOfIssuer + } + return nil +} + +func (v *verifier) resolveSigningKey(kid string, issuer string, at *time.Time) (crypt.PublicKey, error) { + // Compatibility: VC data model v1 puts key discovery out of scope and does not require the `kid` header. + // When `kid` isn't present use the JWT issuer as `kid`, then it is at least compatible with DID methods that contain a single verification method (did:jwk). + if kid == "" { + kid = issuer + } + if strings.HasPrefix(kid, "did:jwk:") && !strings.Contains(kid, "#") { + kid += "#0" + } + return v.keyResolver.ResolveKeyByID(kid, at, resolver.NutsSigningKeyType) +} + // Verify implements the verify interface. // It currently checks if the credential has the required fields and values, if it is valid at the given time and optional the signature. func (v verifier) Verify(credentialToVerify vc.VerifiableCredential, allowUntrusted bool, checkSignature bool, validAt *time.Time) error { @@ -236,7 +285,7 @@ func (v *verifier) RegisterRevocation(revocation credential.Revocation) error { vm := revocation.Proof.VerificationMethod.String() vmIssuer := strings.Split(vm, "#")[0] if vmIssuer != revocation.Issuer.String() { - return errors.New("verificationMethod should owned by the issuer") + return errVerificationMethodNotOfIssuer } pk, err := v.keyResolver.ResolveKeyByID(revocation.Proof.VerificationMethod.String(), &revocation.Date, resolver.NutsSigningKeyType) @@ -264,48 +313,91 @@ func (v verifier) VerifyVP(vp vc.VerifiablePresentation, verifyVCs bool, allowUn } // doVerifyVP delegates VC verification to the supplied Verifier, to aid unit testing. -func (v verifier) doVerifyVP(vcVerifier Verifier, vp vc.VerifiablePresentation, verifyVCs bool, allowUntrustedVCs bool, validAt *time.Time) ([]vc.VerifiableCredential, error) { +func (v verifier) doVerifyVP(vcVerifier Verifier, presentation vc.VerifiablePresentation, verifyVCs bool, allowUntrustedVCs bool, validAt *time.Time) ([]vc.VerifiableCredential, error) { + var err error + switch presentation.Format() { + case issuer.JSONLDPresentationFormat: + err = v.validateJSONLDPresentation(presentation, validAt) + case issuer.JWTPresentationFormat: + err = v.validateJWTPresentation(presentation, validAt) + default: + err = errors.New("unsupported presentation proof format") + } + if err != nil { + return nil, err + } + + if verifyVCs { + for _, current := range presentation.VerifiableCredential { + err := vcVerifier.Verify(current, allowUntrustedVCs, true, validAt) + if err != nil { + return nil, newVerificationError("invalid VC (id=%s): %w", current.ID, err) + } + } + } + + return presentation.VerifiableCredential, nil +} + +func (v *verifier) validateJSONLDPresentation(presentation vc.VerifiablePresentation, validAt *time.Time) error { // Multiple proofs might be supported in the future, when there's an actual use case. - if len(vp.Proof) != 1 { - return nil, newVerificationError("exactly 1 proof is expected") + if len(presentation.Proof) != 1 { + return newVerificationError("exactly 1 proof is expected") } // Make sure the proofs are LD-proofs var ldProofs []proof.LDProof - err := vp.UnmarshalProofValue(&ldProofs) + err := presentation.UnmarshalProofValue(&ldProofs) if err != nil { - return nil, newVerificationError("unsupported proof type: %w", err) + return newVerificationError("unsupported proof type: %w", err) } ldProof := ldProofs[0] // Validate signing time if !v.validateAtTime(ldProof.Created, ldProof.Expires, validAt) { - return nil, toVerificationError(types.ErrPresentationNotValidAtTime) + return toVerificationError(types.ErrPresentationNotValidAtTime) } // Validate signature signingKey, err := v.keyResolver.ResolveKeyByID(ldProof.VerificationMethod.String(), validAt, resolver.NutsSigningKeyType) if err != nil { - return nil, fmt.Errorf("unable to resolve valid signing key: %w", err) + return fmt.Errorf("unable to resolve valid signing key: %w", err) } - signedDocument, err := proof.NewSignedDocument(vp) + signedDocument, err := proof.NewSignedDocument(presentation) if err != nil { - return nil, newVerificationError("invalid LD-JSON document: %w", err) + return newVerificationError("invalid LD-JSON document: %w", err) } err = ldProof.Verify(signedDocument.DocumentWithoutProof(), signature.JSONWebSignature2020{ContextLoader: v.jsonldManager.DocumentLoader()}, signingKey) if err != nil { - return nil, newVerificationError("invalid signature: %w", err) + return newVerificationError("invalid signature: %w", err) } + return nil +} - if verifyVCs { - for _, current := range vp.VerifiableCredential { - err := vcVerifier.Verify(current, allowUntrustedVCs, true, validAt) - if err != nil { - return nil, newVerificationError("invalid VC (id=%s): %w", current.ID, err) - } +func (v *verifier) validateJWTPresentation(presentation vc.VerifiablePresentation, at *time.Time) error { + var keyID string + if len(presentation.VerifiableCredential) != 1 { + return errors.New("exactly 1 credential in JWT VP is expected") + } + subjectDID, err := presentation.VerifiableCredential[0].SubjectDID() + if err != nil { + return err + } + _, err = crypto.ParseJWT(presentation.Raw(), func(kid string) (crypt.PublicKey, error) { + keyID = kid + return v.resolveSigningKey(kid, subjectDID.String(), at) + }, jwt.WithClock(jwt.ClockFunc(func() time.Time { + if at == nil { + return time.Now() } + return *at + }))) + if err != nil { + return fmt.Errorf("unable to validate JWT credential: %w", err) } - - return vp.VerifiableCredential, nil + if keyID != "" && strings.Split(keyID, "#")[0] != subjectDID.String() { + return errVerificationMethodNotOfIssuer + } + return nil } func (v *verifier) validateType(credential vc.VerifiableCredential) error { diff --git a/vcr/verifier/verifier_test.go b/vcr/verifier/verifier_test.go index 841d5ee383..3adb1d33ba 100644 --- a/vcr/verifier/verifier_test.go +++ b/vcr/verifier/verifier_test.go @@ -19,10 +19,18 @@ package verifier import ( + "context" + crypt "crypto" "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "encoding/base64" "encoding/json" "errors" + "github.com/lestrrat-go/jwx/jwk" + "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/crypto/storage/spi" + "github.com/nuts-foundation/nuts-node/vdr/didjwk" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/stretchr/testify/require" "os" @@ -70,7 +78,7 @@ func Test_verifier_Validate(t *testing.T) { timeFunc = time.Now }() - t.Run("ok", func(t *testing.T) { + t.Run("JSON-LD", func(t *testing.T) { ctx := newMockContext(t) instance := ctx.verifier @@ -80,6 +88,85 @@ func Test_verifier_Validate(t *testing.T) { assert.NoError(t, err) }) + t.Run("JWT", func(t *testing.T) { + // Create did:jwk for issuer, and sign credential + keyStore := crypto.NewMemoryCryptoInstance() + key, err := keyStore.New(audit.TestContext(), func(key crypt.PublicKey) (string, error) { + keyAsJWK, _ := jwk.New(key) + keyJSON, _ := json.Marshal(keyAsJWK) + return "did:jwk:" + base64.RawStdEncoding.EncodeToString(keyJSON) + "#0", nil + }) + require.NoError(t, err) + + template := testCredential(t) + template.Issuer = did.MustParseDIDURL(key.KID()).WithoutURL().URI() + + cred, err := vc.CreateJWTVerifiableCredential(audit.TestContext(), template, func(ctx context.Context, claims map[string]interface{}, headers map[string]interface{}) (string, error) { + return keyStore.SignJWT(ctx, claims, headers, key) + }) + require.NoError(t, err) + + t.Run("with kid header", func(t *testing.T) { + ctx := newMockContext(t) + instance := ctx.verifier + + ctx.keyResolver.EXPECT().ResolveKeyByID(key.KID(), gomock.Any(), resolver.NutsSigningKeyType).Return(key.Public(), nil) + err = instance.Validate(*cred, nil) + + assert.NoError(t, err) + }) + t.Run("kid header does not match credential issuer", func(t *testing.T) { + ctx := newMockContext(t) + instance := ctx.verifier + + cred, err := vc.CreateJWTVerifiableCredential(audit.TestContext(), template, func(ctx context.Context, claims map[string]interface{}, headers map[string]interface{}) (string, error) { + return keyStore.SignJWT(ctx, claims, headers, key) + }) + require.NoError(t, err) + cred.Issuer = ssi.MustParseURI("did:example:test") + + ctx.keyResolver.EXPECT().ResolveKeyByID(key.KID(), gomock.Any(), resolver.NutsSigningKeyType).Return(key.Public(), nil) + err = instance.Validate(*cred, nil) + + assert.ErrorIs(t, err, errVerificationMethodNotOfIssuer) + }) + t.Run("signature invalid", func(t *testing.T) { + ctx := newMockContext(t) + instance := ctx.verifier + + realKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + + ctx.keyResolver.EXPECT().ResolveKeyByID(key.KID(), gomock.Any(), resolver.NutsSigningKeyType).Return(realKey.Public(), nil) + err = instance.Validate(*cred, nil) + + assert.EqualError(t, err, "unable to validate JWT credential: failed to verify jws signature: failed to verify message: failed to verify signature using ecdsa") + }) + t.Run("expired token", func(t *testing.T) { + // Credential taken from Sphereon Wallet, expires on Tue Oct 03 2023 + const credentialJSON = `eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTYzMDE3MDgsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJHdWVzdENyZWRlbnRpYWwiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiZmlyc3ROYW1lIjoiSGVsbG8iLCJsYXN0TmFtZSI6IlNwaGVyZW9uIiwiZW1haWwiOiJzcGhlcmVvbkBleGFtcGxlLmNvbSIsInR5cGUiOiJTcGhlcmVvbiBHdWVzdCIsImlkIjoiZGlkOmp3azpleUpoYkdjaU9pSkZVekkxTmtzaUxDSjFjMlVpT2lKemFXY2lMQ0pyZEhraU9pSkZReUlzSW1OeWRpSTZJbk5sWTNBeU5UWnJNU0lzSW5naU9pSmpNVmRZY3pkWE0yMTVjMlZWWms1Q2NYTjRaRkJYUWtsSGFFdGtORlI2TUV4U0xVWnFPRVpOV1dFd0lpd2llU0k2SWxkdGEwTllkVEYzZVhwYVowZE9OMVY0VG1Gd2NIRnVUMUZoVDJ0WE1rTm5UMU51VDI5NVRVbFVkV01pZlEifX0sIkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJHdWVzdENyZWRlbnRpYWwiXSwiZXhwaXJhdGlvbkRhdGUiOiIyMDIzLTEwLTAzVDAyOjU1OjA4LjEzM1oiLCJjcmVkZW50aWFsU3ViamVjdCI6eyJmaXJzdE5hbWUiOiJIZWxsbyIsImxhc3ROYW1lIjoiU3BoZXJlb24iLCJlbWFpbCI6InNwaGVyZW9uQGV4YW1wbGUuY29tIiwidHlwZSI6IlNwaGVyZW9uIEd1ZXN0IiwiaWQiOiJkaWQ6andrOmV5SmhiR2NpT2lKRlV6STFOa3NpTENKMWMyVWlPaUp6YVdjaUxDSnJkSGtpT2lKRlF5SXNJbU55ZGlJNkluTmxZM0F5TlRack1TSXNJbmdpT2lKak1WZFljemRYTTIxNWMyVlZaazVDY1hONFpGQlhRa2xIYUV0a05GUjZNRXhTTFVacU9FWk5XV0V3SWl3aWVTSTZJbGR0YTBOWWRURjNlWHBhWjBkT04xVjRUbUZ3Y0hGdVQxRmhUMnRYTWtOblQxTnVUMjk1VFVsVWRXTWlmUSJ9LCJpc3N1ZXIiOiJkaWQ6andrOmV5SmhiR2NpT2lKRlV6STFOaUlzSW5WelpTSTZJbk5wWnlJc0ltdDBlU0k2SWtWRElpd2lZM0oySWpvaVVDMHlOVFlpTENKNElqb2lWRWN5U0RKNE1tUlhXRTR6ZFVOeFduQnhSakY1YzBGUVVWWkVTa1ZPWDBndFEwMTBZbWRxWWkxT1p5SXNJbmtpT2lJNVRUaE9lR1F3VUU0eU1rMDViRkJFZUdSd1JIQnZWRXg2TVRWM1pubGFTbk0yV21oTFNWVktNek00SW4wIiwiaXNzdWFuY2VEYXRlIjoiMjAyMy0wOS0yOVQxMjozMTowOC4xMzNaIiwic3ViIjoiZGlkOmp3azpleUpoYkdjaU9pSkZVekkxTmtzaUxDSjFjMlVpT2lKemFXY2lMQ0pyZEhraU9pSkZReUlzSW1OeWRpSTZJbk5sWTNBeU5UWnJNU0lzSW5naU9pSmpNVmRZY3pkWE0yMTVjMlZWWms1Q2NYTjRaRkJYUWtsSGFFdGtORlI2TUV4U0xVWnFPRVpOV1dFd0lpd2llU0k2SWxkdGEwTllkVEYzZVhwYVowZE9OMVY0VG1Gd2NIRnVUMUZoVDJ0WE1rTm5UMU51VDI5NVRVbFVkV01pZlEiLCJuYmYiOjE2OTU5OTA2NjgsImlzcyI6ImRpZDpqd2s6ZXlKaGJHY2lPaUpGVXpJMU5pSXNJblZ6WlNJNkluTnBaeUlzSW10MGVTSTZJa1ZESWl3aVkzSjJJam9pVUMweU5UWWlMQ0o0SWpvaVZFY3lTREo0TW1SWFdFNHpkVU54V25CeFJqRjVjMEZRVVZaRVNrVk9YMGd0UTAxMFltZHFZaTFPWnlJc0lua2lPaUk1VFRoT2VHUXdVRTR5TWswNWJGQkVlR1J3UkhCdlZFeDZNVFYzWm5sYVNuTTJXbWhMU1ZWS016TTRJbjAifQ.wdhtLXE4jU1C-3YBBpP9-qE-yh1xOZ6lBLJ-0e5_Sa7fnrUHcAaU1n3kN2CeCyTVjtm1Uy3Tl6RzUOM6MjP3vQ` + cred, _ := vc.ParseVerifiableCredential(credentialJSON) + + keyResolver := resolver.DIDKeyResolver{ + Resolver: didjwk.NewResolver(), + } + err := (&verifier{keyResolver: keyResolver}).Validate(*cred, nil) + + assert.EqualError(t, err, "unable to validate JWT credential: exp not satisfied") + }) + t.Run("without kid header, derived from issuer", func(t *testing.T) { + // Credential taken from Sphereon Wallet + const credentialJSON = `eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTYzMDE3MDgsInZjIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJHdWVzdENyZWRlbnRpYWwiXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiZmlyc3ROYW1lIjoiSGVsbG8iLCJsYXN0TmFtZSI6IlNwaGVyZW9uIiwiZW1haWwiOiJzcGhlcmVvbkBleGFtcGxlLmNvbSIsInR5cGUiOiJTcGhlcmVvbiBHdWVzdCIsImlkIjoiZGlkOmp3azpleUpoYkdjaU9pSkZVekkxTmtzaUxDSjFjMlVpT2lKemFXY2lMQ0pyZEhraU9pSkZReUlzSW1OeWRpSTZJbk5sWTNBeU5UWnJNU0lzSW5naU9pSmpNVmRZY3pkWE0yMTVjMlZWWms1Q2NYTjRaRkJYUWtsSGFFdGtORlI2TUV4U0xVWnFPRVpOV1dFd0lpd2llU0k2SWxkdGEwTllkVEYzZVhwYVowZE9OMVY0VG1Gd2NIRnVUMUZoVDJ0WE1rTm5UMU51VDI5NVRVbFVkV01pZlEifX0sIkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOlsiVmVyaWZpYWJsZUNyZWRlbnRpYWwiLCJHdWVzdENyZWRlbnRpYWwiXSwiZXhwaXJhdGlvbkRhdGUiOiIyMDIzLTEwLTAzVDAyOjU1OjA4LjEzM1oiLCJjcmVkZW50aWFsU3ViamVjdCI6eyJmaXJzdE5hbWUiOiJIZWxsbyIsImxhc3ROYW1lIjoiU3BoZXJlb24iLCJlbWFpbCI6InNwaGVyZW9uQGV4YW1wbGUuY29tIiwidHlwZSI6IlNwaGVyZW9uIEd1ZXN0IiwiaWQiOiJkaWQ6andrOmV5SmhiR2NpT2lKRlV6STFOa3NpTENKMWMyVWlPaUp6YVdjaUxDSnJkSGtpT2lKRlF5SXNJbU55ZGlJNkluTmxZM0F5TlRack1TSXNJbmdpT2lKak1WZFljemRYTTIxNWMyVlZaazVDY1hONFpGQlhRa2xIYUV0a05GUjZNRXhTTFVacU9FWk5XV0V3SWl3aWVTSTZJbGR0YTBOWWRURjNlWHBhWjBkT04xVjRUbUZ3Y0hGdVQxRmhUMnRYTWtOblQxTnVUMjk1VFVsVWRXTWlmUSJ9LCJpc3N1ZXIiOiJkaWQ6andrOmV5SmhiR2NpT2lKRlV6STFOaUlzSW5WelpTSTZJbk5wWnlJc0ltdDBlU0k2SWtWRElpd2lZM0oySWpvaVVDMHlOVFlpTENKNElqb2lWRWN5U0RKNE1tUlhXRTR6ZFVOeFduQnhSakY1YzBGUVVWWkVTa1ZPWDBndFEwMTBZbWRxWWkxT1p5SXNJbmtpT2lJNVRUaE9lR1F3VUU0eU1rMDViRkJFZUdSd1JIQnZWRXg2TVRWM1pubGFTbk0yV21oTFNWVktNek00SW4wIiwiaXNzdWFuY2VEYXRlIjoiMjAyMy0wOS0yOVQxMjozMTowOC4xMzNaIiwic3ViIjoiZGlkOmp3azpleUpoYkdjaU9pSkZVekkxTmtzaUxDSjFjMlVpT2lKemFXY2lMQ0pyZEhraU9pSkZReUlzSW1OeWRpSTZJbk5sWTNBeU5UWnJNU0lzSW5naU9pSmpNVmRZY3pkWE0yMTVjMlZWWms1Q2NYTjRaRkJYUWtsSGFFdGtORlI2TUV4U0xVWnFPRVpOV1dFd0lpd2llU0k2SWxkdGEwTllkVEYzZVhwYVowZE9OMVY0VG1Gd2NIRnVUMUZoVDJ0WE1rTm5UMU51VDI5NVRVbFVkV01pZlEiLCJuYmYiOjE2OTU5OTA2NjgsImlzcyI6ImRpZDpqd2s6ZXlKaGJHY2lPaUpGVXpJMU5pSXNJblZ6WlNJNkluTnBaeUlzSW10MGVTSTZJa1ZESWl3aVkzSjJJam9pVUMweU5UWWlMQ0o0SWpvaVZFY3lTREo0TW1SWFdFNHpkVU54V25CeFJqRjVjMEZRVVZaRVNrVk9YMGd0UTAxMFltZHFZaTFPWnlJc0lua2lPaUk1VFRoT2VHUXdVRTR5TWswNWJGQkVlR1J3UkhCdlZFeDZNVFYzWm5sYVNuTTJXbWhMU1ZWS016TTRJbjAifQ.wdhtLXE4jU1C-3YBBpP9-qE-yh1xOZ6lBLJ-0e5_Sa7fnrUHcAaU1n3kN2CeCyTVjtm1Uy3Tl6RzUOM6MjP3vQ` + cred, _ := vc.ParseVerifiableCredential(credentialJSON) + + keyResolver := resolver.DIDKeyResolver{ + Resolver: didjwk.NewResolver(), + } + validAt := time.Date(2023, 9, 30, 0, 0, 0, 0, time.UTC) + err := (&verifier{keyResolver: keyResolver}).Validate(*cred, &validAt) + + assert.NoError(t, err) + }) + }) t.Run("type", func(t *testing.T) { t.Run("incorrect number of types", func(t *testing.T) { @@ -114,7 +201,7 @@ func Test_verifier_Validate(t *testing.T) { err := instance.Validate(vc2, nil) assert.Error(t, err) - assert.EqualError(t, err, "verification method is not of issuer") + assert.ErrorIs(t, err, errVerificationMethodNotOfIssuer) }) t.Run("error - wrong hashed payload", func(t *testing.T) { @@ -438,7 +525,7 @@ func Test_verifier_CheckAndStoreRevocation(t *testing.T) { assert.NoError(t, json.Unmarshal(rawRevocation, &revocation)) revocation.Proof.VerificationMethod = ssi.MustParseURI("did:nuts:123#abc") err := sut.verifier.RegisterRevocation(revocation) - assert.EqualError(t, err, "verificationMethod should owned by the issuer") + assert.ErrorIs(t, err, errVerificationMethodNotOfIssuer) }) t.Run("it fails when the revoked credential and revocation-issuer are not from the same identity", func(t *testing.T) { @@ -484,7 +571,72 @@ func Test_verifier_CheckAndStoreRevocation(t *testing.T) { } func TestVerifier_VerifyVP(t *testing.T) { - rawVP := `{ + t.Run("JWT", func(t *testing.T) { + const keyID = "did:nuts:GvkzxsezHvEc8nGhgz6Xo3jbqkHwswLmWw3CYtCm7hAW#abc-method-1" + key, err := jwk.ParseKey([]byte(`{ + "crv": "P-256", + "d": "mvipTdytRXwTTY_6wJl5Cwj0YQ4-QdJK-fEC8DzL9_M", + "kty": "EC", + "x": "8WvKOR7ZpOSfNxT20Qig8DuVY7QAwx6Qe4NNejTN3po", + "y": "UYZoXK13bedMDHvsrGskxihDuWIXgGBdQfTvjyQlCDE" +}`)) + require.NoError(t, err) + var publicKey crypt.PublicKey + require.NoError(t, key.Raw(&publicKey)) + + const rawVP = `eyJhbGciOiJFUzI1NiIsImtpZCI6ImRpZDpudXRzOkd2a3p4c2V6SHZFYzhuR2hnejZYbzNqYnFrSHdzd0xtV3czQ1l0Q203aEFXI2FiYy1tZXRob2QtMSIsInR5cCI6IkpXVCJ9.eyJleHAiOjE2OTc2OTY3NDEsImlzcyI6ImRpZDpudXRzOkd2a3p4c2V6SHZFYzhuR2hnejZYbzNqYnFrSHdzd0xtV3czQ1l0Q203aEFXIiwibmJmIjoxNjk3NjEwMzQxLCJzdWIiOiJkaWQ6bnV0czpHdmt6eHNlekh2RWM4bkdoZ3o2WG8zamJxa0h3c3dMbVd3M0NZdENtN2hBVyIsInZwIjp7IkBjb250ZXh0IjpbImh0dHBzOi8vd3d3LnczLm9yZy8yMDE4L2NyZWRlbnRpYWxzL3YxIl0sInR5cGUiOiJWZXJpZmlhYmxlUHJlc2VudGF0aW9uIiwidmVyaWZpYWJsZUNyZWRlbnRpYWwiOnsiQGNvbnRleHQiOlsiaHR0cHM6Ly93d3cudzMub3JnLzIwMTgvY3JlZGVudGlhbHMvdjEiLCJodHRwczovL251dHMubmwvY3JlZGVudGlhbHMvdjEiLCJodHRwczovL3czYy1jY2cuZ2l0aHViLmlvL2xkcy1qd3MyMDIwL2NvbnRleHRzL2xkcy1qd3MyMDIwLXYxLmpzb24iXSwiY3JlZGVudGlhbFN1YmplY3QiOnsiY29tcGFueSI6eyJjaXR5IjoiSGVuZ2VsbyIsIm5hbWUiOiJEZSBiZXN0ZSB6b3JnIn0sImlkIjoiZGlkOm51dHM6R3ZrenhzZXpIdkVjOG5HaGd6NlhvM2picWtId3N3TG1XdzNDWXRDbTdoQVcifSwiaWQiOiJkaWQ6bnV0czo0dHpNYVdmcGl6VktlQThmc2NDM0pUZFdCYzNhc1VXV01qNWhVRkhkV1gzSCNmNDNiZWY0Zi0xYTc5LTQzNjQtOTJmMy0zZmM3NDNmYTlmMTkiLCJpc3N1YW5jZURhdGUiOiIyMDIxLTEyLTI0VDEzOjIxOjI5LjA4NzIwNSswMTowMCIsImlzc3VlciI6ImRpZDpudXRzOjR0ek1hV2ZwaXpWS2VBOGZzY0MzSlRkV0JjM2FzVVdXTWo1aFVGSGRXWDNIIiwicHJvb2YiOnsiY3JlYXRlZCI6IjIwMjEtMTItMjRUMTM6MjE6MjkuMDg3MjA1KzAxOjAwIiwiandzIjoiZXlKaGJHY2lPaUpGVXpJMU5pSXNJbUkyTkNJNlptRnNjMlVzSW1OeWFYUWlPbHNpWWpZMElsMTkuLmhQTTJHTGMxSzlkMkQ4U2J2ZTAwNHg5U3VtakxxYVhUaldoVWh2cVdSd3hmUldsd2ZwNWdIRFVZdVJvRWpoQ1hmTHQtX3Uta25DaFZtSzk4ME4zTEJ3IiwicHJvb2ZQdXJwb3NlIjoiTnV0c1NpZ25pbmdLZXlUeXBlIiwidHlwZSI6Ikpzb25XZWJTaWduYXR1cmUyMDIwIiwidmVyaWZpY2F0aW9uTWV0aG9kIjoiZGlkOm51dHM6R3ZrenhzZXpIdkVjOG5HaGd6NlhvM2picWtId3N3TG1XdzNDWXRDbTdoQVcjYWJjLW1ldGhvZC0xIn0sInR5cGUiOlsiQ29tcGFueUNyZWRlbnRpYWwiLCJWZXJpZmlhYmxlQ3JlZGVudGlhbCJdfX19.v3beJvGa3HeImU3VLvsrZjnHs0krKPaCdTEh-qHS7j26LIQYcMHhrLkIexrpPO5z0TKSDnKq5Jl10SWaJpLRIA` + + presentation, err := vc.ParseVerifiablePresentation(rawVP) + require.NoError(t, err) + + t.Run("ok", func(t *testing.T) { + ctx := newMockContext(t) + ctx.keyResolver.EXPECT().ResolveKeyByID(keyID, gomock.Any(), resolver.NutsSigningKeyType).Return(publicKey, nil) + + validAt := time.Date(2023, 10, 18, 12, 0, 0, 0, time.UTC) + vcs, err := ctx.verifier.VerifyVP(*presentation, false, false, &validAt) + + assert.NoError(t, err) + assert.Len(t, vcs, 1) + }) + t.Run("JWT expired", func(t *testing.T) { + ctx := newMockContext(t) + ctx.keyResolver.EXPECT().ResolveKeyByID(keyID, gomock.Any(), resolver.NutsSigningKeyType).Return(publicKey, nil) + + validAt := time.Date(2023, 10, 21, 12, 0, 0, 0, time.UTC) + vcs, err := ctx.verifier.VerifyVP(*presentation, false, false, &validAt) + + assert.EqualError(t, err, "unable to validate JWT credential: exp not satisfied") + assert.Empty(t, vcs) + }) + t.Run("VP signer != VC credentialSubject.id", func(t *testing.T) { + // This VP was produced by a Sphereon Wallet, using did:key. The signer of the VP is a did:key, + // but the holder of the contained credential is a did:jwt. So the presenter is not the holder. Weird? + const rawVP = `eyJraWQiOiJkaWQ6a2V5Ono2TWtzRXl4NmQ1cEIxZWtvYVZtYUdzaWJiY1lIRTlWeHg3VjEzUFNxUHd4WVJ6TCN6Nk1rc0V5eDZkNXBCMWVrb2FWbWFHc2liYmNZSEU5Vnh4N1YxM1BTcVB3eFlSekwiLCJhbGciOiJFZERTQSIsInR5cCI6IkpXVCJ9.eyJ2cCI6eyJAY29udGV4dCI6WyJodHRwczovL3d3dy53My5vcmcvMjAxOC9jcmVkZW50aWFscy92MSIsImh0dHBzOi8vaWRlbnRpdHkuZm91bmRhdGlvbi9wcmVzZW50YXRpb24tZXhjaGFuZ2Uvc3VibWlzc2lvbi92MSJdLCJ0eXBlIjpbIlZlcmlmaWFibGVQcmVzZW50YXRpb24iLCJQcmVzZW50YXRpb25TdWJtaXNzaW9uIl0sInZlcmlmaWFibGVDcmVkZW50aWFsIjpbImV5SmhiR2NpT2lKRlV6STFOaUlzSW5SNWNDSTZJa3BYVkNKOS5leUpsZUhBaU9qRTJPVFl6TURFM01EZ3NJblpqSWpwN0lrQmpiMjUwWlhoMElqcGJJbWgwZEhCek9pOHZkM2QzTG5jekxtOXlaeTh5TURFNEwyTnlaV1JsYm5ScFlXeHpMM1l4SWwwc0luUjVjR1VpT2xzaVZtVnlhV1pwWVdKc1pVTnlaV1JsYm5ScFlXd2lMQ0pIZFdWemRFTnlaV1JsYm5ScFlXd2lYU3dpWTNKbFpHVnVkR2xoYkZOMVltcGxZM1FpT25zaVptbHljM1JPWVcxbElqb2lTR1ZzYkc4aUxDSnNZWE4wVG1GdFpTSTZJbE53YUdWeVpXOXVJaXdpWlcxaGFXd2lPaUp6Y0dobGNtVnZia0JsZUdGdGNHeGxMbU52YlNJc0luUjVjR1VpT2lKVGNHaGxjbVZ2YmlCSGRXVnpkQ0lzSW1sa0lqb2laR2xrT21wM2F6cGxlVXBvWWtkamFVOXBTa1pWZWtreFRtdHphVXhEU2pGak1sVnBUMmxLZW1GWFkybE1RMHB5WkVocmFVOXBTa1pSZVVselNXMU9lV1JwU1RaSmJrNXNXVE5CZVU1VVduSk5VMGx6U1c1bmFVOXBTbXBOVm1SWlkzcGtXRTB5TVRWak1sWldXbXMxUTJOWVRqUmFSa0pZVVd0c1NHRkZkR3RPUmxJMlRVVjRVMHhWV25GUFJWcE9WMWRGZDBscGQybGxVMGsyU1d4a2RHRXdUbGxrVkVZelpWaHdZVm93WkU5T01WWTBWRzFHZDJOSVJuVlVNVVpvVkRKMFdFMXJUbTVVTVU1MVZESTVOVlJWYkZWa1YwMXBabEVpZlgwc0lrQmpiMjUwWlhoMElqcGJJbWgwZEhCek9pOHZkM2QzTG5jekxtOXlaeTh5TURFNEwyTnlaV1JsYm5ScFlXeHpMM1l4SWwwc0luUjVjR1VpT2xzaVZtVnlhV1pwWVdKc1pVTnlaV1JsYm5ScFlXd2lMQ0pIZFdWemRFTnlaV1JsYm5ScFlXd2lYU3dpWlhod2FYSmhkR2x2YmtSaGRHVWlPaUl5TURJekxURXdMVEF6VkRBeU9qVTFPakE0TGpFek0xb2lMQ0pqY21Wa1pXNTBhV0ZzVTNWaWFtVmpkQ0k2ZXlKbWFYSnpkRTVoYldVaU9pSklaV3hzYnlJc0lteGhjM1JPWVcxbElqb2lVM0JvWlhKbGIyNGlMQ0psYldGcGJDSTZJbk53YUdWeVpXOXVRR1Y0WVcxd2JHVXVZMjl0SWl3aWRIbHdaU0k2SWxOd2FHVnlaVzl1SUVkMVpYTjBJaXdpYVdRaU9pSmthV1E2YW5kck9tVjVTbWhpUjJOcFQybEtSbFY2U1RGT2EzTnBURU5LTVdNeVZXbFBhVXA2WVZkamFVeERTbkprU0d0cFQybEtSbEY1U1hOSmJVNTVaR2xKTmtsdVRteFpNMEY1VGxSYWNrMVRTWE5KYm1kcFQybEthazFXWkZsamVtUllUVEl4TldNeVZsWmFhelZEWTFoT05GcEdRbGhSYTJ4SVlVVjBhMDVHVWpaTlJYaFRURlZhY1U5RldrNVhWMFYzU1dsM2FXVlRTVFpKYkdSMFlUQk9XV1JVUmpObFdIQmhXakJrVDA0eFZqUlViVVozWTBoR2RWUXhSbWhVTW5SWVRXdE9ibFF4VG5WVU1qazFWRlZzVldSWFRXbG1VU0o5TENKcGMzTjFaWElpT2lKa2FXUTZhbmRyT21WNVNtaGlSMk5wVDJsS1JsVjZTVEZPYVVselNXNVdlbHBUU1RaSmJrNXdXbmxKYzBsdGREQmxVMGsyU1d0V1JFbHBkMmxaTTBveVNXcHZhVlZETUhsT1ZGbHBURU5LTkVscWIybFdSV041VTBSS05FMXRVbGhYUlRSNlpGVk9lRmR1UW5oU2FrWTFZekJHVVZWV1drVlRhMVpQV0RCbmRGRXdNVEJaYldSeFdXa3hUMXA1U1hOSmJtdHBUMmxKTlZSVWFFOWxSMUYzVlVVMGVVMXJNRFZpUmtKRlpVZFNkMUpJUW5aV1JYZzJUVlJXTTFwdWJHRlRiazB5VjIxb1RGTldWa3ROZWswMFNXNHdJaXdpYVhOemRXRnVZMlZFWVhSbElqb2lNakF5TXkwd09TMHlPVlF4TWpvek1Ub3dPQzR4TXpOYUlpd2ljM1ZpSWpvaVpHbGtPbXAzYXpwbGVVcG9Za2RqYVU5cFNrWlZla2t4VG10emFVeERTakZqTWxWcFQybEtlbUZYWTJsTVEwcHlaRWhyYVU5cFNrWlJlVWx6U1cxT2VXUnBTVFpKYms1c1dUTkJlVTVVV25KTlUwbHpTVzVuYVU5cFNtcE5WbVJaWTNwa1dFMHlNVFZqTWxaV1dtczFRMk5ZVGpSYVJrSllVV3RzU0dGRmRHdE9SbEkyVFVWNFUweFZXbkZQUlZwT1YxZEZkMGxwZDJsbFUwazJTV3hrZEdFd1RsbGtWRVl6WlZod1lWb3daRTlPTVZZMFZHMUdkMk5JUm5WVU1VWm9WREowV0UxclRtNVVNVTUxVkRJNU5WUlZiRlZrVjAxcFpsRWlMQ0p1WW1ZaU9qRTJPVFU1T1RBMk5qZ3NJbWx6Y3lJNkltUnBaRHBxZDJzNlpYbEthR0pIWTJsUGFVcEdWWHBKTVU1cFNYTkpibFo2V2xOSk5rbHVUbkJhZVVselNXMTBNR1ZUU1RaSmExWkVTV2wzYVZrelNqSkphbTlwVlVNd2VVNVVXV2xNUTBvMFNXcHZhVlpGWTNsVFJFbzBUVzFTV0ZkRk5IcGtWVTU0VjI1Q2VGSnFSalZqTUVaUlZWWmFSVk5yVms5WU1HZDBVVEF4TUZsdFpIRlphVEZQV25sSmMwbHVhMmxQYVVrMVZGUm9UMlZIVVhkVlJUUjVUV3N3TldKR1FrVmxSMUozVWtoQ2RsWkZlRFpOVkZZeldtNXNZVk51VFRKWGJXaE1VMVpXUzAxNlRUUkpiakFpZlEud2RodExYRTRqVTFDLTNZQkJwUDktcUUteWgxeE9aNmxCTEotMGU1X1NhN2ZuclVIY0FhVTFuM2tOMkNlQ3lUVmp0bTFVeTNUbDZSelVPTTZNalAzdlEiXX0sInByZXNlbnRhdGlvbl9zdWJtaXNzaW9uIjp7ImlkIjoidG9DdGp5Y0V3QlZCWVBsbktBQTZGIiwiZGVmaW5pdGlvbl9pZCI6InNwaGVyZW9uIiwiZGVzY3JpcHRvcl9tYXAiOlt7ImlkIjoiNGNlN2FmZjEtMDIzNC00ZjM1LTlkMjEtMjUxNjY4YTYwOTUwIiwiZm9ybWF0Ijoiand0X3ZjIiwicGF0aCI6IiQudmVyaWZpYWJsZUNyZWRlbnRpYWxbMF0ifV19LCJuYmYiOjE2OTU5OTU2MzYsImlzcyI6ImRpZDprZXk6ejZNa3NFeXg2ZDVwQjFla29hVm1hR3NpYmJjWUhFOVZ4eDdWMTNQU3FQd3hZUnpMIn0.w3guHX-pmxJGGn5dGSSIKSba9xywnOutDk-l3tc_bpgHEOSbcR1mmmCqX5sSlZM_G0hgAbgpIv_YYI5iQNIfCw` + const keyID = "did:key:z6MksEyx6d5pB1ekoaVmaGsibbcYHE9Vxx7V13PSqPwxYRzL#z6MksEyx6d5pB1ekoaVmaGsibbcYHE9Vxx7V13PSqPwxYRzL" + keyAsJWK, err := jwk.ParseKey([]byte(`{ + "kty": "OKP", + "crv": "Ed25519", + "x": "vgLDESnU0TIlW-PmajyrvSlk9VysAsRkSYiEPBELj-U" + }`)) + require.NoError(t, err) + require.NoError(t, keyAsJWK.Set("kid", keyID)) + publicKey, err := keyAsJWK.PublicKey() + require.NoError(t, err) + + presentation, err := vc.ParseVerifiablePresentation(rawVP) + require.NoError(t, err) + ctx := newMockContext(t) + ctx.keyResolver.EXPECT().ResolveKeyByID(keyID, gomock.Any(), resolver.NutsSigningKeyType).Return(publicKey, nil) + + vcs, err := ctx.verifier.VerifyVP(*presentation, false, false, nil) + + assert.EqualError(t, err, "verification method is not of issuer") + assert.Empty(t, vcs) + }) + }) + t.Run("JSONLD", func(t *testing.T) { + rawVP := `{ "@context": [ "https://www.w3.org/2018/credentials/v1", "https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json" @@ -525,140 +677,141 @@ func TestVerifier_VerifyVP(t *testing.T) { ] } }` - vp := vc.VerifiablePresentation{} - _ = json.Unmarshal([]byte(rawVP), &vp) - vpSignerKeyID := did.MustParseDIDURL(vp.Proof[0].(map[string]interface{})["verificationMethod"].(string)) - - t.Run("ok - do not verify VCs", func(t *testing.T) { + vp := vc.VerifiablePresentation{} _ = json.Unmarshal([]byte(rawVP), &vp) + vpSignerKeyID := did.MustParseDIDURL(vp.Proof[0].(map[string]interface{})["verificationMethod"].(string)) - var validAt *time.Time + t.Run("ok - do not verify VCs", func(t *testing.T) { + _ = json.Unmarshal([]byte(rawVP), &vp) - ctx := newMockContext(t) - ctx.keyResolver.EXPECT().ResolveKeyByID(vpSignerKeyID.String(), validAt, resolver.NutsSigningKeyType).Return(vdr.TestMethodDIDAPrivateKey().Public(), nil) + var validAt *time.Time - vcs, err := ctx.verifier.VerifyVP(vp, false, false, validAt) + ctx := newMockContext(t) + ctx.keyResolver.EXPECT().ResolveKeyByID(vpSignerKeyID.String(), validAt, resolver.NutsSigningKeyType).Return(vdr.TestMethodDIDAPrivateKey().Public(), nil) - assert.NoError(t, err) - assert.Len(t, vcs, 1) - }) - t.Run("ok - verify VCs (and verify trusted)", func(t *testing.T) { - _ = json.Unmarshal([]byte(rawVP), &vp) + vcs, err := ctx.verifier.VerifyVP(vp, false, false, validAt) - var validAt *time.Time + assert.NoError(t, err) + assert.Len(t, vcs, 1) + }) + t.Run("ok - verify VCs (and verify trusted)", func(t *testing.T) { + _ = json.Unmarshal([]byte(rawVP), &vp) - ctx := newMockContext(t) - ctx.keyResolver.EXPECT().ResolveKeyByID(vpSignerKeyID.String(), validAt, resolver.NutsSigningKeyType).Return(vdr.TestMethodDIDAPrivateKey().Public(), nil) + var validAt *time.Time - mockVerifier := NewMockVerifier(ctx.ctrl) - mockVerifier.EXPECT().Verify(vp.VerifiableCredential[0], false, true, validAt) + ctx := newMockContext(t) + ctx.keyResolver.EXPECT().ResolveKeyByID(vpSignerKeyID.String(), validAt, resolver.NutsSigningKeyType).Return(vdr.TestMethodDIDAPrivateKey().Public(), nil) - vcs, err := ctx.verifier.doVerifyVP(mockVerifier, vp, true, false, validAt) + mockVerifier := NewMockVerifier(ctx.ctrl) + mockVerifier.EXPECT().Verify(vp.VerifiableCredential[0], false, true, validAt) - assert.NoError(t, err) - assert.Len(t, vcs, 1) - }) - t.Run("ok - verify VCs (do not need to be trusted)", func(t *testing.T) { - _ = json.Unmarshal([]byte(rawVP), &vp) + vcs, err := ctx.verifier.doVerifyVP(mockVerifier, vp, true, false, validAt) - var validAt *time.Time + assert.NoError(t, err) + assert.Len(t, vcs, 1) + }) + t.Run("ok - verify VCs (do not need to be trusted)", func(t *testing.T) { + _ = json.Unmarshal([]byte(rawVP), &vp) - ctx := newMockContext(t) - ctx.keyResolver.EXPECT().ResolveKeyByID(vpSignerKeyID.String(), validAt, resolver.NutsSigningKeyType).Return(vdr.TestMethodDIDAPrivateKey().Public(), nil) + var validAt *time.Time - mockVerifier := NewMockVerifier(ctx.ctrl) - mockVerifier.EXPECT().Verify(vp.VerifiableCredential[0], true, true, validAt) + ctx := newMockContext(t) + ctx.keyResolver.EXPECT().ResolveKeyByID(vpSignerKeyID.String(), validAt, resolver.NutsSigningKeyType).Return(vdr.TestMethodDIDAPrivateKey().Public(), nil) - vcs, err := ctx.verifier.doVerifyVP(mockVerifier, vp, true, true, validAt) + mockVerifier := NewMockVerifier(ctx.ctrl) + mockVerifier.EXPECT().Verify(vp.VerifiableCredential[0], true, true, validAt) - assert.NoError(t, err) - assert.Len(t, vcs, 1) - }) - t.Run("error - VP verification fails (not valid at time)", func(t *testing.T) { - _ = json.Unmarshal([]byte(rawVP), &vp) + vcs, err := ctx.verifier.doVerifyVP(mockVerifier, vp, true, true, validAt) - var validAt time.Time + assert.NoError(t, err) + assert.Len(t, vcs, 1) + }) + t.Run("error - VP verification fails (not valid at time)", func(t *testing.T) { + _ = json.Unmarshal([]byte(rawVP), &vp) - ctx := newMockContext(t) + var validAt time.Time - mockVerifier := NewMockVerifier(ctx.ctrl) + ctx := newMockContext(t) - vcs, err := ctx.verifier.doVerifyVP(mockVerifier, vp, true, true, &validAt) + mockVerifier := NewMockVerifier(ctx.ctrl) - assert.EqualError(t, err, "verification error: presentation not valid at given time") - assert.Empty(t, vcs) - }) - t.Run("error - VC verification fails", func(t *testing.T) { - _ = json.Unmarshal([]byte(rawVP), &vp) + vcs, err := ctx.verifier.doVerifyVP(mockVerifier, vp, true, true, &validAt) - var validAt *time.Time + assert.EqualError(t, err, "verification error: presentation not valid at given time") + assert.Empty(t, vcs) + }) + t.Run("error - VC verification fails", func(t *testing.T) { + _ = json.Unmarshal([]byte(rawVP), &vp) - ctx := newMockContext(t) - ctx.keyResolver.EXPECT().ResolveKeyByID(vpSignerKeyID.String(), validAt, resolver.NutsSigningKeyType).Return(vdr.TestMethodDIDAPrivateKey().Public(), nil) + var validAt *time.Time - mockVerifier := NewMockVerifier(ctx.ctrl) - mockVerifier.EXPECT().Verify(vp.VerifiableCredential[0], false, true, validAt).Return(errors.New("invalid")) + ctx := newMockContext(t) + ctx.keyResolver.EXPECT().ResolveKeyByID(vpSignerKeyID.String(), validAt, resolver.NutsSigningKeyType).Return(vdr.TestMethodDIDAPrivateKey().Public(), nil) - vcs, err := ctx.verifier.doVerifyVP(mockVerifier, vp, true, false, validAt) + mockVerifier := NewMockVerifier(ctx.ctrl) + mockVerifier.EXPECT().Verify(vp.VerifiableCredential[0], false, true, validAt).Return(errors.New("invalid")) - assert.Error(t, err) - assert.Empty(t, vcs) - }) - t.Run("error - invalid signature", func(t *testing.T) { - _ = json.Unmarshal([]byte(rawVP), &vp) + vcs, err := ctx.verifier.doVerifyVP(mockVerifier, vp, true, false, validAt) - var validAt *time.Time + assert.Error(t, err) + assert.Empty(t, vcs) + }) + t.Run("error - invalid signature", func(t *testing.T) { + _ = json.Unmarshal([]byte(rawVP), &vp) - ctx := newMockContext(t) - // Return incorrect key, causing signature verification failure - ctx.keyResolver.EXPECT().ResolveKeyByID(vpSignerKeyID.String(), validAt, resolver.NutsSigningKeyType).Return(vdr.TestMethodDIDBPrivateKey().Public(), nil) + var validAt *time.Time - vcs, err := ctx.verifier.VerifyVP(vp, false, false, validAt) + ctx := newMockContext(t) + // Return incorrect key, causing signature verification failure + ctx.keyResolver.EXPECT().ResolveKeyByID(vpSignerKeyID.String(), validAt, resolver.NutsSigningKeyType).Return(vdr.TestMethodDIDBPrivateKey().Public(), nil) - assert.EqualError(t, err, "verification error: invalid signature: invalid proof signature: failed to verify signature using ecdsa") - assert.Empty(t, vcs) - }) - t.Run("error - signing key unknown", func(t *testing.T) { - _ = json.Unmarshal([]byte(rawVP), &vp) + vcs, err := ctx.verifier.VerifyVP(vp, false, false, validAt) - var validAt *time.Time + assert.EqualError(t, err, "verification error: invalid signature: invalid proof signature: failed to verify signature using ecdsa") + assert.Empty(t, vcs) + }) + t.Run("error - signing key unknown", func(t *testing.T) { + _ = json.Unmarshal([]byte(rawVP), &vp) - ctx := newMockContext(t) - // Return incorrect key, causing signature verification failure - ctx.keyResolver.EXPECT().ResolveKeyByID(vpSignerKeyID.String(), validAt, resolver.NutsSigningKeyType).Return(nil, resolver.ErrKeyNotFound) + var validAt *time.Time - vcs, err := ctx.verifier.VerifyVP(vp, false, false, validAt) + ctx := newMockContext(t) + // Return incorrect key, causing signature verification failure + ctx.keyResolver.EXPECT().ResolveKeyByID(vpSignerKeyID.String(), validAt, resolver.NutsSigningKeyType).Return(nil, resolver.ErrKeyNotFound) - assert.ErrorIs(t, err, resolver.ErrKeyNotFound) - assert.Empty(t, vcs) - }) - t.Run("error - invalid proof", func(t *testing.T) { - _ = json.Unmarshal([]byte(rawVP), &vp) + vcs, err := ctx.verifier.VerifyVP(vp, false, false, validAt) - vp.Proof = []interface{}{"invalid"} + assert.ErrorIs(t, err, resolver.ErrKeyNotFound) + assert.Empty(t, vcs) + }) + t.Run("error - invalid proof", func(t *testing.T) { + _ = json.Unmarshal([]byte(rawVP), &vp) - var validAt *time.Time + vp.Proof = []interface{}{"invalid"} - ctx := newMockContext(t) + var validAt *time.Time - vcs, err := ctx.verifier.VerifyVP(vp, false, false, validAt) + ctx := newMockContext(t) - assert.EqualError(t, err, "verification error: unsupported proof type: json: cannot unmarshal string into Go value of type proof.LDProof") - assert.Empty(t, vcs) - }) - t.Run("error - no proof", func(t *testing.T) { - _ = json.Unmarshal([]byte(rawVP), &vp) + vcs, err := ctx.verifier.VerifyVP(vp, false, false, validAt) - vp.Proof = nil + assert.EqualError(t, err, "verification error: unsupported proof type: json: cannot unmarshal string into Go value of type proof.LDProof") + assert.Empty(t, vcs) + }) + t.Run("error - no proof", func(t *testing.T) { + _ = json.Unmarshal([]byte(rawVP), &vp) - var validAt *time.Time + vp.Proof = nil - ctx := newMockContext(t) + var validAt *time.Time - vcs, err := ctx.verifier.VerifyVP(vp, false, false, validAt) + ctx := newMockContext(t) + + vcs, err := ctx.verifier.VerifyVP(vp, false, false, validAt) - assert.EqualError(t, err, "verification error: exactly 1 proof is expected") - assert.Empty(t, vcs) + assert.EqualError(t, err, "verification error: exactly 1 proof is expected") + assert.Empty(t, vcs) + }) }) } diff --git a/vdr/didkey/resolver.go b/vdr/didkey/resolver.go new file mode 100644 index 0000000000..6aeeaf4904 --- /dev/null +++ b/vdr/didkey/resolver.go @@ -0,0 +1,144 @@ +/* + * 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 didkey + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/x509" + "encoding/binary" + "errors" + "fmt" + "github.com/lestrrat-go/jwx/x25519" + "github.com/mr-tron/base58" + "github.com/multiformats/go-multicodec" + ssi "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/nuts-node/vdr/resolver" + "io" +) + +// MethodName is the name of this DID method. +const MethodName = "key" + +var _ resolver.DIDResolver = &Resolver{} + +var errInvalidPublicKeyLength = errors.New("invalid did:key: invalid public key length") + +// NewResolver creates a new Resolver. +func NewResolver() *Resolver { + return &Resolver{} +} + +type Resolver struct { +} + +func (r Resolver) Resolve(id did.DID, _ *resolver.ResolveMetadata) (*did.Document, *resolver.DocumentMetadata, error) { + if id.Method != MethodName { + return nil, nil, fmt.Errorf("unsupported DID method: %s", id.Method) + } + encodedKey := id.ID + if len(encodedKey) == 0 || encodedKey[0] != 'z' { + return nil, nil, errors.New("did:key does not start with 'z'") + } + mcBytes, err := base58.DecodeAlphabet(encodedKey[1:], base58.BTCAlphabet) + if err != nil { + return nil, nil, fmt.Errorf("did:key: invalid base58btc: %w", err) + } + reader := bytes.NewReader(mcBytes) + keyType, err := binary.ReadUvarint(reader) + if err != nil { + return nil, nil, fmt.Errorf("did:key: invalid multicodec value: %w", err) + } + // See https://w3c-ccg.github.io/did-method-key/#signature-method-creation-algorithm + var key crypto.PublicKey + mcBytes, _ = io.ReadAll(reader) + keyLength := len(mcBytes) + + switch multicodec.Code(keyType) { + case multicodec.Bls12_381G2Pub: + return nil, nil, errors.New("did:key: bls12381 public keys are not supported") + case multicodec.X25519Pub: + if keyLength != 32 { + return nil, nil, errInvalidPublicKeyLength + } + key = x25519.PublicKey(mcBytes) + case multicodec.Ed25519Pub: + if keyLength != 32 { + return nil, nil, errInvalidPublicKeyLength + } + key = ed25519.PublicKey(mcBytes) + case multicodec.Secp256k1Pub: + // lestrrat/jwk.New() is missing support for secp256k1 + return nil, nil, errors.New("did:key: secp256k1 public keys are not supported") + case multicodec.P256Pub: + if key, err = unmarshalEC(elliptic.P256(), 33, mcBytes); err != nil { + return nil, nil, err + } + case multicodec.P384Pub: + if key, err = unmarshalEC(elliptic.P384(), 49, mcBytes); err != nil { + return nil, nil, err + } + case multicodec.P521Pub: + key, _ = unmarshalEC(elliptic.P521(), -1, mcBytes) + case multicodec.RsaPub: + rsaKey, err := x509.ParsePKCS1PublicKey(mcBytes) + if err != nil { + return nil, nil, fmt.Errorf("did:key: invalid PKCS#1 encoded RSA public key: %w", err) + } + // Safe RSA keys must be at least 2048 bits + if rsaKey.Size() < 256 { + return nil, nil, errors.New("did:key: RSA public key is too small (must be at least 2048 bits)") + } + key = rsaKey + default: + return nil, nil, fmt.Errorf("did:key: unsupported public key type: 0x%x", keyType) + } + + document := did.Document{ + Context: []ssi.URI{ + ssi.MustParseURI("https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json"), + did.DIDContextV1URI(), + }, + ID: id, + } + keyID := id + keyID.Fragment = id.ID + vm, err := did.NewVerificationMethod(keyID, ssi.JsonWebKey2020, id, key) + if err != nil { + return nil, nil, err + } + document.AddAssertionMethod(vm) + document.AddAuthenticationMethod(vm) + document.AddKeyAgreement(vm) + document.AddCapabilityDelegation(vm) + document.AddCapabilityInvocation(vm) + return &document, &resolver.DocumentMetadata{}, nil +} + +func unmarshalEC(curve elliptic.Curve, expectedLen int, pubKeyBytes []byte) (ecdsa.PublicKey, error) { + if expectedLen != -1 && len(pubKeyBytes) != expectedLen { + return ecdsa.PublicKey{}, errInvalidPublicKeyLength + } + x, y := elliptic.UnmarshalCompressed(curve, pubKeyBytes) + return ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil +} diff --git a/vdr/didkey/resolver_test.go b/vdr/didkey/resolver_test.go new file mode 100644 index 0000000000..e6d1038568 --- /dev/null +++ b/vdr/didkey/resolver_test.go @@ -0,0 +1,279 @@ +/* + * 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 didkey + +import ( + "crypto/ecdsa" + "crypto/ed25519" + "crypto/elliptic" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/binary" + "encoding/json" + "github.com/mr-tron/base58" + "github.com/multiformats/go-multicodec" + "github.com/nuts-foundation/go-did/did" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "testing" +) + +func TestTestVectors(t *testing.T) { + resolver := Resolver{} + type testCase struct { + name string + did string + jwk map[string]interface{} + error string + } + + unsafeRSAKey, _ := rsa.GenerateKey(rand.Reader, 1024) + unsafeRSAKeyBytes := x509.MarshalPKCS1PublicKey(&unsafeRSAKey.PublicKey) + + testCases := []testCase{ + // Taken from https://w3c-ccg.github.io/did-method-key/#ed25519-x25519 + { + name: "ed25519", + did: "did:key:z6MkiTBz1ymuepAQ4HEHYSF1H8quG5GLVVQR3djdX3mDooWp", + jwk: map[string]interface{}{ + "kty": "OKP", + "crv": "Ed25519", + "x": "O2onvM62pC1io6jQKm8Nc2UyFXcd4kOmOsBIoYtZ2ik", + }, + }, + { + name: "ed25519 (invalid length)", + did: createDIDKey(multicodec.Ed25519Pub, []byte{1, 2, 3}), + error: "invalid did:key: invalid public key length", + }, + // Taken from https://w3c-ccg.github.io/did-method-key/#x25519 + { + name: "x25519", + did: "did:key:z6LSeu9HkTHSfLLeUs2nnzUSNedgDUevfNQgQjQC23ZCit6F", + jwk: map[string]interface{}{ + "crv": "X25519", + "kty": "OKP", + "x": "L-V9o0fNYkMVKNqsX7spBzD_9oSvxM_C7ZCZX1jLO3Q", + }, + }, + { + name: "x25519 (invalid length)", + did: createDIDKey(multicodec.X25519Pub, []byte{1, 2, 3}), + error: "invalid did:key: invalid public key length", + }, + // Taken from https://w3c-ccg.github.io/did-method-key/#secp256k1 + { + name: "secp256k1", + did: "did:key:zQ3shbgnTGcgBpXPdBjDur3ATMDWhS7aPs6FRFkWR19Lb9Zwz", + error: "did:key: secp256k1 public keys are not supported", + }, + // Taken from https://w3c-ccg.github.io/did-method-key/#bls-12381 + { + name: "bls12381", + did: "did:key:zUC7K4ndUaGZgV7Cp2yJy6JtMoUHY6u7tkcSYUvPrEidqBmLCTLmi6d5WvwnUqejscAkERJ3bfjEiSYtdPkRSE8kSa11hFBr4sTgnbZ95SJj19PN2jdvJjyzpSZgxkyyxNnBNnY", + error: "did:key: bls12381 public keys are not supported", + }, + // Taken from https://w3c-ccg.github.io/did-method-key/#p-256 + { + name: "secp256", + did: "did:key:zDnaeucDGfhXHoJVqot3p21RuupNJ2fZrs8Lb1GV83VnSo2jR", + jwk: map[string]interface{}{ + "kty": "EC", + "crv": "P-256", + "x": "sYLQHOy9TNAWwFcAlpxkqRA5OutpWCrVPEWsgeli_KA", + "y": "l5Jr9_48oPJWHwuVmH_VZVquGe-U8RtnR-McN4tdYhs", + }, + }, + { + name: "secp256 (invalid length)", + did: createDIDKey(multicodec.P256Pub, []byte{1, 2, 3}), + error: "invalid did:key: invalid public key length", + }, + // Taken from https://w3c-ccg.github.io/did-method-key/#p-384 + { + name: "secp384", + did: "did:key:z82Lm1MpAkeJcix9K8TMiLd5NMAhnwkjjCBeWHXyu3U4oT2MVJJKXkcVBgjGhnLBn2Kaau9", + jwk: map[string]interface{}{ + "kty": "EC", + "crv": "P-384", + "x": "lInTxl8fjLKp_UCrxI0WDklahi-7-_6JbtiHjiRvMvhedhKVdHBfi2HCY8t_QJyc", + "y": "y6N1IC-2mXxHreETBW7K3mBcw0qGr3CWHCs-yl09yCQRLcyfGv7XhqAngHOu51Zv", + }, + }, + { + name: "secp384 (invalid length)", + did: createDIDKey(multicodec.P384Pub, []byte{1, 2, 3}), + error: "invalid did:key: invalid public key length", + }, + // Taken from https://w3c-ccg.github.io/did-method-key/#p-521 + { + name: "secp521", + did: "did:key:z2J9gaYxrKVpdoG9A4gRnmpnRCcxU6agDtFVVBVdn1JedouoZN7SzcyREXXzWgt3gGiwpoHq7K68X4m32D8HgzG8wv3sY5j7", + jwk: map[string]interface{}{ + "kty": "EC", + "crv": "P-521", + "x": "ASUHPMyichQ0QbHZ9ofNx_l4y7luncn5feKLo3OpJ2nSbZoC7mffolj5uy7s6KSKXFmnNWxGJ42IOrjZ47qqwqyS", + "y": "AW9ziIC4ZQQVSNmLlp59yYKrjRY0_VqO-GOIYQ9tYpPraBKUloEId6cI_vynCzlZWZtWpgOM3HPhYEgawQ703RjC", + }, + }, + // Taken from https://w3c-ccg.github.io/did-method-key/#rsa-2048 + { + name: "rsa2048", + did: "did:key:z4MXj1wBzi9jUstyPMS4jQqB6KdJaiatPkAtVtGc6bQEQEEsKTic4G7Rou3iBf9vPmT5dbkm9qsZsuVNjq8HCuW1w24nhBFGkRE4cd2Uf2tfrB3N7h4mnyPp1BF3ZttHTYv3DLUPi1zMdkULiow3M1GfXkoC6DoxDUm1jmN6GBj22SjVsr6dxezRVQc7aj9TxE7JLbMH1wh5X3kA58H3DFW8rnYMakFGbca5CB2Jf6CnGQZmL7o5uJAdTwXfy2iiiyPxXEGerMhHwhjTA1mKYobyk2CpeEcmvynADfNZ5MBvcCS7m3XkFCMNUYBS9NQ3fze6vMSUPsNa6GVYmKx2x6JrdEjCk3qRMMmyjnjCMfR4pXbRMZa3i", + jwk: map[string]interface{}{ + "kty": "RSA", + "e": "AQAB", + "n": "sbX82NTV6IylxCh7MfV4hlyvaniCajuP97GyOqSvTmoEdBOflFvZ06kR_9D6ctt45Fk6hskfnag2GG69NALVH2o4RCR6tQiLRpKcMRtDYE_thEmfBvDzm_VVkOIYfxu-Ipuo9J_S5XDNDjczx2v-3oDh5-CIHkU46hvFeCvpUS-L8TJSbgX0kjVk_m4eIb9wh63rtmD6Uz_KBtCo5mmR4TEtcLZKYdqMp3wCjN-TlgHiz_4oVXWbHUefCEe8rFnX1iQnpDHU49_SaXQoud1jCaexFn25n-Aa8f8bc5Vm-5SeRwidHa6ErvEhTvf1dz6GoNPp2iRvm-wJ1gxwWJEYPQ", + }, + }, + // Taken from https://w3c-ccg.github.io/did-method-key/#rsa-4096 + { + name: "rsa4096", + did: "did:key:zgghBUVkqmWS8e1ioRVp2WN9Vw6x4NvnE9PGAyQsPqM3fnfPf8EdauiRVfBTcVDyzhqM5FFC7ekAvuV1cJHawtfgB9wDcru1hPDobk3hqyedijhgWmsYfJCmodkiiFnjNWATE7PvqTyoCjcmrc8yMRXmFPnoASyT5beUd4YZxTE9VfgmavcPy3BSouNmASMQ8xUXeiRwjb7xBaVTiDRjkmyPD7NYZdXuS93gFhyDFr5b3XLg7Rfj9nHEqtHDa7NmAX7iwDAbMUFEfiDEf9hrqZmpAYJracAjTTR8Cvn6mnDXMLwayNG8dcsXFodxok2qksYF4D8ffUxMRmyyQVQhhhmdSi4YaMPqTnC1J6HTG9Yfb98yGSVaWi4TApUhLXFow2ZvB6vqckCNhjCRL2R4MDUSk71qzxWHgezKyDeyThJgdxydrn1osqH94oSeA346eipkJvKqYREXBKwgB5VL6WF4qAK6sVZxJp2dQBfCPVZ4EbsBQaJXaVK7cNcWG8tZBFWZ79gG9Cu6C4u8yjBS8Ux6dCcJPUTLtixQu4z2n5dCsVSNdnP1EEs8ZerZo5pBgc68w4Yuf9KL3xVxPnAB1nRCBfs9cMU6oL1EdyHbqrTfnjE8HpY164akBqe92LFVsk8RusaGsVPrMekT8emTq5y8v8CabuZg5rDs3f9NPEtogjyx49wiub1FecM5B7QqEcZSYiKHgF4mfkteT2", + jwk: map[string]interface{}{ + "kty": "RSA", + "e": "AQAB", + "n": "qMCkFFRFWtzUyZeK8mgJdyM6SEQcXC5E6JwCRVDld-jlJs8sXNOE_vliexq34wZRQ4hk53-JPFlvZ_QjRgIxdUxSMiZ3S5hlNVvvRaue6SMakA9ugQhnfXaWORro0UbPuHLms-bg5StDP8-8tIezu9c1H1FjwPcdbV6rAvKhyhnsM10qP3v2CPbdE0q3FOsihoKuTelImtO110E7N6fLn4U3EYbC4OyViqlrP1o_1M-R-tiM1cb4pD7XKJnIs6ryZdfOQSPBJwjNqSdN6Py_tdrFgPDTyacSSdpTVADOM2IMAoYbhV1N5APhnjOHBRFyKkF1HffQKpmXQLBqvUNNjuhmpVKWBtrTdcCKrglFXiw0cKGHKxIirjmiOlB_HYHg5UdosyE3_1Txct2U7-WBB6QXak1UgxCzgKYBDI8UPA0RlkUuHHP_Zg0fVXrXIInHO04MYxUeSps5qqyP6dJBu_v_BDn3zUq6LYFwJ_-xsU7zbrKYB4jaRlHPoCj_eDC-rSA2uQ4KXHBB8_aAqNFC9ukWxc26Ifz9dF968DLuL30bi-ZAa2oUh492Pw1bg89J7i4qTsOOfpQvGyDV7TGhKuUG3Hbumfr2w16S-_3EI2RIyd1nYsflE6ZmCkZQMG_lwDAFXaqfyGKEDouJuja4XH8r4fGWeGTrozIoniXT1HU", + }, + }, + { + name: "rsa (invalid key)", + did: createDIDKey(multicodec.RsaPub, []byte{1, 2, 3}), + error: "did:key: invalid PKCS#1 encoded RSA public key: asn1: structure error: tags don't match (16 vs {class:0 tag:1 length:2 isCompound:false}) {optional:false explicit:false application:false private:false defaultValue: tag: stringType:0 timeType:0 set:false omitEmpty:false} pkcs1PublicKey @2", + }, + { + name: "rsa (key too small)", + did: createDIDKey(multicodec.RsaPub, unsafeRSAKeyBytes), + error: "did:key: RSA public key is too small (must be at least 2048 bits)", + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + doc, md, err := resolver.Resolve(did.MustParseDID(tc.did), nil) + if tc.error != "" { + require.EqualError(t, err, tc.error) + return + } + require.NoError(t, err) + require.NotNil(t, doc) + require.NotNil(t, md) + // Assert getting the public key + vm := doc.VerificationMethod[0] + publicKey, err := vm.PublicKey() + require.NoError(t, err, "failed to get public key") + require.NotNil(t, publicKey, "public key is nil") + // Assert JWK type + jwk, err := vm.JWK() + + require.NoError(t, err, "failed to get JWK") + jwkJSON, _ := json.Marshal(jwk) + var jwkAsMap map[string]interface{} + _ = json.Unmarshal(jwkJSON, &jwkAsMap) + assert.Equal(t, tc.jwk, jwkAsMap) + }) + } +} + +func TestResolver_Resolve(t *testing.T) { + t.Run("did:key ID does not start with 'z' (invalid multibase encoding)", func(t *testing.T) { + _, _, err := Resolver{}.Resolve(did.MustParseDID("did:key:foo"), nil) + require.EqualError(t, err, "did:key does not start with 'z'") + }) + t.Run("did:key ID is not valid base58btc encoded 'z'", func(t *testing.T) { + _, _, err := Resolver{}.Resolve(did.MustParseDID("did:key:z291830129"), nil) + require.EqualError(t, err, "did:key: invalid base58btc: invalid base58 digit ('0')") + }) + t.Run("invalid multicodec key type", func(t *testing.T) { + _, _, err := Resolver{}.Resolve(did.MustParseDID("did:key:z"), nil) + require.EqualError(t, err, "did:key: invalid base58btc: zero length string") + }) + t.Run("unsupported key type", func(t *testing.T) { + didKey := createDIDKey(multicodec.Aes256, []byte{1, 2, 3}) + _, _, err := Resolver{}.Resolve(did.MustParseDID(didKey), nil) + require.EqualError(t, err, "did:key: unsupported public key type: 0xa2") + }) + t.Run("verify created DID document", func(t *testing.T) { + const expected = ` +{ + "@context": [ + "https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json", + "https://www.w3.org/ns/did/v1" + ], + "assertionMethod": [ + "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + ], + "authentication": [ + "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + ], + "capabilityDelegation": [ + "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + ], + "capabilityInvocation": [ + "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + ], + "id": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "keyAgreement": [ + "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK" + ], + "verificationMethod": [ + { + "controller": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "id": "did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK#z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK", + "publicKeyJwk": { + "crv": "Ed25519", + "kty": "OKP", + "x": "Lm_M42cB3HkUiODQsXRcweM6TByfzEHGO9ND274JcOY" + }, + "type": "JsonWebKey2020" + } + ] +} +` + doc, md, err := Resolver{}.Resolve(did.MustParseDID("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"), nil) + require.NoError(t, err) + require.NotNil(t, doc) + require.NotNil(t, md) + docJSON, _ := doc.MarshalJSON() + assert.JSONEq(t, expected, string(docJSON)) + // Test the public key + publicKey, err := doc.VerificationMethod[0].PublicKey() + require.NoError(t, err) + require.IsType(t, ed25519.PublicKey{}, publicKey) + }) +} + +func TestNewResolver(t *testing.T) { + assert.NotNil(t, NewResolver()) +} + +func createDIDKey(keyType multicodec.Code, data []byte) string { + mcBytes := append(binary.AppendUvarint([]byte{}, uint64(keyType)), data...) + return "did:key:z" + string(base58.EncodeAlphabet(mcBytes, base58.BTCAlphabet)) +} + +func TestRoundTrip(t *testing.T) { + t.Run("secp384", func(t *testing.T) { + keyPair, _ := ecdsa.GenerateKey(elliptic.P384(), rand.Reader) + data := elliptic.MarshalCompressed(elliptic.P384(), keyPair.PublicKey.X, keyPair.PublicKey.Y) + key := createDIDKey(multicodec.P384Pub, data) + _, _, err := Resolver{}.Resolve(did.MustParseDID(key), nil) + require.NoError(t, err) + }) +} diff --git a/vdr/vdr.go b/vdr/vdr.go index 257f146d77..341532ac0a 100644 --- a/vdr/vdr.go +++ b/vdr/vdr.go @@ -38,6 +38,7 @@ import ( "github.com/nuts-foundation/nuts-node/network" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vdr/didjwk" + "github.com/nuts-foundation/nuts-node/vdr/didkey" "github.com/nuts-foundation/nuts-node/vdr/didnuts" didnutsStore "github.com/nuts-foundation/nuts-node/vdr/didnuts/didstore" "github.com/nuts-foundation/nuts-node/vdr/didweb" @@ -139,6 +140,7 @@ func (r *Module) Configure(_ core.ServerConfig) error { r.didResolver.Register(didnuts.MethodName, &didnuts.Resolver{Store: r.store}) r.didResolver.Register(didweb.MethodName, didweb.NewResolver()) r.didResolver.Register(didjwk.MethodName, didjwk.NewResolver()) + r.didResolver.Register(didkey.MethodName, didkey.NewResolver()) // Initiate the routines for auto-updating the data. r.networkAmbassador.Configure() diff --git a/vdr/vdr_test.go b/vdr/vdr_test.go index 30cab85a79..09c6778d67 100644 --- a/vdr/vdr_test.go +++ b/vdr/vdr_test.go @@ -555,6 +555,17 @@ func TestVDR_Configure(t *testing.T) { require.Len(t, doc.VerificationMethod, 1) assert.Equal(t, "P-256", doc.VerificationMethod[0].PublicKeyJwk["crv"]) }) + t.Run("it can resolve using did:key", func(t *testing.T) { + instance := NewVDR(nil, nil, nil, nil) + err := instance.Configure(core.ServerConfig{}) + require.NoError(t, err) + + doc, md, err := instance.Resolver().Resolve(did.MustParseDID("did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2QtKLGpbnnEGta2doK"), nil) + + assert.NoError(t, err) + assert.NotNil(t, doc) + assert.NotNil(t, md) + }) } type roundTripperFunc func(*http.Request) (*http.Response, error)