From f67f1a5d061ec7220dff8b403cc804149145ba2b Mon Sep 17 00:00:00 2001 From: "sudesh.shetty" Date: Sun, 2 May 2021 13:04:26 -0400 Subject: [PATCH] feat: wallet content management command controllers - Part of #2770 Signed-off-by: sudesh.shetty --- pkg/controller/command/vcwallet/command.go | 209 ++++++- .../command/vcwallet/command_test.go | 575 +++++++++++++++++- pkg/controller/command/vcwallet/models.go | 104 ++++ pkg/wallet/contents.go | 73 ++- pkg/wallet/contents_test.go | 14 +- pkg/wallet/wallet.go | 4 +- pkg/wallet/wallet_test.go | 27 + 7 files changed, 986 insertions(+), 20 deletions(-) diff --git a/pkg/controller/command/vcwallet/command.go b/pkg/controller/command/vcwallet/command.go index 64b7dd9bdc..970cb8ac58 100644 --- a/pkg/controller/command/vcwallet/command.go +++ b/pkg/controller/command/vcwallet/command.go @@ -38,11 +38,26 @@ const ( // UpdateProfileErrorCode for errors during update wallet profile operations. UpdateProfileErrorCode - // OpenWalletErrorCode for errors during update wallet unlock operations. + // OpenWalletErrorCode for errors during wallet unlock operations. OpenWalletErrorCode - // CloseWalletErrorCode for errors during update wallet lock operations. + // CloseWalletErrorCode for errors during wallet lock operations. CloseWalletErrorCode + + // AddToWalletErrorCode for errors while adding contents to wallet. + AddToWalletErrorCode + + // RemoveFromWalletErrorCode for errors while removing contents from wallet. + RemoveFromWalletErrorCode + + // GetFromWalletErrorCode for errors while getting a content from wallet. + GetFromWalletErrorCode + + // GetAllFromWalletErrorCode for errors while getting all contents from wallet. + GetAllFromWalletErrorCode + + // QueryWalletErrorCode for errors while querying credentials contents from wallet. + QueryWalletErrorCode ) // All command operations. @@ -54,12 +69,18 @@ const ( UpdateProfileMethod = "UpdateProfile" OpenMethod = "Open" CloseMethod = "Close" + AddMethod = "Add" + RemoveMethod = "Remove" + GetMethod = "Get" + GetAllMethod = "GetAll" + QueryMethod = "Query" ) // miscellaneous constants for the vc wallet command controller. const ( // log constants. - logSuccess = "success" + logSuccess = "success" + logUserIDKey = "userID" ) // provider contains dependencies for the verifiable credential wallet command controller @@ -88,6 +109,11 @@ func (o *Command) GetHandlers() []command.Handler { cmdutil.NewCommandHandler(CommandName, UpdateProfileMethod, o.UpdateProfile), cmdutil.NewCommandHandler(CommandName, OpenMethod, o.Open), cmdutil.NewCommandHandler(CommandName, CloseMethod, o.Close), + cmdutil.NewCommandHandler(CommandName, AddMethod, o.Add), + cmdutil.NewCommandHandler(CommandName, RemoveMethod, o.Remove), + cmdutil.NewCommandHandler(CommandName, GetMethod, o.Get), + cmdutil.NewCommandHandler(CommandName, GetAllMethod, o.GetAll), + cmdutil.NewCommandHandler(CommandName, QueryMethod, o.Query), } } @@ -120,6 +146,9 @@ func (o *Command) CreateProfile(rw io.Writer, req io.Reader) command.Error { } } + logutil.LogDebug(logger, CommandName, CreateProfileMethod, logSuccess, + logutil.CreateKeyValueString(logUserIDKey, request.UserID)) + return nil } @@ -142,6 +171,9 @@ func (o *Command) UpdateProfile(rw io.Writer, req io.Reader) command.Error { return command.NewExecuteError(UpdateProfileErrorCode, err) } + logutil.LogDebug(logger, CommandName, UpdateProfileMethod, logSuccess, + logutil.CreateKeyValueString(logUserIDKey, request.UserID)) + return nil } @@ -171,7 +203,9 @@ func (o *Command) Open(rw io.Writer, req io.Reader) command.Error { } command.WriteNillableResponse(rw, UnlockWalletResponse{Token: token}, logger) - logutil.LogDebug(logger, CommandName, OpenMethod, logSuccess) + + logutil.LogDebug(logger, CommandName, OpenMethod, logSuccess, + logutil.CreateKeyValueString(logUserIDKey, request.UserID)) return nil } @@ -197,7 +231,171 @@ func (o *Command) Close(rw io.Writer, req io.Reader) command.Error { closed := vcWallet.Close() command.WriteNillableResponse(rw, LockWalletResponse{Closed: closed}, logger) - logutil.LogDebug(logger, CommandName, OpenMethod, logSuccess) + + logutil.LogDebug(logger, CommandName, CloseMethod, logSuccess, + logutil.CreateKeyValueString(logUserIDKey, request.UserID)) + + return nil +} + +// Add adds given data model to wallet content store. +func (o *Command) Add(rw io.Writer, req io.Reader) command.Error { + request := &AddContentRequest{} + + err := json.NewDecoder(req).Decode(&request) + if err != nil { + logutil.LogInfo(logger, CommandName, AddMethod, err.Error()) + + return command.NewValidationError(InvalidRequestErrorCode, err) + } + + vcWallet, err := wallet.New(request.UserID, o.ctx) + if err != nil { + logutil.LogInfo(logger, CommandName, AddMethod, err.Error()) + + return command.NewExecuteError(AddToWalletErrorCode, err) + } + + err = vcWallet.Add(request.Auth, request.ContentType, request.Content, wallet.AddByCollection(request.CollectionID)) + if err != nil { + logutil.LogInfo(logger, CommandName, AddMethod, err.Error()) + + return command.NewExecuteError(AddToWalletErrorCode, err) + } + + logutil.LogDebug(logger, CommandName, AddMethod, logSuccess, + logutil.CreateKeyValueString(logUserIDKey, request.UserID)) + + return nil +} + +// Remove deletes given content from wallet content store. +func (o *Command) Remove(rw io.Writer, req io.Reader) command.Error { + request := &RemoveContentRequest{} + + err := json.NewDecoder(req).Decode(&request) + if err != nil { + logutil.LogInfo(logger, CommandName, RemoveMethod, err.Error()) + + return command.NewValidationError(InvalidRequestErrorCode, err) + } + + vcWallet, err := wallet.New(request.UserID, o.ctx) + if err != nil { + logutil.LogInfo(logger, CommandName, RemoveMethod, err.Error()) + + return command.NewExecuteError(RemoveFromWalletErrorCode, err) + } + + err = vcWallet.Remove(request.Auth, request.ContentType, request.ContentID) + if err != nil { + logutil.LogInfo(logger, CommandName, RemoveMethod, err.Error()) + + return command.NewExecuteError(RemoveFromWalletErrorCode, err) + } + + logutil.LogDebug(logger, CommandName, RemoveMethod, logSuccess, + logutil.CreateKeyValueString(logUserIDKey, request.UserID)) + + return nil +} + +// Get returns wallet content by ID from wallet content store. +func (o *Command) Get(rw io.Writer, req io.Reader) command.Error { + request := &GetContentRequest{} + + err := json.NewDecoder(req).Decode(&request) + if err != nil { + logutil.LogInfo(logger, CommandName, GetMethod, err.Error()) + + return command.NewValidationError(InvalidRequestErrorCode, err) + } + + vcWallet, err := wallet.New(request.UserID, o.ctx) + if err != nil { + logutil.LogInfo(logger, CommandName, GetMethod, err.Error()) + + return command.NewExecuteError(GetFromWalletErrorCode, err) + } + + content, err := vcWallet.Get(request.Auth, request.ContentType, request.ContentID) + if err != nil { + logutil.LogInfo(logger, CommandName, GetMethod, err.Error()) + + return command.NewExecuteError(GetFromWalletErrorCode, err) + } + + command.WriteNillableResponse(rw, GetContentResponse{Content: content}, logger) + + logutil.LogDebug(logger, CommandName, GetMethod, logSuccess, + logutil.CreateKeyValueString(logUserIDKey, request.UserID)) + + return nil +} + +// GetAll gets all wallet content from wallet content store for given type. +func (o *Command) GetAll(rw io.Writer, req io.Reader) command.Error { + request := &GetAllContentRequest{} + + err := json.NewDecoder(req).Decode(&request) + if err != nil { + logutil.LogInfo(logger, CommandName, GetAllMethod, err.Error()) + + return command.NewValidationError(InvalidRequestErrorCode, err) + } + + vcWallet, err := wallet.New(request.UserID, o.ctx) + if err != nil { + logutil.LogInfo(logger, CommandName, GetAllMethod, err.Error()) + + return command.NewExecuteError(GetAllFromWalletErrorCode, err) + } + + contents, err := vcWallet.GetAll(request.Auth, request.ContentType) + if err != nil { + logutil.LogInfo(logger, CommandName, GetAllMethod, err.Error()) + + return command.NewExecuteError(GetAllFromWalletErrorCode, err) + } + + command.WriteNillableResponse(rw, GetAllContentResponse{Contents: contents}, logger) + + logutil.LogDebug(logger, CommandName, GetAllMethod, logSuccess, + logutil.CreateKeyValueString(logUserIDKey, request.UserID)) + + return nil +} + +// Query runs credential queries against wallet credential contents and +// returns presentation containing credential results. +func (o *Command) Query(rw io.Writer, req io.Reader) command.Error { + request := &ContentQueryRequest{} + + err := json.NewDecoder(req).Decode(&request) + if err != nil { + logutil.LogInfo(logger, CommandName, GetAllMethod, err.Error()) + + return command.NewValidationError(InvalidRequestErrorCode, err) + } + + vcWallet, err := wallet.New(request.UserID, o.ctx) + if err != nil { + logutil.LogInfo(logger, CommandName, GetAllMethod, err.Error()) + + return command.NewExecuteError(QueryWalletErrorCode, err) + } + + presentations, err := vcWallet.Query(request.Auth, request.Query...) + if err != nil { + logutil.LogInfo(logger, CommandName, GetAllMethod, err.Error()) + + return command.NewExecuteError(QueryWalletErrorCode, err) + } + + command.WriteNillableResponse(rw, &ContentQueryResponse{Results: presentations}, logger) + + logutil.LogDebug(logger, CommandName, GetAllMethod, logSuccess, + logutil.CreateKeyValueString(logUserIDKey, request.UserID)) return nil } @@ -224,6 +422,7 @@ func prepareProfileOptions(rqst *CreateOrUpdateProfileRequest) []wallet.ProfileO return options } +// prepareUnlockOptions prepares options for unlocking wallet. func prepareUnlockOptions(rqst *UnlockWalletRquest) []wallet.UnlockOptions { var options []wallet.UnlockOptions diff --git a/pkg/controller/command/vcwallet/command_test.go b/pkg/controller/command/vcwallet/command_test.go index bf99996a23..48ef1a738c 100644 --- a/pkg/controller/command/vcwallet/command_test.go +++ b/pkg/controller/command/vcwallet/command_test.go @@ -10,19 +10,26 @@ import ( "bytes" "encoding/json" "errors" + "fmt" "io" + "strings" "testing" "github.com/google/uuid" "github.com/stretchr/testify/require" "github.com/hyperledger/aries-framework-go/pkg/controller/command" + "github.com/hyperledger/aries-framework-go/pkg/doc/did" + vdrapi "github.com/hyperledger/aries-framework-go/pkg/framework/aries/api/vdr" "github.com/hyperledger/aries-framework-go/pkg/internal/jsonldtest" mockprovider "github.com/hyperledger/aries-framework-go/pkg/mock/provider" mockstorage "github.com/hyperledger/aries-framework-go/pkg/mock/storage" + mockvdr "github.com/hyperledger/aries-framework-go/pkg/mock/vdr" + "github.com/hyperledger/aries-framework-go/pkg/vdr/key" "github.com/hyperledger/aries-framework-go/pkg/wallet" ) +// nolint: lll const ( sampleUserID = "sample-user01" samplePassPhrase = "fakepassphrase" @@ -33,6 +40,117 @@ const ( sampleEDVMacKID = "sample-edv-mac-kid" sampleCommandError = "sample-command-error-01" sampleFakeTkn = "sample-fake-token-01" + sampleUDCVC = `{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "credentialSchema": [], + "credentialSubject": { + "degree": { + "type": "BachelorDegree", + "university": "MIT" + }, + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "name": "Jayden Doe", + "spouse": "did:example:c276e12ec21ebfeb1f712ebc6f1" + }, + "expirationDate": "2020-01-01T19:23:24Z", + "id": "http://example.edu/credentials/1877", + "issuanceDate": "2010-01-01T19:23:24Z", + "issuer": { + "id": "did:example:76e12ec712ebc6f1c221ebfeb1f", + "name": "Example University" + }, + "referenceNumber": 83294847, + "type": [ + "VerifiableCredential", + "UniversityDegreeCredential" + ] + }` + sampleMetadata = `{ + "@context": ["https://w3id.org/wallet/v1"], + "id": "urn:uuid:2905324a-9524-11ea-bb37-0242ac130002", + "type": "Metadata", + "name": "Ropsten Testnet HD Accounts", + "image": "https://via.placeholder.com/150", + "description": "My Ethereum TestNet Accounts", + "tags": ["professional", "organization"], + "correlation": ["urn:uuid:4058a72a-9523-11ea-bb37-0242ac130002"], + "hdPath": "m’/44’/60’/0’", + "target": ["urn:uuid:c410e44a-9525-11ea-bb37-0242ac130002"] + }` + sampleBBSVC = `{ + "@context": ["https://www.w3.org/2018/credentials/v1", "https://www.w3.org/2018/credentials/examples/v1", "https://w3id.org/security/bbs/v1"], + "credentialSubject": { + "degree": {"type": "BachelorDegree", "university": "MIT"}, + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21", + "name": "Jayden Doe", + "spouse": "did:example:c276e12ec21ebfeb1f712ebc6f1" + }, + "expirationDate": "2020-01-01T19:23:24Z", + "id": "http://example.edu/credentials/1872", + "issuanceDate": "2010-01-01T19:23:24Z", + "issuer": {"id": "did:example:76e12ec712ebc6f1c221ebfeb1f", "name": "Example University"}, + "proof": { + "created": "2021-03-29T13:27:36.483097-04:00", + "proofPurpose": "assertionMethod", + "proofValue": "rw7FeV6K1wimnYogF9qd-N0zmq5QlaIoszg64HciTca-mK_WU4E1jIusKTT6EnN2GZz04NVPBIw4yhc0kTwIZ07etMvfWUlHt_KMoy2CfTw8FBhrf66q4h7Qcqxh_Kxp6yCHyB4A-MmURlKKb8o-4w", + "type": "BbsBlsSignature2020", + "verificationMethod": "did:key:zUC72c7u4BYVmfYinDceXkNAwzPEyuEE23kUmJDjLy8495KH3pjLwFhae1Fww9qxxRdLnS2VNNwni6W3KbYZKsicDtiNNEp76fYWR6HCD8jAz6ihwmLRjcHH6kB294Xfg1SL1qQ#zUC72c7u4BYVmfYinDceXkNAwzPEyuEE23kUmJDjLy8495KH3pjLwFhae1Fww9qxxRdLnS2VNNwni6W3KbYZKsicDtiNNEp76fYWR6HCD8jAz6ihwmLRjcHH6kB294Xfg1SL1qQ" + }, + "referenceNumber": 83294847, + "type": ["VerifiableCredential", "UniversityDegreeCredential"] + }` + sampleQueryByExample = `{ + "reason": "Please present your identity document.", + "example": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://www.w3.org/2018/credentials/examples/v1" + ], + "type": ["UniversityDegreeCredential"], + "trustedIssuer": [ + { + "issuer": "urn:some:required:issuer" + }, + { + "required": true, + "issuer": "did:example:76e12ec712ebc6f1c221ebfeb1f" + } + ], + "credentialSubject": { + "id": "did:example:ebfeb1f712ebc6f1c276e12ec21" + } + } + }` + sampleQueryByFrame = `{ + "reason": "Please provide your Passport details.", + "frame": { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://w3id.org/citizenship/v1", + "https://w3id.org/security/bbs/v1" + ], + "type": ["VerifiableCredential", "PermanentResidentCard"], + "@explicit": true, + "identifier": {}, + "issuer": {}, + "issuanceDate": {}, + "credentialSubject": { + "@explicit": true, + "name": {}, + "spouse": {} + } + }, + "trustedIssuer": [ + { + "issuer": "did:example:76e12ec712ebc6f1c221ebfeb1f", + "required": true + } + ], + "required": true + }` ) func TestNew(t *testing.T) { @@ -41,7 +159,7 @@ func TestNew(t *testing.T) { require.NotNil(t, cmd) handlers := cmd.GetHandlers() - require.Equal(t, 4, len(handlers)) + require.Equal(t, 9, len(handlers)) }) } @@ -473,6 +591,416 @@ func TestCommand_OpenAndClose(t *testing.T) { }) } +func TestCommand_AddRemoveGetGetAll(t *testing.T) { + const ( + sampleUser1 = "sample-user-01" + sampleUser2 = "sample-user-02" + sampleUser3 = "sample-user-03" + ) + + mockctx := newMockProvider(t) + + createSampleUserProfile(t, mockctx, &CreateOrUpdateProfileRequest{ + UserID: sampleUser1, + LocalKMSPassphrase: samplePassPhrase, + }) + + token1, lock1 := unlockWallet(t, mockctx, &UnlockWalletRquest{ + UserID: sampleUser1, + LocalKMSPassphrase: samplePassPhrase, + }) + + defer lock1() + + createSampleUserProfile(t, mockctx, &CreateOrUpdateProfileRequest{ + UserID: sampleUser2, + KeyStoreURL: sampleKeyStoreURL, + }) + + token2, lock2 := unlockWallet(t, mockctx, &UnlockWalletRquest{ + UserID: sampleUser2, + WebKMSAuth: sampleFakeTkn, + }) + + defer lock2() + + t.Run("add a credential to wallet", func(t *testing.T) { + cmd := New(mockctx) + + var b bytes.Buffer + + cmdErr := cmd.Add(&b, getReader(t, &AddContentRequest{ + UserID: sampleUser1, + Content: []byte(sampleUDCVC), + ContentType: "credential", + WalletAuth: WalletAuth{Auth: token1}, + })) + require.NoError(t, cmdErr) + }) + + t.Run("add a metadata to wallet", func(t *testing.T) { + cmd := New(mockctx) + + var b bytes.Buffer + + cmdErr := cmd.Add(&b, getReader(t, &AddContentRequest{ + UserID: sampleUser1, + Content: []byte(sampleMetadata), + ContentType: "metadata", + WalletAuth: WalletAuth{Auth: token1}, + })) + require.NoError(t, cmdErr) + }) + + t.Run("get a credential from wallet", func(t *testing.T) { + cmd := New(mockctx) + + var b bytes.Buffer + + cmdErr := cmd.Get(&b, getReader(t, &GetContentRequest{ + UserID: sampleUser1, + ContentID: "http://example.edu/credentials/1877", + ContentType: "credential", + WalletAuth: WalletAuth{Auth: token1}, + })) + require.NoError(t, cmdErr) + + var response GetContentResponse + require.NoError(t, json.NewDecoder(&b).Decode(&response)) + require.NotEmpty(t, response) + require.NotEmpty(t, response.Content) + }) + + t.Run("get all credentials from wallet", func(t *testing.T) { + cmd := New(mockctx) + + // save multiple credentials, one already saved + const count = 6 + for i := 1; i < count; i++ { + var b bytes.Buffer + cmdErr := cmd.Add(&b, getReader(t, &AddContentRequest{ + UserID: sampleUser1, + Content: []byte(strings.ReplaceAll(sampleUDCVC, `"http://example.edu/credentials/1877"`, + fmt.Sprintf(`"http://example.edu/credentials/1872%d"`, i))), + ContentType: "credential", + WalletAuth: WalletAuth{Auth: token1}, + })) + require.NoError(t, cmdErr) + + b.Reset() + } + + var b bytes.Buffer + + cmdErr := cmd.GetAll(&b, getReader(t, &GetAllContentRequest{ + UserID: sampleUser1, + ContentType: "credential", + WalletAuth: WalletAuth{Auth: token1}, + })) + require.NoError(t, cmdErr) + + var response GetAllContentResponse + require.NoError(t, json.NewDecoder(&b).Decode(&response)) + require.NotEmpty(t, response) + require.Len(t, response.Contents, count) + }) + + t.Run("remove a credential from wallet", func(t *testing.T) { + cmd := New(mockctx) + + var b bytes.Buffer + + cmdErr := cmd.Remove(&b, getReader(t, &RemoveContentRequest{ + UserID: sampleUser1, + ContentID: "http://example.edu/credentials/1877", + ContentType: "credential", + WalletAuth: WalletAuth{Auth: token1}, + })) + require.NoError(t, cmdErr) + }) + + t.Run("get a credential from different wallet", func(t *testing.T) { + cmd := New(mockctx) + + var b bytes.Buffer + + cmdErr := cmd.Get(&b, getReader(t, &GetContentRequest{ + UserID: sampleUser2, + ContentID: "http://example.edu/credentials/1877", + ContentType: "credential", + WalletAuth: WalletAuth{Auth: token2}, + })) + validateError(t, cmdErr, command.ExecuteError, GetFromWalletErrorCode, "data not found") + }) + + t.Run("try content operations from invalid auth", func(t *testing.T) { + cmd := New(mockctx) + + var b bytes.Buffer + + const expectedErr = "invalid auth token" + + cmdErr := cmd.Add(&b, getReader(t, &AddContentRequest{ + UserID: sampleUser1, + Content: []byte(sampleUDCVC), + ContentType: "credential", + WalletAuth: WalletAuth{Auth: sampleFakeTkn}, + })) + validateError(t, cmdErr, command.ExecuteError, AddToWalletErrorCode, expectedErr) + b.Reset() + + cmdErr = cmd.Get(&b, getReader(t, &GetContentRequest{ + UserID: sampleUser1, + ContentID: "http://example.edu/credentials/1877", + ContentType: "credential", + WalletAuth: WalletAuth{Auth: sampleFakeTkn}, + })) + validateError(t, cmdErr, command.ExecuteError, GetFromWalletErrorCode, expectedErr) + + cmdErr = cmd.GetAll(&b, getReader(t, &GetAllContentRequest{ + UserID: sampleUser1, + ContentType: "credential", + WalletAuth: WalletAuth{Auth: sampleFakeTkn}, + })) + validateError(t, cmdErr, command.ExecuteError, GetAllFromWalletErrorCode, expectedErr) + + cmdErr = cmd.Remove(&b, getReader(t, &RemoveContentRequest{ + UserID: sampleUser1, + ContentID: "http://example.edu/credentials/1877", + ContentType: "credential", + WalletAuth: WalletAuth{Auth: sampleFakeTkn}, + })) + validateError(t, cmdErr, command.ExecuteError, RemoveFromWalletErrorCode, expectedErr) + }) + + t.Run("try content operations from invalid content type", func(t *testing.T) { + cmd := New(mockctx) + + var b bytes.Buffer + + cmdErr := cmd.Add(&b, getReader(t, &AddContentRequest{ + UserID: sampleUser1, + Content: []byte(sampleUDCVC), + ContentType: "mango", + WalletAuth: WalletAuth{Auth: token1}, + })) + validateError(t, cmdErr, command.ExecuteError, AddToWalletErrorCode, "invalid content type") + b.Reset() + + cmdErr = cmd.Get(&b, getReader(t, &GetContentRequest{ + UserID: sampleUser1, + ContentID: "http://example.edu/credentials/1877", + ContentType: "pineapple", + WalletAuth: WalletAuth{Auth: token1}, + })) + validateError(t, cmdErr, command.ExecuteError, GetFromWalletErrorCode, "data not found") + + cmdErr = cmd.GetAll(&b, getReader(t, &GetAllContentRequest{ + UserID: sampleUser1, + ContentType: "orange", + WalletAuth: WalletAuth{Auth: token1}, + })) + require.NoError(t, cmdErr) + + var response GetAllContentResponse + require.NoError(t, json.NewDecoder(&b).Decode(&response)) + require.Empty(t, response.Contents) + b.Reset() + + cmdErr = cmd.Remove(&b, getReader(t, &RemoveContentRequest{ + UserID: sampleUser1, + ContentID: "http://example.edu/credentials/1877", + ContentType: "strawberry", + WalletAuth: WalletAuth{Auth: token1}, + })) + require.NoError(t, cmdErr) + }) + + t.Run("try content operations from invalid profile", func(t *testing.T) { + cmd := New(mockctx) + + var b bytes.Buffer + + const expectedErr = "profile does not exist" + + cmdErr := cmd.Add(&b, getReader(t, &AddContentRequest{ + UserID: sampleUser3, + Content: []byte(sampleUDCVC), + ContentType: "credential", + WalletAuth: WalletAuth{Auth: sampleFakeTkn}, + })) + validateError(t, cmdErr, command.ExecuteError, AddToWalletErrorCode, expectedErr) + b.Reset() + + cmdErr = cmd.Get(&b, getReader(t, &GetContentRequest{ + UserID: sampleUser3, + ContentID: "http://example.edu/credentials/1877", + ContentType: "credential", + WalletAuth: WalletAuth{Auth: sampleFakeTkn}, + })) + validateError(t, cmdErr, command.ExecuteError, GetFromWalletErrorCode, expectedErr) + + cmdErr = cmd.GetAll(&b, getReader(t, &GetAllContentRequest{ + UserID: sampleUser3, + ContentType: "credential", + WalletAuth: WalletAuth{Auth: sampleFakeTkn}, + })) + validateError(t, cmdErr, command.ExecuteError, GetAllFromWalletErrorCode, expectedErr) + + cmdErr = cmd.Remove(&b, getReader(t, &RemoveContentRequest{ + UserID: sampleUser3, + ContentID: "http://example.edu/credentials/1877", + ContentType: "credential", + WalletAuth: WalletAuth{Auth: sampleFakeTkn}, + })) + validateError(t, cmdErr, command.ExecuteError, RemoveFromWalletErrorCode, expectedErr) + }) + + t.Run("try content operations from invalid request", func(t *testing.T) { + cmd := New(mockctx) + + var b bytes.Buffer + + const expectedErr = "invalid character" + + cmdErr := cmd.Add(&b, bytes.NewBufferString("invalid request")) + validateError(t, cmdErr, command.ValidationError, InvalidRequestErrorCode, expectedErr) + b.Reset() + + cmdErr = cmd.Get(&b, bytes.NewBufferString("invalid request")) + validateError(t, cmdErr, command.ValidationError, InvalidRequestErrorCode, expectedErr) + + cmdErr = cmd.GetAll(&b, bytes.NewBufferString("invalid request")) + validateError(t, cmdErr, command.ValidationError, InvalidRequestErrorCode, expectedErr) + + cmdErr = cmd.Remove(&b, bytes.NewBufferString("invalid request")) + validateError(t, cmdErr, command.ValidationError, InvalidRequestErrorCode, expectedErr) + }) +} + +func TestCommand_Query(t *testing.T) { + const sampleUser1 = "sample-user-01" + + mockctx := newMockProvider(t) + mockctx.VDRegistryValue = getMockDIDKeyVDR() + + createSampleUserProfile(t, mockctx, &CreateOrUpdateProfileRequest{ + UserID: sampleUser1, + LocalKMSPassphrase: samplePassPhrase, + }) + + token, lock := unlockWallet(t, mockctx, &UnlockWalletRquest{ + UserID: sampleUser1, + LocalKMSPassphrase: samplePassPhrase, + }) + + defer lock() + + addContent(t, mockctx, &AddContentRequest{ + UserID: sampleUser1, + Content: []byte(sampleUDCVC), + ContentType: "credential", + WalletAuth: WalletAuth{Auth: token}, + }) + + addContent(t, mockctx, &AddContentRequest{ + UserID: sampleUser1, + Content: []byte(sampleBBSVC), + ContentType: "credential", + WalletAuth: WalletAuth{Auth: token}, + }) + + t.Run("successfully query credentials", func(t *testing.T) { + cmd := New(mockctx) + + var b bytes.Buffer + + cmdErr := cmd.Query(&b, getReader(t, &ContentQueryRequest{ + UserID: sampleUser1, + Query: []*wallet.QueryParams{ + { + Type: "QueryByExample", + Query: []json.RawMessage{[]byte(sampleQueryByExample)}, + }, + { + Type: "QueryByFrame", + Query: []json.RawMessage{[]byte(sampleQueryByFrame)}, + }, + }, + WalletAuth: WalletAuth{Auth: token}, + })) + require.NoError(t, cmdErr) + + var response map[string]interface{} + require.NoError(t, json.NewDecoder(&b).Decode(&response)) + require.NotEmpty(t, response) + require.NotEmpty(t, response["results"]) + }) + + t.Run("query credentials with invalid auth", func(t *testing.T) { + cmd := New(mockctx) + + var b bytes.Buffer + + cmdErr := cmd.Query(&b, getReader(t, &ContentQueryRequest{ + UserID: sampleUser1, + Query: []*wallet.QueryParams{ + { + Type: "QueryByFrame", + Query: []json.RawMessage{[]byte(sampleQueryByFrame)}, + }, + }, + WalletAuth: WalletAuth{Auth: sampleFakeTkn}, + })) + validateError(t, cmdErr, command.ExecuteError, QueryWalletErrorCode, "invalid auth token") + }) + + t.Run("query credentials with invalid wallet profile", func(t *testing.T) { + cmd := New(mockctx) + + var b bytes.Buffer + + cmdErr := cmd.Query(&b, getReader(t, &ContentQueryRequest{ + UserID: sampleUserID, + Query: []*wallet.QueryParams{ + { + Type: "QueryByFrame", + Query: []json.RawMessage{[]byte(sampleQueryByFrame)}, + }, + }, + WalletAuth: WalletAuth{Auth: sampleFakeTkn}, + })) + validateError(t, cmdErr, command.ExecuteError, QueryWalletErrorCode, "profile does not exist") + }) + + t.Run("query credentials with invalid query type", func(t *testing.T) { + cmd := New(mockctx) + + var b bytes.Buffer + + cmdErr := cmd.Query(&b, getReader(t, &ContentQueryRequest{ + UserID: sampleUser1, + Query: []*wallet.QueryParams{ + { + Type: "QueryByOrange", + Query: []json.RawMessage{[]byte(sampleQueryByFrame)}, + }, + }, + WalletAuth: WalletAuth{Auth: token}, + })) + validateError(t, cmdErr, command.ExecuteError, QueryWalletErrorCode, "unsupported query type") + }) + + t.Run("query credentials with invalid request", func(t *testing.T) { + cmd := New(mockctx) + + var b bytes.Buffer + + cmdErr := cmd.Query(&b, bytes.NewBufferString("--")) + validateError(t, cmdErr, command.ValidationError, InvalidRequestErrorCode, "invalid character") + }) +} + func createSampleUserProfile(t *testing.T, ctx *mockprovider.Provider, request *CreateOrUpdateProfileRequest) { cmd := New(ctx) require.NotNil(t, cmd) @@ -497,6 +1025,32 @@ func getUnlockToken(t *testing.T, b bytes.Buffer) string { return response.Token } +func unlockWallet(t *testing.T, ctx *mockprovider.Provider, request *UnlockWalletRquest) (string, func()) { + cmd := New(ctx) + + var b bytes.Buffer + + cmdErr := cmd.Open(&b, getReader(t, &request)) + require.NoError(t, cmdErr) + + return getUnlockToken(t, b), func() { + cmdErr = cmd.Close(&b, getReader(t, &LockWalletRequest{UserID: request.UserID})) + if cmdErr != nil { + t.Log(t, cmdErr) + } + } +} + +func addContent(t *testing.T, ctx *mockprovider.Provider, request *AddContentRequest) { + cmd := New(ctx) + + var b bytes.Buffer + defer b.Reset() + + cmdErr := cmd.Add(&b, getReader(t, &request)) + require.NoError(t, cmdErr) +} + func validateError(t *testing.T, err command.Error, expectedType command.Type, expectedCode command.Code, contains string) { require.Error(t, err) @@ -519,3 +1073,22 @@ func newMockProvider(t *testing.T) *mockprovider.Provider { JSONLDDocumentLoaderValue: loader, } } + +func getMockDIDKeyVDR() *mockvdr.MockVDRegistry { + return &mockvdr.MockVDRegistry{ + ResolveFunc: func(didID string, opts ...vdrapi.DIDMethodOption) (*did.DocResolution, error) { + if strings.HasPrefix(didID, "did:key:") { + k := key.New() + + d, e := k.Read(didID) + if e != nil { + return nil, e + } + + return d, nil + } + + return nil, fmt.Errorf("did not found") + }, + } +} diff --git a/pkg/controller/command/vcwallet/models.go b/pkg/controller/command/vcwallet/models.go index a85a975c79..b2225ccb33 100644 --- a/pkg/controller/command/vcwallet/models.go +++ b/pkg/controller/command/vcwallet/models.go @@ -6,6 +6,13 @@ SPDX-License-Identifier: Apache-2.0 package vcwallet +import ( + "encoding/json" + + "github.com/hyperledger/aries-framework-go/pkg/doc/verifiable" + "github.com/hyperledger/aries-framework-go/pkg/wallet" +) + // CreateOrUpdateProfileRequest is request model for // creating a new wallet profile or updating an existing wallet profile. type CreateOrUpdateProfileRequest struct { @@ -91,3 +98,100 @@ type LockWalletResponse struct { // if false, wallet is already closed or never unlocked. Closed bool `json:"userID"` } + +// WalletAuth contains wallet auth parameters for performing wallet operations. +type WalletAuth struct { + // Authorization token for performing wallet operations. + Auth string `json:"auth"` +} + +// AddContentRequest is request for adding a content to wallet. +type AddContentRequest struct { + WalletAuth + + // ID of wallet user. + UserID string `json:"userID"` + + // type of the content to be added to the wallet. + // supported types: collection, credential, didResolutionResponse, metadata, connection, key + ContentType wallet.ContentType `json:"contentType"` + + // content to be added to wallet content store. + Content json.RawMessage `json:"content"` + + // ID of the wallet collection to which this content should belong. + CollectionID string `json:"collectionID"` +} + +// RemoveContentRequest is request for removing a content from wallet. +type RemoveContentRequest struct { + WalletAuth + + // ID of wallet user. + UserID string `json:"userID"` + + // type of the content to be removed from the wallet. + // supported types: collection, credential, didResolutionResponse, metadata, connection + ContentType wallet.ContentType `json:"contentType"` + + // ID of the content to be removed from wallet + ContentID string `json:"contentID"` +} + +// GetContentRequest is request for getting a content from wallet. +type GetContentRequest struct { + WalletAuth + + // ID of wallet user. + UserID string `json:"userID"` + + // type of the content to be returned from wallet. + // supported types: collection, credential, didResolutionResponse, metadata, connection + ContentType wallet.ContentType `json:"contentType"` + + // ID of the content to be returned from wallet + ContentID string `json:"contentID"` +} + +// GetContentResponse response for get content from wallet operation. +type GetContentResponse struct { + // content retrieved from wallet content store. + Content json.RawMessage `json:"content"` +} + +// GetAllContentRequest is request for getting all contents from wallet for given content type. +type GetAllContentRequest struct { + WalletAuth + + // ID of wallet user. + UserID string `json:"userID"` + + // type of the contents to be returned from wallet. + // supported types: collection, credential, didResolutionResponse, metadata, connection + ContentType wallet.ContentType `json:"contentType"` +} + +// GetAllContentResponse response for get all content by content type wallet operation. +type GetAllContentResponse struct { + // contents retrieved from wallet content store. + // map of content ID to content. + Contents map[string]json.RawMessage `json:"contents"` +} + +// ContentQueryRequest is request model for querying wallet contents. +type ContentQueryRequest struct { + WalletAuth + + // ID of wallet user. + UserID string `json:"userID"` + + // credential query(s) for querying wallet contents. + Query []*wallet.QueryParams `json:"query"` +} + +// ContentQueryResponse response for wallet content query. +type ContentQueryResponse struct { + // contents retrieved from wallet content store. + // map of content ID to content. + Results []*verifiable.Presentation `json:"results"` +} diff --git a/pkg/wallet/contents.go b/pkg/wallet/contents.go index 80f6198661..77c7e98fb9 100644 --- a/pkg/wallet/contents.go +++ b/pkg/wallet/contents.go @@ -15,6 +15,9 @@ import ( "fmt" "strings" "sync" + "time" + + "github.com/bluele/gcache" "github.com/hyperledger/aries-framework-go/pkg/doc/did" "github.com/hyperledger/aries-framework-go/pkg/framework/aries/api/vdr" @@ -100,6 +103,7 @@ var ( // contentStore is store for wallet contents for given user profile. type contentStore struct { + storeID string provider *storageProvider open storeOpenHandle close storeCloseHandle @@ -109,7 +113,13 @@ type contentStore struct { // newContentStore returns new wallet content store instance. // will use underlying storage provider as content storage if profile doesn't have edv settings. func newContentStore(p storage.Provider, pr *profile) *contentStore { - return &contentStore{open: storeLocked, close: noOp, provider: newWalletStorageProvider(pr, p)} + contents := &contentStore{open: storeLocked, close: noOp, provider: newWalletStorageProvider(pr, p), storeID: pr.ID} + + if store, err := storeManager().get(pr.ID); err == nil { + contents.updateStoreHandles(store) + } + + return contents } func (cs *contentStore) Open(auth string, opts *unlockOpts) error { @@ -120,9 +130,20 @@ func (cs *contentStore) Open(auth string, opts *unlockOpts) error { return err } + // store instances needs to be cached to share unlock session between multiple instances of wallet. + if err := storeManager().persist(cs.storeID, store, opts.tokenExpiry); err != nil { + return err + } + cs.lock.Lock() defer cs.lock.Unlock() + cs.updateStoreHandles(store) + + return nil +} + +func (cs *contentStore) updateStoreHandles(store storage.Store) { // give access to store only when auth is valid & not expired. cs.open = func(auth string) (storage.Store, error) { _, err := keyManager().getKeyManger(auth) @@ -136,11 +157,9 @@ func (cs *contentStore) Open(auth string, opts *unlockOpts) error { cs.close = func() error { return store.Close() } - - return nil } -func (cs *contentStore) Close() { +func (cs *contentStore) Close() bool { cs.lock.Lock() defer cs.lock.Unlock() @@ -150,6 +169,8 @@ func (cs *contentStore) Close() { cs.open = storeLocked cs.close = noOp + + return storeManager().delete(cs.storeID) } // Save for storing given wallet content to store by content ID (content document id) & content type. @@ -454,3 +475,47 @@ func (v *walletVDR) Resolve(didID string, opts ...vdr.DIDMethodOption) (*did.Doc return v.Registry.Resolve(didID, opts...) } + +//nolint:gochecknoglobals +var ( + walletStoreInstance *walletStoreManager + walletStoreOnce sync.Once +) + +func storeManager() *walletStoreManager { + walletStoreOnce.Do(func() { + walletStoreInstance = &walletStoreManager{ + gstore: gcache.New(0).Build(), + } + }) + + return walletStoreInstance +} + +// walletStoreManager manages store instances in cache. +// this is store manager singleton - access only via storeManager() +// underlying gcache is threasafe, no need of locks. +type walletStoreManager struct { + gstore gcache.Cache +} + +func (ws *walletStoreManager) persist(id string, store storage.Store, expiration time.Duration) error { + if expiration == 0 { + expiration = defaultCacheExpiry + } + + return ws.gstore.SetWithExpire(id, store, expiration) +} + +func (ws *walletStoreManager) get(id string) (storage.Store, error) { + val, err := ws.gstore.Get(id) + if err != nil { + return nil, err + } + + return val.(storage.Store), nil +} + +func (ws *walletStoreManager) delete(id string) bool { + return ws.gstore.Remove(id) +} diff --git a/pkg/wallet/contents_test.go b/pkg/wallet/contents_test.go index bccffaa7be..2eea7a100f 100644 --- a/pkg/wallet/contents_test.go +++ b/pkg/wallet/contents_test.go @@ -201,7 +201,7 @@ func TestContentStores(t *testing.T) { []string{"collection", "credential", "connection", "didResolutionResponse", "connection", "key"}) // close store - contentStore.Close() + require.True(t, contentStore.Close()) store, err := contentStore.open(token) require.Empty(t, store) require.True(t, errors.Is(err, ErrWalletLocked)) @@ -255,7 +255,7 @@ func TestContentStores(t *testing.T) { })) // close store - contentStore.Close() + require.True(t, contentStore.Close()) store, err := contentStore.open(tkn) require.Empty(t, store) require.True(t, errors.Is(err, ErrWalletLocked)) @@ -287,7 +287,7 @@ func TestContentStores(t *testing.T) { contentStore = newContentStore(sp, &profile{ID: uuid.New().String()}) require.NoError(t, contentStore.Open(token, &unlockOpts{})) - contentStore.Close() + require.True(t, contentStore.Close()) }) t.Run("save to store - success", func(t *testing.T) { @@ -296,7 +296,7 @@ func TestContentStores(t *testing.T) { contentStore := newContentStore(sp, &profile{ID: uuid.New().String()}) require.NotEmpty(t, contentStore) - require.NoError(t, contentStore.Open(token, nil)) + require.NoError(t, contentStore.Open(token, &unlockOpts{})) err := contentStore.Save(token, Collection, []byte(sampleContentValid)) require.NoError(t, err) @@ -316,7 +316,7 @@ func TestContentStores(t *testing.T) { contentStore := newContentStore(sp, &profile{ID: uuid.New().String()}) require.NotEmpty(t, contentStore) - require.NoError(t, contentStore.Open(token, nil)) + require.NoError(t, contentStore.Open(token, &unlockOpts{})) err := contentStore.Save(token, Collection, []byte(sampleContentNoID)) require.NoError(t, err) @@ -566,7 +566,7 @@ func TestContentStores(t *testing.T) { require.Error(t, err) require.Contains(t, err.Error(), sampleContenttErr) - contentStore.Close() + require.True(t, contentStore.Close()) err = contentStore.Remove(token, "did:example:123456789abcdefghi", Collection) require.True(t, errors.Is(err, ErrWalletLocked)) }) @@ -973,7 +973,7 @@ func TestContentStore_Collections(t *testing.T) { require.Empty(t, allVcs) // wallet locked error - contentStore.Close() + require.True(t, contentStore.Close()) allVcs, err = contentStore.GetAllByCollection(token, collectionID, Credential) require.True(t, errors.Is(err, ErrWalletLocked)) require.Empty(t, allVcs) diff --git a/pkg/wallet/wallet.go b/pkg/wallet/wallet.go index f9003ccdf2..9b351b7dd2 100644 --- a/pkg/wallet/wallet.go +++ b/pkg/wallet/wallet.go @@ -262,9 +262,7 @@ func (c *Wallet) Open(options ...UnlockOptions) (string, error) { // Close expires token issued to this VC wallet, removes the key manager instance and closes wallet content store. // returns false if token is not found or already expired for this wallet user. func (c *Wallet) Close() bool { - c.contents.Close() - - return keyManager().removeKeyManager(c.userID) + return keyManager().removeKeyManager(c.userID) && c.contents.Close() } // Export produces a serialized exported wallet representation. diff --git a/pkg/wallet/wallet_test.go b/pkg/wallet/wallet_test.go index 56eed61ce7..39fda33924 100644 --- a/pkg/wallet/wallet_test.go +++ b/pkg/wallet/wallet_test.go @@ -713,6 +713,33 @@ func TestWallet_OpenClose(t *testing.T) { require.False(t, wallet.Close()) }) + t.Run("test opened wallet between multliple instances", func(t *testing.T) { + mockctx := newMockProvider(t) + err := CreateProfile(sampleUserID, mockctx, WithPassphrase(samplePassPhrase)) + require.NoError(t, err) + + wallet1, err := New(sampleUserID, mockctx) + require.NoError(t, err) + require.NotEmpty(t, wallet1) + + // get token + token, err := wallet1.Open(WithUnlockByPassphrase(samplePassPhrase), WithUnlockExpiry(500*time.Millisecond)) + require.NoError(t, err) + require.NotEmpty(t, token) + + // create new instance for same profile + wallet2, err := New(sampleUserID, mockctx) + require.NoError(t, err) + require.NotEmpty(t, wallet2) + + // no need to unlock again since token is shared + require.NoError(t, wallet2.Add(token, Metadata, []byte(sampleContentValid))) + + // close first instance + wallet1.Close() + require.Error(t, wallet2.Add(token, Metadata, []byte(sampleContentNoID))) + }) + t.Run("test open wallet failure when store open fails", func(t *testing.T) { mockctx := newMockProvider(t) err := CreateProfile(sampleUserID, mockctx, WithKeyServerURL(sampleKeyServerURL))