From 11f516ee7adb6aa6f3924f1551b061f3c393f79d Mon Sep 17 00:00:00 2001 From: reinkrul Date: Fri, 8 Sep 2023 16:13:23 +0200 Subject: [PATCH] VCR: Introduce wallet containing the node's own credentials (#2446) --- docs/pages/deployment/backup-restore.rst | 9 +- vcr/ambassador_test.go | 1 + vcr/holder/interface.go | 13 ++ vcr/holder/mock.go | 64 +++++ vcr/holder/wallet.go | 115 ++++++++- vcr/holder/wallet_test.go | 283 +++++++++++++++++++---- vcr/store.go | 8 +- vcr/store_test.go | 29 ++- vcr/test.go | 16 +- vcr/vcr.go | 84 ++++++- vcr/vcr_test.go | 116 +++++++++- 11 files changed, 678 insertions(+), 60 deletions(-) diff --git a/docs/pages/deployment/backup-restore.rst b/docs/pages/deployment/backup-restore.rst index 5b11bfa736..ac3c128255 100644 --- a/docs/pages/deployment/backup-restore.rst +++ b/docs/pages/deployment/backup-restore.rst @@ -71,4 +71,11 @@ To restore a backup, follow the following steps: BBolt ===== -In step 3, copy ``network/data.db``, ``vcr/backup-credentials.db``, ``vcr/backup-issued-credentials.db``, ``vcr/backup-revoked-credentials.db`` and ``vdr/didstore.db`` from your backup to the ``datadir`` (keep the directory structure). \ No newline at end of file +In step 3, copy the following files from your backup to the ``datadir`` (keep the directory structure) + + - ``network/data.db`` + - ``vcr/wallet.db`` + - ``vcr/backup-credentials.db`` + - ``vcr/backup-issued-credentials.db`` + - ``vcr/backup-revoked-credentials.db`` + - ``vdr/didstore.db`` \ No newline at end of file diff --git a/vcr/ambassador_test.go b/vcr/ambassador_test.go index ebbf9b6fa7..d32cf908da 100644 --- a/vcr/ambassador_test.go +++ b/vcr/ambassador_test.go @@ -123,6 +123,7 @@ func TestAmbassador_handleReprocessEvent(t *testing.T) { // mocks publicKey := signer.Public() + ctx.vdr.EXPECT().IsOwner(gomock.Any(), gomock.Any()).Return(false, nil) ctx.didResolver.EXPECT().Resolve(gomock.Any(), gomock.Any()).Return(documentWithPublicKey(t, publicKey), nil, nil) // Publish a VC diff --git a/vcr/holder/interface.go b/vcr/holder/interface.go index 3fa2a62a35..a0129daa19 100644 --- a/vcr/holder/interface.go +++ b/vcr/holder/interface.go @@ -23,6 +23,7 @@ import ( ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" ) @@ -34,10 +35,22 @@ var VerifiablePresentationLDType = ssi.MustParseURI("VerifiablePresentation") // Wallet holds Verifiable Credentials and can present them. type Wallet interface { + core.Diagnosable + // BuildPresentation builds and signs a Verifiable Presentation using the given Verifiable Credentials. // The assertion key used for signing it is taken from signerDID's DID document. // If signerDID is not provided, it will be derived from the credentials credentialSubject.id fields. But only if all provided credentials have the same (singular) credentialSubject.id field. BuildPresentation(ctx context.Context, credentials []vc.VerifiableCredential, options PresentationOptions, signerDID *did.DID, validateVC bool) (*vc.VerifiablePresentation, error) + + // List returns all credentials in the wallet for the given holder. + List(ctx context.Context, holderDID did.DID) ([]vc.VerifiableCredential, error) + + // Put adds the given credentials to the wallet. It is an all-or-nothing operation: + // if one of them fails, none of the credentials are added. + Put(ctx context.Context, credentials ...vc.VerifiableCredential) error + + // IsEmpty returns true if the wallet contains no credentials at all (for all holder DIDs). + IsEmpty() (bool, error) } // PresentationOptions contains parameters used to create the right VerifiablePresentation diff --git a/vcr/holder/mock.go b/vcr/holder/mock.go index 277052c6ac..96829a7c28 100644 --- a/vcr/holder/mock.go +++ b/vcr/holder/mock.go @@ -10,6 +10,7 @@ import ( did "github.com/nuts-foundation/go-did/did" vc "github.com/nuts-foundation/go-did/vc" + core "github.com/nuts-foundation/nuts-node/core" gomock "go.uber.org/mock/gomock" ) @@ -50,3 +51,66 @@ func (mr *MockWalletMockRecorder) BuildPresentation(ctx, credentials, options, s mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "BuildPresentation", reflect.TypeOf((*MockWallet)(nil).BuildPresentation), ctx, credentials, options, signerDID, validateVC) } + +// Diagnostics mocks base method. +func (m *MockWallet) Diagnostics() []core.DiagnosticResult { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Diagnostics") + ret0, _ := ret[0].([]core.DiagnosticResult) + return ret0 +} + +// Diagnostics indicates an expected call of Diagnostics. +func (mr *MockWalletMockRecorder) Diagnostics() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Diagnostics", reflect.TypeOf((*MockWallet)(nil).Diagnostics)) +} + +// IsEmpty mocks base method. +func (m *MockWallet) IsEmpty() (bool, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IsEmpty") + ret0, _ := ret[0].(bool) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// IsEmpty indicates an expected call of IsEmpty. +func (mr *MockWalletMockRecorder) IsEmpty() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsEmpty", reflect.TypeOf((*MockWallet)(nil).IsEmpty)) +} + +// List mocks base method. +func (m *MockWallet) List(ctx context.Context, holderDID did.DID) ([]vc.VerifiableCredential, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "List", ctx, holderDID) + ret0, _ := ret[0].([]vc.VerifiableCredential) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// List indicates an expected call of List. +func (mr *MockWalletMockRecorder) List(ctx, holderDID interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockWallet)(nil).List), ctx, holderDID) +} + +// Put mocks base method. +func (m *MockWallet) Put(ctx context.Context, credentials ...vc.VerifiableCredential) error { + m.ctrl.T.Helper() + varargs := []interface{}{ctx} + for _, a := range credentials { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Put", varargs...) + ret0, _ := ret[0].(error) + return ret0 +} + +// Put indicates an expected call of Put. +func (mr *MockWalletMockRecorder) Put(ctx interface{}, credentials ...interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]interface{}{ctx}, credentials...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Put", reflect.TypeOf((*MockWallet)(nil).Put), varargs...) +} diff --git a/vcr/holder/wallet.go b/vcr/holder/wallet.go index 273c65e992..aeae34d9ca 100644 --- a/vcr/holder/wallet.go +++ b/vcr/holder/wallet.go @@ -20,36 +20,46 @@ package holder import ( "context" + "encoding/binary" "encoding/json" "errors" "fmt" - ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/go-stoabs" "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/jsonld" + "github.com/nuts-foundation/nuts-node/vcr/log" "github.com/nuts-foundation/nuts-node/vcr/signature" "github.com/nuts-foundation/nuts-node/vcr/signature/proof" "github.com/nuts-foundation/nuts-node/vcr/verifier" vdr "github.com/nuts-foundation/nuts-node/vdr/types" ) +const statsShelf = "stats" + +var credentialCountStatsKey = stoabs.BytesKey("credential_count") + type wallet struct { keyResolver vdr.KeyResolver keyStore crypto.KeyStore verifier verifier.Verifier jsonldManager jsonld.JSONLD + walletStore stoabs.KVStore } // New creates a new Wallet. -func New(keyResolver vdr.KeyResolver, keyStore crypto.KeyStore, verifier verifier.Verifier, jsonldManager jsonld.JSONLD) Wallet { +func New( + keyResolver vdr.KeyResolver, keyStore crypto.KeyStore, verifier verifier.Verifier, jsonldManager jsonld.JSONLD, + walletStore stoabs.KVStore) Wallet { return &wallet{ keyResolver: keyResolver, keyStore: keyStore, verifier: verifier, jsonldManager: jsonldManager, + walletStore: walletStore, } } @@ -120,6 +130,96 @@ func (h wallet) BuildPresentation(ctx context.Context, credentials []vc.Verifiab return &signedVP, nil } +func (h wallet) Put(ctx context.Context, credentials ...vc.VerifiableCredential) error { + err := h.walletStore.Write(ctx, func(tx stoabs.WriteTx) error { + stats := tx.GetShelfWriter(statsShelf) + var newCredentials uint32 + for _, credential := range credentials { + subjectDID, err := h.resolveSubjectDID([]vc.VerifiableCredential{credential}) + if err != nil { + return fmt.Errorf("unable to resolve subject DID from VC %s: %w", credential.ID, err) + } + walletKey := stoabs.BytesKey(credential.ID.String()) + // First check if the VC doesn't already exist; otherwise stats will be incorrect + walletShelf := tx.GetShelfWriter(subjectDID.String()) + _, err = walletShelf.Get(walletKey) + if err == nil { + // Already exists + continue + } else if !errors.Is(err, stoabs.ErrKeyNotFound) { + // Other error + return fmt.Errorf("unable to check if credential %s already exists: %w", credential.ID, err) + } + // Write credential + data, _ := credential.MarshalJSON() + err = walletShelf.Put(walletKey, data) + if err != nil { + return fmt.Errorf("unable to store credential %s: %w", credential.ID, err) + } + newCredentials++ + } + // Update stats + currentCount, err := h.readCredentialCount(stats) + if err != nil { + return fmt.Errorf("unable to read wallet credential count: %w", err) + } + return stats.Put(credentialCountStatsKey, binary.BigEndian.AppendUint32([]byte{}, currentCount+newCredentials)) + }, stoabs.WithWriteLock()) // lock required for stats consistency + if err != nil { + return fmt.Errorf("unable to store credential(s): %w", err) + } + return nil +} + +func (h wallet) List(ctx context.Context, holderDID did.DID) ([]vc.VerifiableCredential, error) { + var result []vc.VerifiableCredential + err := h.walletStore.ReadShelf(ctx, holderDID.String(), func(reader stoabs.Reader) error { + return reader.Iterate(func(key stoabs.Key, value []byte) error { + var cred vc.VerifiableCredential + err := json.Unmarshal(value, &cred) + if err != nil { + return fmt.Errorf("unable to unmarshal credential %s: %w", string(key.Bytes()), err) + } + result = append(result, cred) + return nil + }, stoabs.BytesKey{}) + }) + if err != nil { + return nil, fmt.Errorf("unable to list credentials: %w", err) + } + return result, nil +} + +func (h wallet) Diagnostics() []core.DiagnosticResult { + ctx := context.Background() + var count uint32 + var err error + err = h.walletStore.Read(ctx, func(tx stoabs.ReadTx) error { + count, err = h.readCredentialCount(tx.GetShelfReader(statsShelf)) + return err + }) + if err != nil { + log.Logger().WithError(err).Warn("unable to read credential count in wallet") + } + return []core.DiagnosticResult{ + core.GenericDiagnosticResult{ + Title: "credential_count", + Outcome: int(count), + }, + } +} + +func (h wallet) IsEmpty() (bool, error) { + ctx := context.Background() + var count uint32 + var err error + err = h.walletStore.Read(ctx, func(tx stoabs.ReadTx) error { + count, err = h.readCredentialCount(tx.GetShelfReader(statsShelf)) + return err + }) + return count == 0, err +} + func (h wallet) resolveSubjectDID(credentials []vc.VerifiableCredential) (*did.DID, error) { var subjectID did.DID for _, credential := range credentials { @@ -139,3 +239,14 @@ func (h wallet) resolveSubjectDID(credentials []vc.VerifiableCredential) (*did.D return &subjectID, nil } + +func (h wallet) readCredentialCount(statsShelf stoabs.Reader) (uint32, error) { + countBytes, err := statsShelf.Get(credentialCountStatsKey) + if errors.Is(err, stoabs.ErrKeyNotFound) { + // No stats yet + countBytes = make([]byte, 4) + } else if err != nil { + return 0, fmt.Errorf("error reading credential count for wallet: %w", err) + } + return binary.BigEndian.Uint32(countBytes), nil +} diff --git a/vcr/holder/wallet_test.go b/vcr/holder/wallet_test.go index 80955c2f7e..c19328786a 100644 --- a/vcr/holder/wallet_test.go +++ b/vcr/holder/wallet_test.go @@ -19,9 +19,14 @@ package holder import ( + "context" "encoding/json" "errors" + "github.com/google/uuid" + "github.com/nuts-foundation/go-did/did" + "github.com/nuts-foundation/go-stoabs" "github.com/nuts-foundation/nuts-node/audit" + "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/stretchr/testify/require" "testing" @@ -41,39 +46,9 @@ import ( var testDID = vdr.TestDIDA -func TestWallet_Present(t *testing.T) { +func TestWallet_BuildPresentation(t *testing.T) { var kid = vdr.TestMethodDIDA.String() - testCredentialJSON := ` -{ - "@context": [ - "https://www.w3.org/2018/credentials/v1", - "https://nuts.nl/credentials/v1", - "https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json" - ], - "credentialSubject": { - "company": { - "city": "Hengelo", - "name": "De beste zorg" - }, - "id": "` + testDID.String() + `" - }, - "id": "did:nuts:4tzMaWfpizVKeA8fscC3JTdWBc3asUWWMj5hUFHdWX3H#d2aa8189-db59-4dad-a3e5-60ca54f8fcc0", - "issuanceDate": "2021-12-24T13:21:29.087205+01:00", - "issuer": "did:nuts:4tzMaWfpizVKeA8fscC3JTdWBc3asUWWMj5hUFHdWX3H", - "proof": { - "created": "2021-12-24T13:21:29.087205+01:00", - "jws": "eyJhbGciOiJFUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19..hPM2GLc1K9d2D8Sbve004x9SumjLqaXTjWhUhvqWRwxfRWlwfp5gHDUYuRoEjhCXfLt-_u-knChVmK980N3LBw", - "proofPurpose": "NutsSigningKeyType", - "type": "JsonWebSignature2020", - "verificationMethod": "` + kid + `" - }, - "type": [ - "CompanyCredential", - "VerifiableCredential" - ] -}` - testCredential := vc.VerifiableCredential{} - _ = json.Unmarshal([]byte(testCredentialJSON), &testCredential) + testCredential := createCredential(kid) key := vdr.TestMethodDIDAPrivateKey() jsonldManager := jsonld.NewTestJSONLDManager(t) testDID := vdr.TestDIDA @@ -89,10 +64,9 @@ func TestWallet_Present(t *testing.T) { ctrl := gomock.NewController(t) keyResolver := types.NewMockKeyResolver(ctrl) - keyResolver.EXPECT().ResolveKey(testDID, nil, types.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) - w := New(keyResolver, keyStore, nil, jsonldManager) + w := New(keyResolver, keyStore, nil, jsonldManager, nil) resultingPresentation, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential}, options, &testDID, false) @@ -113,7 +87,7 @@ func TestWallet_Present(t *testing.T) { keyResolver.EXPECT().ResolveKey(testDID, nil, types.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) - w := New(keyResolver, keyStore, nil, jsonldManager) + w := New(keyResolver, keyStore, nil, jsonldManager, nil) resultingPresentation, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential}, options, &testDID, false) @@ -132,7 +106,7 @@ func TestWallet_Present(t *testing.T) { keyResolver.EXPECT().ResolveKey(testDID, nil, types.NutsSigningKeyType).Return(vdr.TestMethodDIDA.URI(), key.Public(), nil) - w := New(keyResolver, keyStore, nil, jsonldManager) + w := New(keyResolver, keyStore, nil, jsonldManager, nil) resultingPresentation, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential, testCredential}, options, &testDID, false) @@ -152,7 +126,7 @@ func TestWallet_Present(t *testing.T) { keyResolver.EXPECT().ResolveKey(testDID, nil, types.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) - w := New(keyResolver, keyStore, mockVerifier, jsonldManager) + w := New(keyResolver, keyStore, mockVerifier, jsonldManager, nil) resultingPresentation, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential}, options, &testDID, true) @@ -168,11 +142,11 @@ func TestWallet_Present(t *testing.T) { keyResolver.EXPECT().ResolveKey(testDID, nil, types.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) - w := New(keyResolver, keyStore, mockVerifier, jsonldManager) + w := New(keyResolver, keyStore, mockVerifier, jsonldManager, nil) resultingPresentation, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential}, options, &testDID, true) - assert.EqualError(t, err, "invalid credential (id=did:nuts:4tzMaWfpizVKeA8fscC3JTdWBc3asUWWMj5hUFHdWX3H#d2aa8189-db59-4dad-a3e5-60ca54f8fcc0): failed") + assert.EqualError(t, err, "invalid credential (id="+testCredential.ID.String()+"): failed") assert.Nil(t, resultingPresentation) }) }) @@ -186,7 +160,7 @@ func TestWallet_Present(t *testing.T) { keyResolver.EXPECT().ResolveKey(testDID, nil, types.NutsSigningKeyType).Return(ssi.MustParseURI(kid), key.Public(), nil) - w := New(keyResolver, keyStore, nil, jsonldManager) + w := New(keyResolver, keyStore, nil, jsonldManager, nil) resultingPresentation, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential, testCredential}, options, nil, false) @@ -201,7 +175,7 @@ func TestWallet_Present(t *testing.T) { keyResolver := types.NewMockKeyResolver(ctrl) - w := New(keyResolver, keyStore, nil, jsonldManager) + w := New(keyResolver, keyStore, nil, jsonldManager, nil) resultingPresentation, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential, secondCredential}, options, nil, false) @@ -216,7 +190,7 @@ func TestWallet_Present(t *testing.T) { keyResolver := types.NewMockKeyResolver(ctrl) - w := New(keyResolver, keyStore, nil, jsonldManager) + w := New(keyResolver, keyStore, nil, jsonldManager, nil) resultingPresentation, err := w.BuildPresentation(ctx, []vc.VerifiableCredential{testCredential, secondCredential}, options, nil, false) @@ -225,3 +199,228 @@ func TestWallet_Present(t *testing.T) { }) }) } + +func Test_wallet_Put(t *testing.T) { + t.Run("put 1 credential", func(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + store, _ := storageEngine.GetProvider("test").GetKVStore("credentials", storage.PersistentStorageClass) + sut := New(nil, nil, nil, nil, store) + expected := createCredential(vdr.TestMethodDIDA.String()) + + err := sut.Put(context.Background(), expected) + require.NoError(t, err) + + list, err := sut.List(context.Background(), vdr.TestDIDA) + require.NoError(t, err) + require.Len(t, list, 1) + assert.Equal(t, expected.ID.String(), list[0].ID.String()) + }) + t.Run("put 2 credentials", func(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + store, _ := storageEngine.GetProvider("test").GetKVStore("credentials", storage.PersistentStorageClass) + sut := New(nil, nil, nil, nil, store) + expected := []vc.VerifiableCredential{ + createCredential(vdr.TestMethodDIDA.String()), + createCredential(vdr.TestMethodDIDB.String()), + } + + err := sut.Put(context.Background(), expected...) + require.NoError(t, err) + + // For DID A + list, err := sut.List(context.Background(), vdr.TestDIDA) + require.NoError(t, err) + require.Len(t, list, 1) + assert.Equal(t, expected[0].ID.String(), list[0].ID.String()) + + // For DID B + list, err = sut.List(context.Background(), vdr.TestDIDB) + require.NoError(t, err) + require.Len(t, list, 1) + assert.Equal(t, expected[1].ID.String(), list[0].ID.String()) + }) + t.Run("put 3 credentials, 1 fails", func(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + store, _ := storageEngine.GetProvider("test").GetKVStore("credentials", storage.PersistentStorageClass) + sut := New(nil, nil, nil, nil, store) + expected := []vc.VerifiableCredential{ + createCredential(vdr.TestMethodDIDA.String()), + createCredential(vdr.TestMethodDIDB.String()), + {}, // no subject, causes error + } + + err := sut.Put(context.Background(), expected...) + require.Error(t, err) + + // For DID A + list, err := sut.List(context.Background(), vdr.TestDIDA) + require.NoError(t, err) + require.Empty(t, list) + + // For DID B + list, err = sut.List(context.Background(), vdr.TestDIDA) + require.NoError(t, err) + require.Empty(t, list) + }) + t.Run("duplicate credential", func(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + store, _ := storageEngine.GetProvider("test").GetKVStore("credentials", storage.PersistentStorageClass) + sut := New(nil, nil, nil, nil, store) + expected := createCredential(vdr.TestMethodDIDA.String()) + + err := sut.Put(context.Background(), expected) + require.NoError(t, err) + err = sut.Put(context.Background(), expected) + require.NoError(t, err) + + list, err := sut.List(context.Background(), vdr.TestDIDA) + require.NoError(t, err) + require.Len(t, list, 1) + assert.Equal(t, expected.ID.String(), list[0].ID.String()) + assert.Equal(t, 1, sut.Diagnostics()[0].Result(), "duplicate credential should not increment total number of credentials") + }) +} + +func Test_wallet_List(t *testing.T) { + t.Run("invalid credential returns error", func(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + store, _ := storageEngine.GetProvider("test").GetKVStore("credentials", storage.PersistentStorageClass) + sut := New(nil, nil, nil, nil, store) + err := store.WriteShelf(context.Background(), vdr.TestDIDA.String(), func(writer stoabs.Writer) error { + return writer.Put(stoabs.BytesKey("invalid"), []byte("invalid")) + }) + require.NoError(t, err) + + _, err = sut.List(context.Background(), vdr.TestDIDA) + require.EqualError(t, err, "unable to list credentials: unable to unmarshal credential invalid: invalid character 'i' looking for beginning of value") + }) +} + +func Test_wallet_Diagnostics(t *testing.T) { + t.Run("empty wallet", func(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + store, _ := storageEngine.GetProvider("test").GetKVStore("credentials", storage.PersistentStorageClass) + sut := New(nil, nil, nil, nil, store) + + actual := sut.Diagnostics() + require.Len(t, actual, 1) + assert.Equal(t, "credential_count", actual[0].Name()) + assert.Equal(t, 0, actual[0].Result()) + }) + t.Run("1 credential", func(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + store, _ := storageEngine.GetProvider("test").GetKVStore("credentials", storage.PersistentStorageClass) + sut := New(nil, nil, nil, nil, store) + cred := createCredential(vdr.TestMethodDIDA.String()) + + err := sut.Put(context.Background(), cred) + require.NoError(t, err) + + actual := sut.Diagnostics() + require.Len(t, actual, 1) + assert.Equal(t, "credential_count", actual[0].Name()) + assert.Equal(t, 1, actual[0].Result()) + }) + t.Run("2 credentials", func(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + store, _ := storageEngine.GetProvider("test").GetKVStore("credentials", storage.PersistentStorageClass) + sut := New(nil, nil, nil, nil, store) + + err := sut.Put(context.Background(), createCredential(vdr.TestMethodDIDA.String())) + require.NoError(t, err) + err = sut.Put(context.Background(), createCredential(vdr.TestMethodDIDA.String())) + require.NoError(t, err) + + actual := sut.Diagnostics() + require.Len(t, actual, 1) + assert.Equal(t, "credential_count", actual[0].Name()) + assert.Equal(t, 2, actual[0].Result()) + }) + t.Run("IO error", func(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + store, _ := storageEngine.GetProvider("test").GetKVStore("credentials", storage.PersistentStorageClass) + sut := New(nil, nil, nil, nil, store) + cred := createCredential(vdr.TestMethodDIDA.String()) + + err := sut.Put(context.Background(), cred) + require.NoError(t, err) + // Close store to cause error + _ = store.Close(context.Background()) + + actual := sut.Diagnostics() + require.Len(t, actual, 1) + assert.Equal(t, "credential_count", actual[0].Name()) + assert.Equal(t, 0, actual[0].Result()) + }) +} + +func createCredential(keyID string) vc.VerifiableCredential { + testCredentialJSON := ` +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://nuts.nl/credentials/v1", + "https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json" + ], + "credentialSubject": { + "company": { + "city": "Hengelo", + "name": "De beste zorg" + }, + "id": "` + did.MustParseDIDURL(keyID).WithoutURL().String() + `" + }, + "issuanceDate": "2021-12-24T13:21:29.087205+01:00", + "issuer": "did:nuts:4tzMaWfpizVKeA8fscC3JTdWBc3asUWWMj5hUFHdWX3H", + "proof": { + "created": "2021-12-24T13:21:29.087205+01:00", + "jws": "eyJhbGciOiJFUzI1NiIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19..hPM2GLc1K9d2D8Sbve004x9SumjLqaXTjWhUhvqWRwxfRWlwfp5gHDUYuRoEjhCXfLt-_u-knChVmK980N3LBw", + "proofPurpose": "NutsSigningKeyType", + "type": "JsonWebSignature2020", + "verificationMethod": "` + keyID + `" + }, + "type": [ + "CompanyCredential", + "VerifiableCredential" + ] +}` + testCredential := vc.VerifiableCredential{} + _ = json.Unmarshal([]byte(testCredentialJSON), &testCredential) + testCredential.ID, _ = ssi.ParseURI(testCredential.Issuer.String() + "#" + uuid.NewString()) + return testCredential +} + +func Test_wallet_IsEmpty(t *testing.T) { + t.Run("empty", func(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + store, _ := storageEngine.GetProvider("test").GetKVStore("credentials", storage.PersistentStorageClass) + sut := New(nil, nil, nil, nil, store) + + empty, err := sut.IsEmpty() + + require.NoError(t, err) + assert.True(t, empty) + }) + t.Run("2 credentials", func(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + store, _ := storageEngine.GetProvider("test").GetKVStore("credentials", storage.PersistentStorageClass) + sut := New(nil, nil, nil, nil, store) + + err := sut.Put(context.Background(), createCredential(vdr.TestMethodDIDA.String())) + require.NoError(t, err) + + empty, err := sut.IsEmpty() + + require.NoError(t, err) + assert.False(t, empty) + }) + t.Run("error", func(t *testing.T) { + storageEngine := storage.NewTestStorageEngine(t) + store, _ := storageEngine.GetProvider("test").GetKVStore("credentials", storage.PersistentStorageClass) + sut := New(nil, nil, nil, nil, store).(*wallet) + _ = sut.walletStore.Close(context.Background()) + + _, err := sut.IsEmpty() + + require.Error(t, err) + }) +} diff --git a/vcr/store.go b/vcr/store.go index 6d43c23990..4c7ae0b292 100644 --- a/vcr/store.go +++ b/vcr/store.go @@ -74,10 +74,14 @@ func (c *vcr) writeCredential(subject vc.VerifiableCredential) error { log.Logger(). WithField(core.LogFieldCredentialID, subject.ID). WithField(core.LogFieldCredentialType, vcType). - Debug("Writing credential to store") + Debug("Writing credential to store and wallet") log.Logger().Tracef("%+v", subject) - doc, _ := json.Marshal(subject) + _, err := c.writeCredentialToWallet(subject) + if err != nil { + return fmt.Errorf("unable to write credential to wallet (id=%s): %w", subject.ID, err) + } + doc, _ := json.Marshal(subject) return c.credentialCollection().Add([]leia.Document{doc}) } diff --git a/vcr/store_test.go b/vcr/store_test.go index aeb7ef15a9..cc4d91f1f8 100644 --- a/vcr/store_test.go +++ b/vcr/store_test.go @@ -20,9 +20,11 @@ package vcr import ( + "context" "crypto/ecdsa" "crypto/sha1" "encoding/json" + "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/crypto/storage/spi" "github.com/nuts-foundation/nuts-node/vdr/types" "github.com/stretchr/testify/require" @@ -40,6 +42,7 @@ func TestVcr_StoreCredential(t *testing.T) { target := vc.VerifiableCredential{} vcJSON, _ := os.ReadFile("test/vc.json") json.Unmarshal(vcJSON, &target) + holderDID := did.MustParseDID(target.CredentialSubject[0].(map[string]interface{})["id"].(string)) // load pub key pke := spi.PublicKeyEntry{} @@ -48,20 +51,37 @@ func TestVcr_StoreCredential(t *testing.T) { var pk = new(ecdsa.PublicKey) pke.JWK().Raw(pk) - t.Run("ok", func(t *testing.T) { + t.Run("ok - not owned, do not store in wallet", func(t *testing.T) { ctx := newMockContext(t) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), holderDID).Return(false, nil) ctx.didResolver.EXPECT().Resolve(gomock.Any(), &types.ResolveMetadata{}).Return(documentWithPublicKey(t, pk), nil, nil) err := ctx.vcr.StoreCredential(target, nil) assert.NoError(t, err) + list, err := ctx.vcr.wallet.List(context.Background(), holderDID) + assert.NoError(t, err) + assert.Empty(t, list) + }) + t.Run("ok - owned, store in wallet", func(t *testing.T) { + ctx := newMockContext(t) + + ctx.vdr.EXPECT().IsOwner(gomock.Any(), holderDID).Return(true, nil) + ctx.didResolver.EXPECT().Resolve(gomock.Any(), &types.ResolveMetadata{}).Return(documentWithPublicKey(t, pk), nil, nil) + + err := ctx.vcr.StoreCredential(target, nil) + + assert.NoError(t, err) + list, err := ctx.vcr.wallet.List(context.Background(), holderDID) + assert.NoError(t, err) + assert.Len(t, list, 1) }) t.Run("ok - with validAt", func(t *testing.T) { ctx := newMockContext(t) now := time.Now() - + ctx.vdr.EXPECT().IsOwner(gomock.Any(), holderDID).Return(false, nil) ctx.didResolver.EXPECT().Resolve(gomock.Any(), &types.ResolveMetadata{ResolveTime: &now}).Return(documentWithPublicKey(t, pk), nil, nil) err := ctx.vcr.StoreCredential(target, &now) @@ -72,7 +92,7 @@ func TestVcr_StoreCredential(t *testing.T) { t.Run("ok - already exists", func(t *testing.T) { ctx := newMockContext(t) now := time.Now() - + ctx.vdr.EXPECT().IsOwner(gomock.Any(), holderDID).Return(false, nil) ctx.didResolver.EXPECT().Resolve(gomock.Any(), &types.ResolveMetadata{ResolveTime: &now}).Return(documentWithPublicKey(t, pk), nil, nil) _ = ctx.vcr.StoreCredential(target, &now) @@ -85,7 +105,7 @@ func TestVcr_StoreCredential(t *testing.T) { t.Run("error - already exists, but differs", func(t *testing.T) { ctx := newMockContext(t) now := time.Now() - + ctx.vdr.EXPECT().IsOwner(gomock.Any(), holderDID).Return(false, nil) ctx.didResolver.EXPECT().Resolve(gomock.Any(), &types.ResolveMetadata{ResolveTime: &now}).Return(documentWithPublicKey(t, pk), nil, nil) _ = ctx.vcr.StoreCredential(target, &now) @@ -120,6 +140,7 @@ func TestStore_writeCredential(t *testing.T) { t.Run("ok - stored in JSON-LD collection", func(t *testing.T) { ctx := newMockContext(t) + ctx.vdr.EXPECT().IsOwner(gomock.Any(), gomock.Any()).Return(false, nil) vcBytes, _ := json.Marshal(target) ref := sha1.Sum(vcBytes) diff --git a/vcr/test.go b/vcr/test.go index 43ef8170ae..e46e490186 100644 --- a/vcr/test.go +++ b/vcr/test.go @@ -61,7 +61,7 @@ func NewTestVCRContext(t *testing.T, keyStore crypto.KeyStore) TestVCRContext { storageEngine := storage.NewTestStorageEngine(t) networkInstance := network.NewTestNetworkInstance(t) eventManager := events.NewTestManager(t) - vdrInstance := vdr.NewVDR(nil, networkInstance, didStore, eventManager) + vdrInstance := vdr.NewVDR(keyStore, networkInstance, didStore, eventManager) err := vdrInstance.Configure(core.ServerConfig{}) require.NoError(t, err) newInstance := NewVCRInstance( @@ -91,15 +91,18 @@ func NewTestVCRInstance(t *testing.T) *vcr { testDirectory := io.TestDirectory(t) didStore := didstore.NewTestStore(t) storageEngine := storage.NewTestStorageEngine(t) - networkInstance := network.NewTestNetworkInstance(t) + keyStore := crypto.NewMemoryCryptoInstance() eventManager := events.NewTestManager(t) - vdrInstance := vdr.NewVDR(nil, networkInstance, didStore, eventManager) + networkInstance := network.NewNetworkInstance(network.TestNetworkConfig(), didStore, keyStore, eventManager, storageEngine.GetProvider("network"), nil) + serverCfg := core.TestServerConfig(core.ServerConfig{Datadir: testDirectory}) + _ = networkInstance.Configure(serverCfg) + vdrInstance := vdr.NewVDR(keyStore, networkInstance, didStore, eventManager) err := vdrInstance.Configure(core.ServerConfig{}) if err != nil { t.Fatal(err) } newInstance := NewVCRInstance( - nil, + keyStore, vdrInstance, networkInstance, jsonld.NewTestJSONLDManager(t), @@ -107,8 +110,7 @@ func NewTestVCRInstance(t *testing.T) *vcr { storageEngine, pki.New(), ).(*vcr) - - if err := newInstance.Configure(core.TestServerConfig(core.ServerConfig{Datadir: testDirectory})); err != nil { + if err := newInstance.Configure(serverCfg); err != nil { t.Fatal(err) } if err := newInstance.Start(); err != nil { @@ -153,6 +155,7 @@ type mockContext struct { vcr *vcr didResolver *types.MockDIDResolver crypto *crypto.Crypto + vdr *types.MockVDR } func newMockContext(t *testing.T) mockContext { @@ -188,6 +191,7 @@ func newMockContext(t *testing.T) mockContext { ctrl: ctrl, crypto: cryptoInstance, vcr: vcr, + vdr: vdrInstance, didResolver: didResolver, } } diff --git a/vcr/vcr.go b/vcr/vcr.go index 678d14413d..dd8a0eef85 100644 --- a/vcr/vcr.go +++ b/vcr/vcr.go @@ -25,7 +25,9 @@ import ( "errors" "fmt" "github.com/nuts-foundation/go-leia/v4" + "github.com/nuts-foundation/go-stoabs" "github.com/nuts-foundation/nuts-node/pki" + "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/nuts-foundation/nuts-node/vdr/didservice" "io/fs" @@ -56,6 +58,8 @@ import ( const credentialsBackupShelf = "credentials" +var _ core.Migratable = (*vcr)(nil) + // NewVCRInstance creates a new vcr instance with default config and empty concept registry func NewVCRInstance(keyStore crypto.KeyStore, vdrInstance vdr.VDR, network network.Transactions, jsonldManager jsonld.JSONLD, eventManager events.Event, storageClient storage.Engine, @@ -91,6 +95,7 @@ type vcr struct { wallet holder.Wallet issuerStore issuer.Store verifierStore verifier.Store + walletStore stoabs.KVStore jsonldManager jsonld.JSONLD eventManager events.Event storageClient storage.Engine @@ -141,6 +146,41 @@ func (c *vcr) resolveOpenID4VCIIdentifier(ctx context.Context, id did.DID) (stri return identifier, nil } +func (c *vcr) Migrate() error { + walletIsEmpty, err := c.wallet.IsEmpty() + if err != nil { + return fmt.Errorf("unable to check if wallet is empty: %w", err) + } + if !walletIsEmpty { + // Nothing to do + return nil + } + log.Logger().Info("Migrating credentials to wallet...") + var credentials []vc.VerifiableCredential + startTime := time.Now() + defer func() { + log.Logger().Infof("Copied %d credentials into wallet in %s", len(credentials), time.Now().Sub(startTime)) + }() + err = c.credentialCollection().Iterate(leia.Query{}, func(key leia.Reference, value []byte) error { + var cred vc.VerifiableCredential + if err := json.Unmarshal(value, &cred); err != nil { + return fmt.Errorf("unable to unmarshal credential (leia key=%s): %w", key.EncodeToString(), err) + } + putInWallet, err := c.canWalletHoldCredential(cred) + if err != nil { + return err + } + if putInWallet { + credentials = append(credentials, cred) + } + return nil + }) + if err != nil { + return err + } + return c.wallet.Put(context.TODO(), credentials...) +} + func (c *vcr) Issuer() issuer.Issuer { return c.issuer } @@ -184,7 +224,7 @@ func (c *vcr) Configure(config core.ServerConfig) error { return err } - // create credentials store (for public credentials and this node's wallet) + // create credentials store (for public credentials) if err = c.createCredentialsStore(); err != nil { return err } @@ -236,7 +276,12 @@ func (c *vcr) Configure(config core.ServerConfig) error { c.ambassador = NewAmbassador(c.network, c, c.verifier, c.eventManager) - c.wallet = holder.New(c.keyResolver, c.keyStore, c.verifier, c.jsonldManager) + // Create holder/wallet + c.walletStore, err = c.storageClient.GetProvider(ModuleName).GetKVStore("wallet", storage.PersistentStorageClass) + if err != nil { + return err + } + c.wallet = holder.New(c.keyResolver, c.keyStore, c.verifier, c.jsonldManager, c.walletStore) if err = c.store.HandleRestore(); err != nil { return err @@ -550,5 +595,40 @@ func (c *vcr) Diagnostics() []core.DiagnosticResult { Title: "credential_count", Outcome: credentialCount, }, + core.DiagnosticResultMap{ + Title: "wallet_credential_count", + Items: c.wallet.Diagnostics(), + }, + } +} + +// writeCredentialToWallet writes a credential to the wallet if the subject is owned by this node. +// If it's not written to the wallet (because it's not owned by this node), it returns false. +// If it's written to the wallet, it returns true. +// If an error occurs, it returns false and the error. +func (c *vcr) writeCredentialToWallet(cred vc.VerifiableCredential) (bool, error) { + put, err := c.canWalletHoldCredential(cred) + if err != nil { + return false, err + } + if put { + return true, c.wallet.Put(context.TODO(), cred) + } + return false, nil +} + +// canWalletHoldCredential returns true if the credential is subject to the wallet of the node, meaning: +// - It is of a type that can be stored in the wallet +// - The subject of the credential is owned by this node +// If these conditions are met, it returns true. +// If an error occurs, it returns false and the error. +func (c *vcr) canWalletHoldCredential(cred vc.VerifiableCredential) (bool, error) { + if cred.IsType(*credential.NutsAuthorizationCredentialTypeURI) { + return false, nil + } + subjectDID, _ := cred.SubjectDID() + if subjectDID == nil { + return false, nil } + return c.vdrInstance.IsOwner(context.Background(), *subjectDID) } diff --git a/vcr/vcr_test.go b/vcr/vcr_test.go index 60a09b2056..6f873e73c7 100644 --- a/vcr/vcr_test.go +++ b/vcr/vcr_test.go @@ -27,8 +27,10 @@ import ( "github.com/nuts-foundation/go-leia/v4" "github.com/nuts-foundation/go-stoabs" bbolt2 "github.com/nuts-foundation/go-stoabs/bbolt" + "github.com/nuts-foundation/nuts-node/audit" "github.com/nuts-foundation/nuts-node/pki" "github.com/nuts-foundation/nuts-node/storage" + "github.com/nuts-foundation/nuts-node/vcr/holder" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" "github.com/stretchr/testify/require" "os" @@ -189,13 +191,15 @@ func TestVCR_Diagnostics(t *testing.T) { diagnostics := instance.Diagnostics() - assert.Len(t, diagnostics, 3) + assert.Len(t, diagnostics, 4) assert.Equal(t, "issuer", diagnostics[0].Name()) assert.NotEmpty(t, diagnostics[0].Result()) assert.Equal(t, "verifier", diagnostics[1].Name()) assert.NotEmpty(t, diagnostics[1].Result()) assert.Equal(t, "credential_count", diagnostics[2].Name()) assert.Equal(t, 0, diagnostics[2].Result()) + assert.Equal(t, "wallet_credential_count", diagnostics[3].Name()) + assert.NotEmpty(t, diagnostics[3].Result()) } func TestVCR_Resolve(t *testing.T) { @@ -395,6 +399,116 @@ func TestVcr_Untrusted(t *testing.T) { }) } +func TestVcr_Migrate(t *testing.T) { + const authCred = ` +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://nuts.nl/credentials/v1", + "https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json" + ], + "credentialSubject": { + "id": "did:nuts:owned" + }, + "id": "did:nuts:4tzMaWfpizVKeA8fscC3JTdWBc3asUWWMj5hUFHdWX3H#d2aa8189-db59-4dad-a3e5-60ca54f8fcc0", + "issuer": "did:nuts:4tzMaWfpizVKeA8fscC3JTdWBc3asUWWMj5hUFHdWX3H", + "type": [ + "NutsAuthorizationCredential", + "VerifiableCredential" + ] +}` + const ownedNutsOrgCred = ` +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://nuts.nl/credentials/v1", + "https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json" + ], + "credentialSubject": { + "id": "did:nuts:owned" + }, + "id": "owned", + "issuer": "did:nuts:4tzMaWfpizVKeA8fscC3JTdWBc3asUWWMj5hUFHdWX3H", + "type": [ + "NutsOrganizationCredential", + "VerifiableCredential" + ] +}` + const otherNutsOrgCred = ` +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://nuts.nl/credentials/v1", + "https://w3c-ccg.github.io/lds-jws2020/contexts/lds-jws2020-v1.json" + ], + "credentialSubject": { + "id": "did:nuts:foo" + }, + "id": "did:nuts:4tzMaWfpizVKeA8fscC3JTdWBc3asUWWMj5hUFHdWX3H#2", + "issuer": "did:nuts:4tzMaWfpizVKeA8fscC3JTdWBc3asUWWMj5hUFHdWX3H", + "type": [ + "NutsOrganizationCredential", + "VerifiableCredential" + ] +}` + const invalidCred = `{"id": "1", "issuer": false}` + + t.Run("ok", func(t *testing.T) { + ctx := audit.TestContext() + ctrl := gomock.NewController(t) + instance := NewTestVCRInstance(t) + mockVDR := types.NewMockVDR(ctrl) + ownedDID := did.MustParseDID("did:nuts:owned") + mockVDR.EXPECT().IsOwner(gomock.Any(), ownedDID).Return(true, nil) + mockVDR.EXPECT().IsOwner(gomock.Any(), did.MustParseDID("did:nuts:foo")).Return(false, nil) + instance.vdrInstance = mockVDR + + // 3 credentials: 1 owned NutsAuthorizationCredential that must be ignored, 1 non-owned credential and finally 1 credential that should end up in the wallet + require.NoError(t, instance.credentialCollection().Add([]leia.Document{[]byte(authCred)})) + require.NoError(t, instance.credentialCollection().Add([]leia.Document{[]byte(ownedNutsOrgCred)})) + require.NoError(t, instance.credentialCollection().Add([]leia.Document{[]byte(otherNutsOrgCred)})) + // Wallet should be empty beforehand + list, err := instance.wallet.List(ctx, ownedDID) + require.NoError(t, err) + require.Empty(t, list) + require.NoError(t, err) + + err = instance.Migrate() + require.NoError(t, err) + + // Check if the owned credential is now in the wallet + list, err = instance.wallet.List(ctx, ownedDID) + require.NoError(t, err) + require.Len(t, list, 1) + assert.Equal(t, "owned", list[0].ID.String()) + }) + t.Run("invalid credential in store (unmarshal error)", func(t *testing.T) { + instance := NewTestVCRInstance(t) + require.NoError(t, instance.credentialCollection().Add([]leia.Document{[]byte(invalidCred)})) + + err := instance.Migrate() + require.EqualError(t, err, "unable to unmarshal credential (leia key=fc69766520403a8f007e428b0c268da0ce2379a9): json: cannot unmarshal bool into Go struct field Alias.issuer of type string") + }) + t.Run("no need to migrate", func(t *testing.T) { + ctrl := gomock.NewController(t) + instance := NewTestVCRInstance(t) + wallet := holder.NewMockWallet(ctrl) + instance.wallet = wallet + wallet.EXPECT().IsEmpty().Times(2). + Return(true, nil). // before migration + Return(false, nil) // after migration + require.NoError(t, instance.credentialCollection().Add([]leia.Document{[]byte(ownedNutsOrgCred)})) + + // First time, migration should happen since the wallet is empty + err := instance.Migrate() + require.NoError(t, err) + + // Second time, migration should happen since the wallet is empty + err = instance.Migrate() + require.NoError(t, err) + }) +} + func TestVcr_restoreFromShelf(t *testing.T) { testVC := vc.VerifiableCredential{} _ = json.Unmarshal([]byte(jsonld.TestOrganizationCredential), &testVC)