diff --git a/.github/workflows/vc-status.yml b/.github/workflows/vc-status.yml new file mode 100644 index 00000000..bd1b45d3 --- /dev/null +++ b/.github/workflows/vc-status.yml @@ -0,0 +1,53 @@ +# +# Copyright Gen Digital Inc. All Rights Reserved. +# +# SPDX-License-Identifier: Apache-2.0 +# +name: vc-status +on: + push: + paths: + - 'component/vc/status/**' + pull_request: + paths: + - 'component/vc/status/**' +jobs: + linter: + name: Go linter + timeout-minutes: 10 + env: + LINT_PATH: component/vc/status + GOLANGCI_LINT_IMAGE: "golangci/golangci-lint:v1.50.0" + runs-on: ubuntu-18.04 + steps: + - uses: actions/checkout@v2 + + - name: Checks linter + timeout-minutes: 10 + run: make lint + unitTest: + name: Unit test + runs-on: ubuntu-18.04 + timeout-minutes: 15 + env: + UNIT_TESTS_PATH: component/vc/status + steps: + - name: Setup Go 1.19 + uses: actions/setup-go@v2 + with: + go-version: 1.19 + id: go + + - uses: actions/checkout@v2 + + - name: Run unit test + timeout-minutes: 15 + run: make unit-test + + - name: Upload coverage to Codecov + timeout-minutes: 10 + if: github.repository == 'hyperledger/aries-framework-go-ext' + uses: codecov/codecov-action@v1.0.14 + with: + file: ./coverage.out + diff --git a/component/vc/status/.custom_golangci.yml b/component/vc/status/.custom_golangci.yml new file mode 100644 index 00000000..5fdf287f --- /dev/null +++ b/component/vc/status/.custom_golangci.yml @@ -0,0 +1,109 @@ +# +# Copyright Gen Digital Inc. All Rights Reserved. +# +# SPDX-License-Identifier: Apache-2.0 +# + +run: + concurrency: 4 + deadline: 3m + issues-exit-code: 1 + tests: true + build-tags: [""] + skip-dirs: [""] + +output: + format: colored-line-number + print-issued-lines: true + print-linter-name: true + +linters-settings: + errcheck: + check-type-assertions: true + check-blank: true + govet: + check-shadowing: true + gofmt: + simplify: true + goimports: + local-prefixes: github.com/hyperledger/aries-framework-go-ext + gci: + sections: + - standard + - default + - prefix(github.com/hyperledger/aries-framework-go-ext) + - dot + gocyclo: + min-complexity: 10 + dupl: + threshold: 500 + goconst: + min-len: 3 + min-occurrences: 3 + misspell: + # default locale is a neutral variety of English. + locale: + ignore-words: [] + lll: + line-length: 120 + tab-width: 1 + unused: + check-exported: false + unparam: + check-exported: false + nakedret: + max-func-lines: 0 + gocritic: + enabled-tags: + - diagnostic + - performance + - style + - opinionated + disabled-checks: + - whyNoLint # TODO enable. + funlen: + lines: 60 + statements: 40 + wsl: + strict-append: true + allow-assign-and-call: true + allow-multiline-assign: true + allow-case-traling-whitespace: true + allow-cuddle-declarations: false + godot: + check-all: false + gomoddirectives: + replace-local: true + +linters: + enable-all: true + disable: + - goerr113 + - paralleltest + - exhaustivestruct + - exhaustruct + - interfacer # deprecated by the author https://github.com/mvdan/interfacer#interfacer + - scopelint # deprecated by the author https://github.com/kyoh86/scopelint#obsoleted + - maligned # deprecated by the author https://github.com/mdempsky/maligned + - cyclop # TODO consider replacing gocyclo with cyclop + - ifshort # TODO enable + - wrapcheck # TODO enable + - forbidigo # TODO enable + - typecheck + +issues: + exclude-use-default: false + exclude-rules: + - path: _test\.go + linters: + - dupl + - funlen + - gomnd + - path: / + linters: + - exhaustruct + - typecheck + + exclude: + - Line contains TODO/BUG/FIXME + - unnamedResult diff --git a/component/vc/status/go.mod b/component/vc/status/go.mod index d15881fe..7e7b870b 100644 --- a/component/vc/status/go.mod +++ b/component/vc/status/go.mod @@ -6,6 +6,7 @@ module github.com/hyperledger/aries-framework-go-ext/component/vc/status go 1.19 require ( + github.com/google/uuid v1.3.0 github.com/hyperledger/aries-framework-go v0.1.10-0.20230307184157-877172747719 github.com/stretchr/testify v1.8.1 ) @@ -20,7 +21,6 @@ require ( github.com/golang/protobuf v1.5.2 // indirect github.com/golang/snappy v0.0.4 // indirect github.com/google/tink/go v1.7.0 // indirect - github.com/google/uuid v1.3.0 // indirect github.com/hyperledger/aries-framework-go/spi v0.0.0-20221025204933-b807371b6f1e // indirect github.com/hyperledger/ursa-wrapper-go v0.3.1 // indirect github.com/kilic/bls12-381 v0.1.1-0.20210503002446-7b7597926c69 // indirect diff --git a/component/vc/status/internal/bitstring/bitstring.go b/component/vc/status/internal/bitstring/bitstring.go index 5593c4b0..cb35e81d 100644 --- a/component/vc/status/internal/bitstring/bitstring.go +++ b/component/vc/status/internal/bitstring/bitstring.go @@ -29,13 +29,13 @@ func Decode(src string) ([]byte, error) { b := bytes.NewReader(decodedBits) - r, err := gzip.NewReader(b) + zipReader, err := gzip.NewReader(b) if err != nil { return nil, err } buf := new(bytes.Buffer) - if _, err := buf.ReadFrom(r); err != nil { + if _, err := buf.ReadFrom(zipReader); err != nil { return nil, err } diff --git a/component/vc/status/internal/identityhub/identityhub.go b/component/vc/status/internal/identityhub/identityhub.go new file mode 100644 index 00000000..6738ad96 --- /dev/null +++ b/component/vc/status/internal/identityhub/identityhub.go @@ -0,0 +1,215 @@ +/* +Copyright Gen Digital Inc. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +// Package identityhub implements a subset of Identity Hub data models, to support requesting identity hub data. +package identityhub + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/google/uuid" + "github.com/hyperledger/aries-framework-go/pkg/common/model" + "github.com/hyperledger/aries-framework-go/pkg/doc/did" +) + +// Request contains an identity hub query. +type Request struct { + RequestID string `json:"requestId"` + Target string `json:"target"` + Messages []Message `json:"messages"` +} + +// Response contains the results of an identity hub query. +type Response struct { + RequestID string `json:"requestId"` + Status *Status `json:"status"` + Replies []MessageResult `json:"replies"` +} + +// MessageResult holds a set of messages inside an identity hub response. +type MessageResult struct { + MessageID string `json:"messageId"` + Status Status `json:"status"` + Entries []Message `json:"entries,omitempty"` +} + +// Message holds a single data element inside an identity hub response. +type Message struct { + Descriptor map[string]interface{} `json:"descriptor"` + Data string `json:"data,omitempty"` +} + +// Status holds a http status code and error message, for an identity hub response or message result. +type Status struct { + Code int `json:"code"` + Message string `json:"message"` +} + +const ( + methodKey = "method" + objectIDKey = "objectId" + serviceTypeIdentityHub = "IdentityHub" +) + +// CheckStatus returns an error if this Response or any MessageResult within has a status other than http.StatusOK. +func (i Response) CheckStatus() error { + if i.Status != nil && i.Status.Code != http.StatusOK { + return fmt.Errorf( + "unexpected request level status code, got %d, message: %s", + i.Status.Code, + i.Status.Message, + ) + } + + for _, messageResult := range i.Replies { + if messageResult.Status.Code != http.StatusOK { + return fmt.Errorf( + "unexpected message level status code, got %d, message: %s", + messageResult.Status.Code, + messageResult.Status.Message, + ) + } + } + + return nil +} + +// GetMessageData returns the data for the Message with the given ID inside this Response. +func (i Response) GetMessageData(objectID string) ([]byte, error) { + for _, messageResult := range i.Replies { + for _, message := range messageResult.Entries { + objectIDReceived, ok := message.GetObjectID() + if !ok || !strings.EqualFold(objectIDReceived, objectID) { + continue + } + + messageData, err := base64.StdEncoding.DecodeString(message.Data) + if err != nil { + return nil, fmt.Errorf("unable to decode message bytes: %w", err) + } + + return messageData, nil + } + } + + return nil, fmt.Errorf("unable to get message by object ID from Response") +} + +// GetObjectID returns the objectId of the Message. +func (m Message) GetObjectID() (string, bool) { + val, ok := m.Descriptor[objectIDKey].(string) + + return val, ok +} + +// IsMethod returns true iff the Message matches the given method. +func (m Message) IsMethod(method string) bool { + v, ok := m.Descriptor[methodKey].(string) + + return ok && strings.EqualFold(v, method) +} + +// GetRequest constructs a Request with one message, for the given method, with message descriptor taken from the +// matching method in messageDescriptorData. +// Returns the object ID of the selected message, and the Request. +func GetRequest( + hubDID, messageMethod string, + messageDescriptorData []map[string]interface{}, +) (string, *Request, error) { + request := Request{ + RequestID: uuid.NewString(), + Target: hubDID, + Messages: nil, + } + + objectID, msg, err := firstValidMessage(messageMethod, messageDescriptorData) + if err != nil { + return "", nil, err + } + + request.Messages = append(request.Messages, *msg) + + return objectID, &request, nil +} + +func firstValidMessage(selectMethod string, messageDescriptors []map[string]interface{}) (string, *Message, error) { + for _, descriptor := range messageDescriptors { + msg := &Message{Descriptor: descriptor} + if !msg.IsMethod(selectMethod) { + continue + } + + objectID, hasID := msg.GetObjectID() + if !hasID { + continue + } + + return objectID, msg, nil + } + + return "", nil, fmt.Errorf("objectId is not defined, query %v", messageDescriptors) +} + +// ServiceEndpoint returns the identity hub service endpoint URI from the identity hub service of the given DID doc. +func ServiceEndpoint(doc *did.Doc) (string, error) { + var svc *did.Service + + for i := range doc.Service { + if doc.Service[i].Type == serviceTypeIdentityHub { + svc = &(doc.Service[i]) + + break + } + } + + if svc == nil { + return "", fmt.Errorf("no identity hub service supplied") + } + + switch svc.ServiceEndpoint.Type() { //nolint:exhaustive + case model.DIDCommV1, model.DIDCommV2: + serviceEndpoint, err := svc.ServiceEndpoint.URI() + if err != nil { + return "", fmt.Errorf("unable to get service endpoint URL: %w", err) + } + + return serviceEndpoint, nil + default: + return getDIDCoreServiceEndpoint(svc) + } +} + +func getDIDCoreServiceEndpoint(svc *did.Service) (string, error) { + serviceEndpoint, err := svc.ServiceEndpoint.URI() + if err == nil { + return serviceEndpoint, nil + } + + endpointBytes, err := svc.ServiceEndpoint.MarshalJSON() + if err != nil { + return "", fmt.Errorf("unable to marshal DIDCore service endpoint: %w", err) + } + + var mapped map[string]interface{} + if err = json.Unmarshal(endpointBytes, &mapped); err != nil { + return "", fmt.Errorf("unable to unmarshal DIDCore service endpoint: %w", err) + } + + for _, v := range mapped { + didCoreEndpoint := model.NewDIDCoreEndpoint(v) + + serviceEndpoint, err = didCoreEndpoint.URI() + if err == nil { + return serviceEndpoint, nil + } + } + + return "", fmt.Errorf("unable to extract DIDCore service endpoint") +} diff --git a/component/vc/status/internal/identityhub/identityhub_test.go b/component/vc/status/internal/identityhub/identityhub_test.go new file mode 100644 index 00000000..90332f32 --- /dev/null +++ b/component/vc/status/internal/identityhub/identityhub_test.go @@ -0,0 +1,372 @@ +/* +Copyright Gen Digital Inc. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package identityhub_test + +import ( + "encoding/base64" + "net/http" + "testing" + + "github.com/hyperledger/aries-framework-go/pkg/common/model" + "github.com/hyperledger/aries-framework-go/pkg/doc/did" + "github.com/stretchr/testify/require" + + . "github.com/hyperledger/aries-framework-go-ext/component/vc/status/internal/identityhub" +) + +const ( + methodKey = "method" + objectIDKey = "objectId" + serviceTypeIdentityHub = "IdentityHub" +) + +func TestResponse_CheckStatus(t *testing.T) { + t.Run("success", func(t *testing.T) { + resp := &Response{ + Status: &Status{ + Code: http.StatusOK, + }, + Replies: []MessageResult{ + { + Status: Status{ + Code: http.StatusOK, + }, + }, + }, + } + + require.NoError(t, resp.CheckStatus()) + }) + + t.Run("response status error", func(t *testing.T) { + errMsg := "foo bar error" + + resp := &Response{ + Status: &Status{ + Code: http.StatusInternalServerError, + Message: errMsg, + }, + Replies: []MessageResult{ + { + Status: Status{ + Code: http.StatusOK, + }, + }, + }, + } + + err := resp.CheckStatus() + require.Error(t, err) + require.Contains(t, err.Error(), "unexpected request level status code") + require.Contains(t, err.Error(), errMsg) + }) + + t.Run("message status error", func(t *testing.T) { + errMsg := "foo bar error" + + resp := &Response{ + Status: &Status{ + Code: http.StatusOK, + }, + Replies: []MessageResult{ + { + Status: Status{ + Code: http.StatusInternalServerError, + Message: errMsg, + }, + }, + }, + } + + err := resp.CheckStatus() + require.Error(t, err) + require.Contains(t, err.Error(), "unexpected message level status code") + require.Contains(t, err.Error(), errMsg) + }) +} + +func TestResponse_GetMessageData(t *testing.T) { + t.Run("success", func(t *testing.T) { + objectID := "object-id-value" + + data := []byte("foo bar baz") + + resp := &Response{ + Replies: []MessageResult{ + { + Status: Status{ + Code: http.StatusOK, + }, + }, + { + Status: Status{ + Code: http.StatusOK, + }, + Entries: []Message{ + { + Descriptor: map[string]interface{}{}, + }, + { + Descriptor: map[string]interface{}{ + objectIDKey: "different ID", + }, + }, + { + Descriptor: map[string]interface{}{ + objectIDKey: objectID, + }, + Data: base64.StdEncoding.EncodeToString(data), + }, + }, + }, + }, + } + + result, err := resp.GetMessageData(objectID) + require.NoError(t, err) + require.Equal(t, data, result) + }) + + t.Run("expected object ID not found", func(t *testing.T) { + resp := &Response{ + Replies: []MessageResult{ + { + Status: Status{ + Code: http.StatusOK, + }, + }, + { + Status: Status{ + Code: http.StatusOK, + }, + Entries: []Message{ + { + Descriptor: map[string]interface{}{}, + }, + { + Descriptor: map[string]interface{}{ + objectIDKey: "different ID", + }, + }, + }, + }, + }, + } + + result, err := resp.GetMessageData("expected object ID") + require.Error(t, err) + require.Nil(t, result) + require.Contains(t, err.Error(), "unable to get message by object ID from Response") + }) + + t.Run("message data is not base64 encoded", func(t *testing.T) { + objectID := "object-id-value" + + resp := &Response{ + Replies: []MessageResult{ + { + Status: Status{ + Code: http.StatusOK, + }, + Entries: []Message{ + { + Descriptor: map[string]interface{}{ + objectIDKey: objectID, + }, + Data: "!!! not base 64 !!!", + }, + }, + }, + }, + } + + result, err := resp.GetMessageData(objectID) + require.Error(t, err) + require.Nil(t, result) + require.Contains(t, err.Error(), "unable to decode message bytes") + }) +} + +func TestMessage_GetObjectID(t *testing.T) { + t.Run("success", func(t *testing.T) { + expectID := "foo" + + msg := &Message{ + Descriptor: map[string]interface{}{ + objectIDKey: expectID, + }, + } + + gotID, hasID := msg.GetObjectID() + require.True(t, hasID) + require.Equal(t, expectID, gotID) + }) + + t.Run("no id", func(t *testing.T) { + id, hasID := Message{}.GetObjectID() + require.False(t, hasID) + require.Empty(t, id) + }) +} + +func TestGetRequest(t *testing.T) { + const ( + targetDID = "did:foo:bar" + messageMethod = "custom-method" + expectObjID = "expected object id" + customFieldName = "custom-field-name" + customFieldVal = "custom-field-val" + ) + + t.Run("success", func(t *testing.T) { + objID, req, err := GetRequest(targetDID, messageMethod, []map[string]interface{}{ + { + methodKey: "wrong-method", + }, + { + // missing object ID + methodKey: messageMethod, + }, + { + objectIDKey: expectObjID, + methodKey: messageMethod, + customFieldName: customFieldVal, + }, + }) + require.NoError(t, err) + require.Equal(t, expectObjID, objID) + require.NotNil(t, req) + require.NotEmpty(t, req.RequestID) + require.Equal(t, targetDID, req.Target) + require.Len(t, req.Messages, 1) + require.Equal(t, customFieldVal, req.Messages[0].Descriptor[customFieldName]) + }) + + t.Run("no valid matching message found", func(t *testing.T) { + _, _, err := GetRequest(targetDID, messageMethod, []map[string]interface{}{ + { + objectIDKey: expectObjID, + methodKey: "wrong-method", + }, + { + // missing object ID + methodKey: messageMethod, + }, + }) + require.Error(t, err) + require.Contains(t, err.Error(), "objectId is not defined") + }) +} + +func TestServiceEndpoint(t *testing.T) { + const ( + endpointURL = "example.net/server/url/endpoint" + ) + + t.Run("success", func(t *testing.T) { + testCases := []struct { + name string + svc did.Service + }{ + { + name: "didcomm v1", + svc: did.Service{ + Type: serviceTypeIdentityHub, + ServiceEndpoint: model.NewDIDCommV1Endpoint(endpointURL), + }, + }, + { + name: "didcomm v2", + svc: did.Service{ + Type: serviceTypeIdentityHub, + ServiceEndpoint: model.NewDIDCommV2Endpoint([]model.DIDCommV2Endpoint{ + { + URI: endpointURL, + }, + }), + }, + }, + { + name: "did core", + svc: did.Service{ + Type: serviceTypeIdentityHub, + ServiceEndpoint: model.NewDIDCoreEndpoint([]string{endpointURL}), + }, + }, + { + name: "did core object", + svc: did.Service{ + Type: serviceTypeIdentityHub, + ServiceEndpoint: model.NewDIDCoreEndpoint(map[string]interface{}{ + "0": []string{endpointURL}, + }), + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + doc := &did.Doc{ + Context: []string{did.ContextV1}, + Service: []did.Service{ + testCase.svc, + }, + } + + endpoint, err := ServiceEndpoint(doc) + require.NoError(t, err) + require.Equal(t, endpointURL, endpoint) + }) + } + }) + + t.Run("fail: no identity hub service in doc", func(t *testing.T) { + _, err := ServiceEndpoint(&did.Doc{}) + require.Error(t, err) + require.Contains(t, err.Error(), "no identity hub service supplied") + }) + + t.Run("fail: did core service endpoint", func(t *testing.T) { + testCases := []struct { + name string + svc did.Service + err string + }{ + { + name: "no contents", + svc: did.Service{ + Type: serviceTypeIdentityHub, + ServiceEndpoint: model.NewDIDCoreEndpoint(map[string]interface{}{}), + }, + err: "unable to extract DIDCore service endpoint", + }, + { + name: "cannot marshal", + svc: did.Service{ + Type: serviceTypeIdentityHub, + ServiceEndpoint: model.NewDIDCoreEndpoint(new(chan int)), + }, + err: "unable to marshal DIDCore service endpoint", + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + doc := &did.Doc{ + Context: []string{did.ContextV1}, + Service: []did.Service{ + testCase.svc, + }, + } + + _, err := ServiceEndpoint(doc) + require.Error(t, err) + require.Contains(t, err.Error(), testCase.err) + }) + } + }) +} diff --git a/component/vc/status/resolver/resolver.go b/component/vc/status/resolver/resolver.go index 9e6e0e0a..619d9ebb 100644 --- a/component/vc/status/resolver/resolver.go +++ b/component/vc/status/resolver/resolver.go @@ -1,5 +1,6 @@ /* Copyright Avast Software. All Rights Reserved. +Copyright Gen Digital Inc. All Rights Reserved. SPDX-License-Identifier: Apache-2.0 */ @@ -8,26 +9,35 @@ SPDX-License-Identifier: Apache-2.0 package resolver import ( + "bytes" "context" + "encoding/base64" + "encoding/json" "fmt" "io" "net/http" + "net/url" "strings" "github.com/hyperledger/aries-framework-go/pkg/doc/verifiable" + "github.com/hyperledger/aries-framework-go/pkg/framework/aries/api/vdr" + + "github.com/hyperledger/aries-framework-go-ext/component/vc/status/internal/identityhub" ) // Resolver resolves credential status list VCs. type Resolver struct { client *http.Client bearerToken string + didResolver vdr.Registry } // NewResolver creates a Resolver. -func NewResolver(client *http.Client, bearerToken string) *Resolver { +func NewResolver(client *http.Client, didResolver vdr.Registry, bearerToken string) *Resolver { return &Resolver{ client: client, bearerToken: bearerToken, + didResolver: didResolver, } } @@ -39,22 +49,25 @@ func (r *Resolver) Resolve(statusListVCURI string) (*verifiable.Credential, erro ) if strings.HasPrefix(statusListVCURI, "did:") { - return nil, fmt.Errorf("did-uri status list VC resolution not supported") - } - - req, e := http.NewRequestWithContext(context.Background(), http.MethodGet, statusListVCURI, nil) - if e != nil { - return nil, e - } + vcBytes, err = r.resolveDIDRelativeURL(statusListVCURI) + if err != nil { + return nil, fmt.Errorf("failed to resolve status VC DID URI: %w", err) + } + } else { + req, e := http.NewRequestWithContext(context.Background(), http.MethodGet, statusListVCURI, http.NoBody) + if e != nil { + return nil, e + } - vcBytes, err = r.sendHTTPRequest(req, http.StatusOK, r.bearerToken) + vcBytes, err = r.sendHTTPRequest(req, http.StatusOK, r.bearerToken) - if err != nil { - return nil, fmt.Errorf("unable to resolve statusListVCURI: %w", err) + if err != nil { + return nil, fmt.Errorf("unable to resolve statusListVCURI: %w", err) + } } // TODO: need to verify proof on vc - consider if validation also needs to be done (json-ld and json schema) - vc, err := verifiable.ParseCredential( + cred, err := verifiable.ParseCredential( vcBytes, verifiable.WithDisabledProofCheck(), verifiable.WithCredDisableValidation(), @@ -63,7 +76,67 @@ func (r *Resolver) Resolve(statusListVCURI string) (*verifiable.Credential, erro return nil, fmt.Errorf("failed to parse and verify status vc: %w", err) } - return vc, nil + return cred, nil +} + +const ( + idHubQueryMethod = "CollectionsQuery" +) + +func (r *Resolver) resolveDIDRelativeURL(didURL string) ([]byte, error) { + docRes, err := r.didResolver.Resolve(strings.Split(didURL, "?")[0]) + if err != nil { + return nil, fmt.Errorf("failed to resolve DID: %w", err) + } + + didDoc := docRes.DIDDocument + + queries, err := getQueries(didURL) + if err != nil { + return nil, err + } + + objectID, reqMessage, err := identityhub.GetRequest(didDoc.ID, idHubQueryMethod, queries) + if err != nil { + return nil, fmt.Errorf("unable to construct identity hub request object: %w", err) + } + + payload, err := json.Marshal(reqMessage) + if err != nil { + return nil, fmt.Errorf("unable to marshal identityHubRequest: %w", err) + } + + svcEndpoint, err := identityhub.ServiceEndpoint(didDoc) + if err != nil { + return nil, fmt.Errorf("unable to find identity hub service endpoint in did doc: %w", err) + } + + req, err := http.NewRequestWithContext( + context.Background(), http.MethodPost, svcEndpoint, bytes.NewReader(payload)) + if err != nil { + return nil, fmt.Errorf("unable to create request to identity hub: %w", err) + } + + req.Header.Add("Content-Type", "application/json") + + resp, err := r.sendHTTPRequest(req, http.StatusOK, r.bearerToken) + if err != nil { + return nil, fmt.Errorf("send identity hub request failed: %w", err) + } + + var identityHubResponse identityhub.Response + + err = json.Unmarshal(resp, &identityHubResponse) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal Response: %w", err) + } + + err = identityHubResponse.CheckStatus() + if err != nil { + return nil, fmt.Errorf("identity hub server returned error response: %w", err) + } + + return identityHubResponse.GetMessageData(objectID) } func (r *Resolver) sendHTTPRequest(req *http.Request, status int, token string) ([]byte, error) { @@ -94,3 +167,34 @@ func (r *Resolver) sendHTTPRequest(req *http.Request, status int, token string) return body, nil } + +func getQueries(didRelativeURL string) ([]map[string]interface{}, error) { + chunks := strings.Split(didRelativeURL, "?") + if len(chunks) <= 1 { + return nil, fmt.Errorf("missing query") + } + + queryValues, err := url.ParseQuery(chunks[1]) + if err != nil { + return nil, fmt.Errorf("unable to parse query from didURL: %w", err) + } + + queries := queryValues.Get("queries") + if queries == "" { + return nil, fmt.Errorf("missing 'queries' parameter") + } + + queriesVal, err := base64.StdEncoding.DecodeString(queries) + if err != nil { + return nil, fmt.Errorf("unable to decode \"queries\" key: %w", err) + } + + queryMaps := []map[string]interface{}{} + + err = json.Unmarshal(queriesVal, &queryMaps) + if err != nil { + return nil, fmt.Errorf("unable to unmarshal queries: %w", err) + } + + return queryMaps, nil +} diff --git a/component/vc/status/resolver/resolver_test.go b/component/vc/status/resolver/resolver_test.go new file mode 100644 index 00000000..73b55c08 --- /dev/null +++ b/component/vc/status/resolver/resolver_test.go @@ -0,0 +1,441 @@ +/* +Copyright Gen Digital Inc. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package resolver_test + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/google/uuid" + "github.com/hyperledger/aries-framework-go/pkg/common/model" + "github.com/hyperledger/aries-framework-go/pkg/doc/did" + "github.com/hyperledger/aries-framework-go/pkg/doc/verifiable" + "github.com/hyperledger/aries-framework-go/pkg/mock/vdr" + "github.com/stretchr/testify/require" + + "github.com/hyperledger/aries-framework-go-ext/component/vc/status/internal/identityhub" + + . "github.com/hyperledger/aries-framework-go-ext/component/vc/status/resolver" +) + +const ( + methodCollectionsQuery = "CollectionsQuery" + methodKey = "method" + objectIDKey = "objectId" + serviceTypeIdentityHub = "IdentityHub" +) + +func TestResolve(t *testing.T) { //nolint:maintidx + srcVC := &verifiable.Credential{ + Context: []string{verifiable.ContextURI}, + Types: []string{verifiable.VCType}, + ID: uuid.NewString(), + Schemas: []verifiable.TypedID{}, + CustomFields: verifiable.CustomFields{}, + } + + srcVCBytes, e := srcVC.MarshalJSON() + require.NoError(t, e) + + const objectID = "object-id" + + t.Run("success: resolve http status VC URI", func(t *testing.T) { + resolver := NewResolver(http.DefaultClient, &vdr.MockVDRegistry{}, "") + + statusServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + _, err := w.Write(srcVCBytes) + require.NoError(t, err) + })) + + defer func() { + statusServer.Close() + }() + + gotVC, err := resolver.Resolve(statusServer.URL) + require.NoError(t, err) + require.NotNil(t, gotVC) + require.Equal(t, srcVC, gotVC) + }) + + t.Run("success: DID with Identity Hub service", func(t *testing.T) { + resp := &identityhub.Response{ + Replies: []identityhub.MessageResult{ + { + Entries: []identityhub.Message{ + { + Descriptor: map[string]interface{}{ + objectIDKey: objectID, + }, + Data: base64.StdEncoding.EncodeToString(srcVCBytes), + }, + }, + Status: identityhub.Status{ + Code: http.StatusOK, + }, + }, + }, + } + + respBytes, err := json.Marshal(resp) + require.NoError(t, err) + + statusServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + _, e := w.Write(respBytes) + require.NoError(t, e) + })) + + defer func() { + statusServer.Close() + }() + + resolver := NewResolver(http.DefaultClient, &vdr.MockVDRegistry{ + ResolveValue: &did.Doc{ + Context: []string{did.ContextV1}, + Service: []did.Service{ + { + Type: serviceTypeIdentityHub, + ServiceEndpoint: model.NewDIDCommV1Endpoint(statusServer.URL), + }, + }, + }, + }, "") + + queryString := mockDIDQueryString(t, objectID) + + gotVC, err := resolver.Resolve("did:foo:bar" + queryString) + require.NoError(t, err) + require.NotNil(t, gotVC) + require.Equal(t, srcVC, gotVC) + }) + + t.Run("fail: resolve DID", func(t *testing.T) { + resolver := NewResolver(http.DefaultClient, &vdr.MockVDRegistry{}, "") + + _, err := resolver.Resolve("did:foo:bar") + require.Error(t, err) + require.Contains(t, err.Error(), "failed to resolve DID") + }) + + t.Run("fail: no 'queries' query param", func(t *testing.T) { + resolver := NewResolver(http.DefaultClient, &vdr.MockVDRegistry{ + ResolveValue: &did.Doc{ + Context: []string{did.ContextV1}, + }, + }, "") + + _, err := resolver.Resolve("did:foo:bar") + require.Error(t, err) + require.Contains(t, err.Error(), "missing query") + }) + + t.Run("fail: no query params", func(t *testing.T) { + resolver := NewResolver(http.DefaultClient, &vdr.MockVDRegistry{ + ResolveValue: &did.Doc{ + Context: []string{did.ContextV1}, + }, + }, "") + + _, err := resolver.Resolve("did:foo:bar") + require.Error(t, err) + require.Contains(t, err.Error(), "missing query") + }) + + t.Run("fail: no 'queries' param", func(t *testing.T) { + resolver := NewResolver(http.DefaultClient, &vdr.MockVDRegistry{ + ResolveValue: &did.Doc{ + Context: []string{did.ContextV1}, + }, + }, "") + + _, err := resolver.Resolve("did:foo:bar?foo=foo") + require.Error(t, err) + require.Contains(t, err.Error(), "missing 'queries' parameter") + }) + + t.Run("fail: 'queries' param is not base 64 data", func(t *testing.T) { + resolver := NewResolver(http.DefaultClient, &vdr.MockVDRegistry{ + ResolveValue: &did.Doc{ + Context: []string{did.ContextV1}, + }, + }, "") + + _, err := resolver.Resolve("did:foo:bar?queries=$-+_not-base64") + require.Error(t, err) + require.Contains(t, err.Error(), "unable to decode \"queries\" key") + }) + + t.Run("fail: 'queries' param is not encoded map list", func(t *testing.T) { + resolver := NewResolver(http.DefaultClient, &vdr.MockVDRegistry{ + ResolveValue: &did.Doc{ + Context: []string{did.ContextV1}, + }, + }, "") + + queryData := base64.StdEncoding.EncodeToString([]byte("foo bar baz")) + + _, err := resolver.Resolve("did:foo:bar?queries=" + queryData) + require.Error(t, err) + require.Contains(t, err.Error(), "unable to unmarshal queries") + }) + + t.Run("fail: 'queries' does not have valid data for constructing request", func(t *testing.T) { + resolver := NewResolver(http.DefaultClient, &vdr.MockVDRegistry{ + ResolveValue: &did.Doc{ + Context: []string{did.ContextV1}, + }, + }, "") + + queryData := base64.StdEncoding.EncodeToString([]byte("[{\"foo\":\"bar\"}, {}]")) + + _, err := resolver.Resolve("did:foo:bar?queries=" + queryData) + require.Error(t, err) + require.Contains(t, err.Error(), "unable to construct identity hub request object") + }) + + t.Run("fail: no identity hub did service", func(t *testing.T) { + resolver := NewResolver(http.DefaultClient, &vdr.MockVDRegistry{ + ResolveValue: &did.Doc{ + Context: []string{did.ContextV1}, + }, + }, "") + + queryString := mockDIDQueryString(t, "foo") + + _, err := resolver.Resolve("did:foo:bar" + queryString) + require.Error(t, err) + require.Contains(t, err.Error(), "unable to find identity hub service endpoint in did doc") + }) + + t.Run("fail: identity hub server error response", func(t *testing.T) { + statusServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + + defer func() { + statusServer.Close() + }() + + resolver := NewResolver(http.DefaultClient, &vdr.MockVDRegistry{ + ResolveValue: &did.Doc{ + Context: []string{did.ContextV1}, + Service: []did.Service{ + { + Type: serviceTypeIdentityHub, + ServiceEndpoint: model.NewDIDCommV1Endpoint(statusServer.URL), + }, + }, + }, + }, "") + + queryString := mockDIDQueryString(t, "zop") + + _, err := resolver.Resolve("did:foo:bar" + queryString) + require.Error(t, err) + require.Contains(t, err.Error(), "send identity hub request failed") + }) + + t.Run("fail: can't parse identity hub server response", func(t *testing.T) { + statusServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + _, e := w.Write([]byte("abc def")) + require.NoError(t, e) + })) + + defer func() { + statusServer.Close() + }() + + resolver := NewResolver(http.DefaultClient, &vdr.MockVDRegistry{ + ResolveValue: &did.Doc{ + Context: []string{did.ContextV1}, + Service: []did.Service{ + { + Type: serviceTypeIdentityHub, + ServiceEndpoint: model.NewDIDCommV1Endpoint(statusServer.URL), + }, + }, + }, + }, "") + + queryString := mockDIDQueryString(t, "zip") + + _, err := resolver.Resolve("did:foo:bar" + queryString) + require.Error(t, err) + require.Contains(t, err.Error(), "unable to unmarshal Response") + }) + + t.Run("fail: response status error", func(t *testing.T) { + resp := &identityhub.Response{ + Status: &identityhub.Status{ + Code: http.StatusInternalServerError, + Message: "error", + }, + } + + respBytes, err := json.Marshal(resp) + require.NoError(t, err) + + statusServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + _, e := w.Write(respBytes) + require.NoError(t, e) + })) + + defer func() { + statusServer.Close() + }() + + resolver := NewResolver(http.DefaultClient, &vdr.MockVDRegistry{ + ResolveValue: &did.Doc{ + Context: []string{did.ContextV1}, + Service: []did.Service{ + { + Type: serviceTypeIdentityHub, + ServiceEndpoint: model.NewDIDCommV1Endpoint(statusServer.URL), + }, + }, + }, + }, "") + + queryString := mockDIDQueryString(t, objectID) + + _, err = resolver.Resolve("did:foo:bar" + queryString) + require.Error(t, err) + require.Contains(t, err.Error(), "identity hub server returned error response") + }) + + t.Run("fail: response has no message with expected ID", func(t *testing.T) { + resp := &identityhub.Response{ + Replies: []identityhub.MessageResult{ + { + Status: identityhub.Status{ + Code: http.StatusOK, + }, + }, + }, + } + + respBytes, err := json.Marshal(resp) + require.NoError(t, err) + + statusServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + _, e := w.Write(respBytes) + require.NoError(t, e) + })) + + defer func() { + statusServer.Close() + }() + + resolver := NewResolver(http.DefaultClient, &vdr.MockVDRegistry{ + ResolveValue: &did.Doc{ + Context: []string{did.ContextV1}, + Service: []did.Service{ + { + Type: serviceTypeIdentityHub, + ServiceEndpoint: model.NewDIDCommV1Endpoint(statusServer.URL), + }, + }, + }, + }, "") + + queryString := mockDIDQueryString(t, objectID) + + _, err = resolver.Resolve("did:foo:bar" + queryString) + require.Error(t, err) + require.Contains(t, err.Error(), "unable to get message by object ID from Response") + }) + + t.Run("fail: response vc data in invalid format", func(t *testing.T) { + resp := &identityhub.Response{ + Replies: []identityhub.MessageResult{ + { + Entries: []identityhub.Message{ + { + Descriptor: map[string]interface{}{ + objectIDKey: objectID, + }, + Data: "$%^ not base64 data $%^", + }, + }, + Status: identityhub.Status{ + Code: http.StatusOK, + }, + }, + }, + } + + respBytes, err := json.Marshal(resp) + require.NoError(t, err) + + statusServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + _, e := w.Write(respBytes) + require.NoError(t, e) + })) + + defer func() { + statusServer.Close() + }() + + resolver := NewResolver(http.DefaultClient, &vdr.MockVDRegistry{ + ResolveValue: &did.Doc{ + Context: []string{did.ContextV1}, + Service: []did.Service{ + { + Type: serviceTypeIdentityHub, + ServiceEndpoint: model.NewDIDCommV1Endpoint(statusServer.URL), + }, + }, + }, + }, "") + + queryString := mockDIDQueryString(t, objectID) + + _, err = resolver.Resolve("did:foo:bar" + queryString) + require.Error(t, err) + require.Contains(t, err.Error(), "unable to decode message bytes") + }) + + t.Run("fail: can't parse status VC", func(t *testing.T) { + resolver := NewResolver(http.DefaultClient, &vdr.MockVDRegistry{}, "") + + statusServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + _, e := w.Write([]byte("invalid data")) + require.NoError(t, e) + })) + + defer func() { + statusServer.Close() + }() + + _, err := resolver.Resolve(statusServer.URL) + require.Error(t, err) + require.Contains(t, err.Error(), "failed to parse and verify status vc") + }) +} + +func mockDIDQueryString(t *testing.T, objectID string) string { + t.Helper() + + messageDescriptorList := []map[string]interface{}{ + { + methodKey: methodCollectionsQuery, + objectIDKey: objectID, + }, + } + + msgDescBytes, err := json.Marshal(messageDescriptorList) + require.NoError(t, err) + + query := url.Values{ + "queries": []string{base64.StdEncoding.EncodeToString(msgDescBytes)}, + } + + return "?" + query.Encode() +} diff --git a/component/vc/status/status_test.go b/component/vc/status/status_test.go index 6ee1ba44..bff497c5 100644 --- a/component/vc/status/status_test.go +++ b/component/vc/status/status_test.go @@ -1,5 +1,6 @@ /* Copyright Avast Software. All Rights Reserved. +Copyright Gen Digital Inc. All Rights Reserved. SPDX-License-Identifier: Apache-2.0 */ @@ -13,14 +14,16 @@ import ( "testing" "github.com/hyperledger/aries-framework-go/pkg/doc/verifiable" + "github.com/hyperledger/aries-framework-go/pkg/mock/vdr" "github.com/stretchr/testify/require" - . "github.com/hyperledger/aries-framework-go-ext/component/vc/status" "github.com/hyperledger/aries-framework-go-ext/component/vc/status/api" "github.com/hyperledger/aries-framework-go-ext/component/vc/status/internal/bitstring" "github.com/hyperledger/aries-framework-go-ext/component/vc/status/resolver" "github.com/hyperledger/aries-framework-go-ext/component/vc/status/validator" "github.com/hyperledger/aries-framework-go-ext/component/vc/status/validator/statuslist2021" + + . "github.com/hyperledger/aries-framework-go-ext/component/vc/status" ) const issuerID = "issuer-id" @@ -29,7 +32,7 @@ func TestClient_VerifyStatus(t *testing.T) { t.Run("success", func(t *testing.T) { client := Client{ ValidatorGetter: validator.GetValidator, - Resolver: resolver.NewResolver(http.DefaultClient, ""), + Resolver: resolver.NewResolver(http.DefaultClient, &vdr.MockVDRegistry{}, ""), } statusServer := httptest.NewServer(mockStatusResponseHandler(t, mockStatusVC(t, issuerID, isRevoked{false, true}))) diff --git a/component/vc/status/validator/statuslist2021/statuslist2021.go b/component/vc/status/validator/statuslist2021/statuslist2021.go index 73cc9729..ee5da0d6 100644 --- a/component/vc/status/validator/statuslist2021/statuslist2021.go +++ b/component/vc/status/validator/statuslist2021/statuslist2021.go @@ -36,7 +36,8 @@ const ( // Validator validates a Verifiable Credential's Status field against the VC Status List 2021 specification, and // returns fields for status verification. -// Spec: https://w3c.github.io/vc-status-list-2021/#statuslist2021entry +// +// Implements spec: https://w3c.github.io/vc-status-list-2021/#statuslist2021entry type Validator struct{} // ValidateStatus validates that a Verifiable Credential's Status field matches the VC Status List 2021 specification. diff --git a/component/vc/status/validator/validator.go b/component/vc/status/validator/validator.go index 4fdf5296..9d4e7334 100644 --- a/component/vc/status/validator/validator.go +++ b/component/vc/status/validator/validator.go @@ -16,7 +16,7 @@ import ( ) // GetValidator returns the VC status list validator for the given status type. -func GetValidator(statusType string) (api.Validator, error) { +func GetValidator(statusType string) (api.Validator, error) { //nolint:ireturn switch statusType { case statuslist2021.StatusList2021Type: return &statuslist2021.Validator{}, nil