From 5d09324e99bdea34ca2c96d15d3d5a28ff48667a Mon Sep 17 00:00:00 2001 From: Sandra Vrtikapa Date: Fri, 30 Sep 2022 09:54:33 -0400 Subject: [PATCH] feat: Add did configuration client (#3391) Did configuration client will: - retrieve did configuration from domain - verify requested did and domain against did configuration Closes #3390 Signed-off-by: Sandra Vrtikapa Signed-off-by: Sandra Vrtikapa --- pkg/client/didconfig/didconfig.go | 111 +++++++++++++ pkg/client/didconfig/didconfig_test.go | 221 +++++++++++++++++++++++++ pkg/doc/didconfig/didconfig.go | 2 +- 3 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 pkg/client/didconfig/didconfig.go create mode 100644 pkg/client/didconfig/didconfig_test.go diff --git a/pkg/client/didconfig/didconfig.go b/pkg/client/didconfig/didconfig.go new file mode 100644 index 000000000..384eae2b3 --- /dev/null +++ b/pkg/client/didconfig/didconfig.go @@ -0,0 +1,111 @@ +/* +Copyright SecureKey Technologies Inc. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package didconfig + +import ( + "context" + "fmt" + "io" + "io/ioutil" + "net/http" + "time" + + jsonld "github.com/piprate/json-gold/ld" + + "github.com/hyperledger/aries-framework-go/pkg/common/log" + "github.com/hyperledger/aries-framework-go/pkg/doc/didconfig" + vdrapi "github.com/hyperledger/aries-framework-go/pkg/framework/aries/api/vdr" +) + +var logger = log.New("aries-framework/client/did-config") + +const defaultTimeout = time.Minute + +// Client is a JSON-LD SDK client. +type Client struct { + httpClient HTTPClient + didConfigOpts []didconfig.DIDConfigurationOpt +} + +// New creates new did configuration client. +func New(opts ...Option) *Client { + client := &Client{ + httpClient: &http.Client{Timeout: defaultTimeout}, + } + + for _, opt := range opts { + opt(client) + } + + return client +} + +// HTTPClient represents an HTTP client. +type HTTPClient interface { + Do(req *http.Request) (*http.Response, error) +} + +// Option configures the did configuration client. +type Option func(opts *Client) + +// WithHTTPClient option is for custom http client. +func WithHTTPClient(httpClient HTTPClient) Option { + return func(opts *Client) { + opts.httpClient = httpClient + } +} + +// WithJSONLDDocumentLoader defines a JSON-LD document loader. +func WithJSONLDDocumentLoader(documentLoader jsonld.DocumentLoader) Option { + return func(opts *Client) { + opts.didConfigOpts = append(opts.didConfigOpts, didconfig.WithJSONLDDocumentLoader(documentLoader)) + } +} + +// WithVDRegistry defines a vdr service. +func WithVDRegistry(vdrRegistry vdrapi.Registry) Option { + return func(opts *Client) { + opts.didConfigOpts = append(opts.didConfigOpts, didconfig.WithVDRegistry(vdrRegistry)) + } +} + +// VerifyDIDAndDomain will verify that there is valid domain linkage credential in did configuration +// for specified did and domain. +func (c *Client) VerifyDIDAndDomain(did, domain string) error { + endpoint := domain + "/.well-known/did-configuration.json" + + req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, endpoint, nil) + if err != nil { + return fmt.Errorf("new HTTP request: %w", err) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("httpClient.Do: %w", err) + } + + defer closeResponseBody(resp.Body) + + responseBytes, err := ioutil.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("endpoint %s returned status '%d' and message '%s'", + endpoint, resp.StatusCode, responseBytes) + } + + return didconfig.VerifyDIDAndDomain(responseBytes, did, domain) +} + +func closeResponseBody(respBody io.Closer) { + e := respBody.Close() + if e != nil { + logger.Warnf("failed to close response body: %v", e) + } +} diff --git a/pkg/client/didconfig/didconfig_test.go b/pkg/client/didconfig/didconfig_test.go new file mode 100644 index 000000000..5e5f2b8db --- /dev/null +++ b/pkg/client/didconfig/didconfig_test.go @@ -0,0 +1,221 @@ +/* +Copyright SecureKey Technologies Inc. All Rights Reserved. + +SPDX-License-Identifier: Apache-2.0 +*/ + +package didconfig + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/hyperledger/aries-framework-go/pkg/doc/ldcontext" + "github.com/hyperledger/aries-framework-go/pkg/internal/ldtestutil" + "github.com/hyperledger/aries-framework-go/pkg/vdr" + "github.com/hyperledger/aries-framework-go/pkg/vdr/key" +) + +const ( + testDID = "did:key:z6MkoTHsgNNrby8JzCNQ1iRLyW5QQ6R8Xuu6AA8igGrMVPUM" + testDomain = "https://identity.foundation" + + contextV1 = "https://identity.foundation/.well-known/did-configuration/v1" +) + +func TestNew(t *testing.T) { + t.Run("success - default options", func(t *testing.T) { + c := New() + require.NotNil(t, c) + require.Len(t, c.didConfigOpts, 0) + }) + + t.Run("success - did config options provided", func(t *testing.T) { + loader, err := ldtestutil.DocumentLoader(ldcontext.Document{ + URL: contextV1, + Content: json.RawMessage(didCfgCtxV1), + }) + require.NoError(t, err) + + c := New(WithJSONLDDocumentLoader(loader), + WithVDRegistry(vdr.New(vdr.WithVDR(key.New()))), + WithHTTPClient(&http.Client{})) + require.NotNil(t, c) + require.Len(t, c.didConfigOpts, 2) + }) +} + +func TestVerifyDIDAndDomain(t *testing.T) { + loader, err := ldtestutil.DocumentLoader(ldcontext.Document{ + URL: contextV1, + Content: json.RawMessage(didCfgCtxV1), + }) + require.NoError(t, err) + + t.Run("success", func(t *testing.T) { + httpClient := &mockHTTPClient{ + DoFunc: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(bytes.NewReader([]byte(didCfg))), + }, nil + }, + } + + c := New(WithJSONLDDocumentLoader(loader), WithHTTPClient(httpClient)) + + err := c.VerifyDIDAndDomain(testDID, testDomain) + require.NoError(t, err) + }) + + t.Run("success", func(t *testing.T) { + httpClient := &mockHTTPClient{ + DoFunc: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(bytes.NewReader([]byte(didCfg))), + }, nil + }, + } + + c := New(WithJSONLDDocumentLoader(loader), WithHTTPClient(httpClient)) + + err := c.VerifyDIDAndDomain(testDID, testDomain) + require.NoError(t, err) + }) + + t.Run("error - http client error", func(t *testing.T) { + c := New(WithJSONLDDocumentLoader(loader)) + + err := c.VerifyDIDAndDomain(testDID, "https://non-existent-abc.com") + require.Error(t, err) + require.Contains(t, err.Error(), + "Get \"https://non-existent-abc.com/.well-known/did-configuration.json\": dial tcp: "+ + "lookup non-existent-abc.com: no such host") + }) + + t.Run("error - http request error", func(t *testing.T) { + c := New(WithJSONLDDocumentLoader(loader)) + + err := c.VerifyDIDAndDomain(testDID, ":invalid.com") + require.Error(t, err) + require.Contains(t, err.Error(), "missing protocol scheme") + }) + + t.Run("error - http status error", func(t *testing.T) { + httpClient := &mockHTTPClient{ + DoFunc: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: ioutil.NopCloser(bytes.NewReader([]byte("data not found"))), + }, nil + }, + } + + c := New(WithJSONLDDocumentLoader(loader), WithHTTPClient(httpClient)) + + err := c.VerifyDIDAndDomain(testDID, testDomain) + require.Error(t, err) + require.Contains(t, err.Error(), "endpoint https://identity.foundation/.well-known/did-configuration.json "+ + "returned status '404' and message 'data not found'") + }) + + t.Run("error - did configuration missing linked DIDs", func(t *testing.T) { + httpClient := &mockHTTPClient{ + DoFunc: func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: ioutil.NopCloser(bytes.NewReader([]byte(didCfgNoLinkedDIDs))), + }, nil + }, + } + + c := New(WithJSONLDDocumentLoader(loader), + WithVDRegistry(vdr.New(vdr.WithVDR(key.New()))), + WithHTTPClient(httpClient)) + + err := c.VerifyDIDAndDomain(testDID, testDomain) + require.Error(t, err) + require.Contains(t, err.Error(), "did configuration: property 'linked_dids' is required ") + }) +} + +func TestCloseResponseBody(t *testing.T) { + t.Run("error", func(t *testing.T) { + closeResponseBody(&mockCloser{Err: fmt.Errorf("test error")}) + }) +} + +type mockHTTPClient struct { + DoFunc func(req *http.Request) (*http.Response, error) +} + +func (m *mockHTTPClient) Do(req *http.Request) (*http.Response, error) { + return m.DoFunc(req) +} + +type mockCloser struct { + Err error +} + +func (c *mockCloser) Close() error { + return c.Err +} + +// nolint: lll +const didCfg = ` +{ + "@context": "https://identity.foundation/.well-known/did-configuration/v1", + "linked_dids": [ + { + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "https://identity.foundation/.well-known/did-configuration/v1" + ], + "issuer": "did:key:z6MkoTHsgNNrby8JzCNQ1iRLyW5QQ6R8Xuu6AA8igGrMVPUM", + "issuanceDate": "2020-12-04T14:08:28-06:00", + "expirationDate": "2025-12-04T14:08:28-06:00", + "type": [ + "VerifiableCredential", + "DomainLinkageCredential" + ], + "credentialSubject": { + "id": "did:key:z6MkoTHsgNNrby8JzCNQ1iRLyW5QQ6R8Xuu6AA8igGrMVPUM", + "origin": "https://identity.foundation" + }, + "proof": { + "type": "Ed25519Signature2018", + "created": "2020-12-04T20:08:28.540Z", + "jws": "eyJhbGciOiJFZERTQSIsImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19..D0eDhglCMEjxDV9f_SNxsuU-r3ZB9GR4vaM9TYbyV7yzs1WfdUyYO8rFZdedHbwQafYy8YOpJ1iJlkSmB4JaDQ", + "proofPurpose": "assertionMethod", + "verificationMethod": "did:key:z6MkoTHsgNNrby8JzCNQ1iRLyW5QQ6R8Xuu6AA8igGrMVPUM#z6MkoTHsgNNrby8JzCNQ1iRLyW5QQ6R8Xuu6AA8igGrMVPUM" + } + } + ] +}` + +const didCfgNoLinkedDIDs = ` +{ + "@context": "https://identity.foundation/.well-known/did-configuration/v1" +}` + +// nolint: lll +const didCfgCtxV1 = ` +{ + "@context": [ + { + "@version": 1.1, + "@protected": true, + "LinkedDomains": "https://identity.foundation/.well-known/resources/did-configuration/#LinkedDomains", + "DomainLinkageCredential": "https://identity.foundation/.well-known/resources/did-configuration/#DomainLinkageCredential", + "origin": "https://identity.foundation/.well-known/resources/did-configuration/#origin", + "linked_dids": "https://identity.foundation/.well-known/resources/did-configuration/#linked_dids" + } + ] +}` diff --git a/pkg/doc/didconfig/didconfig.go b/pkg/doc/didconfig/didconfig.go index 9998d78ca..d52908136 100644 --- a/pkg/doc/didconfig/didconfig.go +++ b/pkg/doc/didconfig/didconfig.go @@ -24,7 +24,7 @@ import ( "github.com/hyperledger/aries-framework-go/pkg/vdr/key" ) -var logger = log.New("aries-framework/doc/verifiable") +var logger = log.New("aries-framework/doc/didconfig") const ( // ContextV0 is did configuration context version 0.