From 508379a63009fadb940ff2adf2f0c6f862fc6660 Mon Sep 17 00:00:00 2001
From: Lyuben Penkovski <lyuben.penkovski@vereign.com>
Date: Thu, 23 Jun 2022 19:52:03 +0300
Subject: [PATCH] Implement trusted import functionality

---
 cmd/infohub/main.go                           |  14 ++-
 go.mod                                        |   4 +-
 internal/credential/issuer.go                 |  37 +++++-
 .../credential/keyfetcher/web_key_fetcher.go  | 105 ++++++++++++++++++
 .../infohub/infohubfakes/fake_cache.go        |  87 +++++++++++++++
 .../infohub/infohubfakes/fake_credentials.go  |  84 ++++++++++++++
 internal/service/infohub/service.go           |  57 +++++++++-
 .../gaiax/tsa/golib/goadec/bytes_decoder.go   | Bin 0 -> 758 bytes
 vendor/modules.txt                            | Bin 14889 -> 14929 bytes
 9 files changed, 378 insertions(+), 10 deletions(-)
 create mode 100644 internal/credential/keyfetcher/web_key_fetcher.go
 create mode 100644 vendor/code.vereign.com/gaiax/tsa/golib/goadec/bytes_decoder.go

diff --git a/cmd/infohub/main.go b/cmd/infohub/main.go
index 441f173..6dd89fd 100644
--- a/cmd/infohub/main.go
+++ b/cmd/infohub/main.go
@@ -18,6 +18,7 @@ import (
 	"golang.org/x/sync/errgroup"
 
 	"code.vereign.com/gaiax/tsa/golib/cache"
+	"code.vereign.com/gaiax/tsa/golib/goadec"
 	"code.vereign.com/gaiax/tsa/golib/graceful"
 	goahealth "code.vereign.com/gaiax/tsa/infohub/gen/health"
 	goahealthsrv "code.vereign.com/gaiax/tsa/infohub/gen/http/health/server"
@@ -41,7 +42,7 @@ func main() {
 	// load configuration from environment
 	var cfg config.Config
 	if err := envconfig.Process("", &cfg); err != nil {
-		log.Fatalf("cannot load configuration: %v", err)
+		log.Fatalf("cannot load configuration: %v\n", err)
 	}
 
 	// create logger
@@ -134,6 +135,17 @@ func main() {
 		openapiServer = goaopenapisrv.New(openapiEndpoints, mux, dec, enc, nil, errFormatter, nil, nil)
 	}
 
+	// set custom request decoder, so that request body bytes are simply
+	// read and not decoded in some other way
+	infohubServer.Import = goainfohubsrv.NewImportHandler(
+		infohubEndpoints.Import,
+		mux,
+		goadec.BytesDecoder,
+		enc,
+		nil,
+		errFormatter,
+	)
+
 	// Configure the mux.
 	goainfohubsrv.Mount(mux, infohubServer)
 	goahealthsrv.Mount(mux, healthServer)
diff --git a/go.mod b/go.mod
index ad83f1d..e205b5e 100644
--- a/go.mod
+++ b/go.mod
@@ -4,9 +4,11 @@ go 1.17
 
 require (
 	code.vereign.com/gaiax/tsa/golib v0.0.0-20220617105657-d5117fe7a1f4
+	github.com/google/uuid v1.3.0
 	github.com/hyperledger/aries-framework-go v0.1.8
 	github.com/kelseyhightower/envconfig v1.4.0
 	github.com/piprate/json-gold v0.4.1
+	github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693
 	github.com/stretchr/testify v1.7.1
 	go.mongodb.org/mongo-driver v1.9.1
 	go.uber.org/zap v1.21.0
@@ -27,7 +29,6 @@ require (
 	github.com/golang/snappy v0.0.4 // indirect
 	github.com/google/go-cmp v0.5.6 // indirect
 	github.com/google/tink/go v1.6.1-0.20210519071714-58be99b3c4d0 // indirect
-	github.com/google/uuid v1.3.0 // indirect
 	github.com/gopherjs/gopherjs v1.17.2 // indirect
 	github.com/gorilla/websocket v1.5.0 // indirect
 	github.com/hyperledger/aries-framework-go/spi v0.0.0-20220322085443-50e8f9bd208b // indirect
@@ -44,7 +45,6 @@ require (
 	github.com/pquerna/cachecontrol v0.0.0-20180517163645-1555304b9b35 // indirect
 	github.com/sergi/go-diff v1.2.0 // indirect
 	github.com/smartystreets/assertions v1.13.0 // indirect
-	github.com/square/go-jose/v3 v3.0.0-20200630053402-0a67ce9b0693 // indirect
 	github.com/teserakt-io/golang-ed25519 v0.0.0-20210104091850-3888c087a4c8 // indirect
 	github.com/xdg-go/pbkdf2 v1.0.0 // indirect
 	github.com/xdg-go/scram v1.0.2 // indirect
diff --git a/internal/credential/issuer.go b/internal/credential/issuer.go
index b5e14fb..7f81064 100644
--- a/internal/credential/issuer.go
+++ b/internal/credential/issuer.go
@@ -4,27 +4,38 @@ import (
 	"net/http"
 	"time"
 
+	"github.com/hyperledger/aries-framework-go/pkg/doc/signature/suite"
+	"github.com/hyperledger/aries-framework-go/pkg/doc/signature/suite/jsonwebsignature2020"
 	"github.com/hyperledger/aries-framework-go/pkg/doc/util"
 	"github.com/hyperledger/aries-framework-go/pkg/doc/verifiable"
 	"github.com/piprate/json-gold/ld"
+
+	"code.vereign.com/gaiax/tsa/infohub/internal/credential/keyfetcher"
 )
 
+var defaultContexts = []string{
+	"https://www.w3.org/2018/credentials/v1",
+	"https://w3id.org/security/suites/jws-2020/v1",
+}
+
 type Issuer struct {
-	issuerURI string
-	docLoader *ld.CachingDocumentLoader
+	issuerURI  string
+	docLoader  *ld.CachingDocumentLoader
+	httpClient *http.Client
 }
 
 func NewIssuer(issuerURI string, httpClient *http.Client) *Issuer {
 	loader := ld.NewDefaultDocumentLoader(httpClient)
 
 	return &Issuer{
-		issuerURI: issuerURI,
-		docLoader: ld.NewCachingDocumentLoader(loader),
+		issuerURI:  issuerURI,
+		docLoader:  ld.NewCachingDocumentLoader(loader),
+		httpClient: httpClient,
 	}
 }
 
 func (i *Issuer) NewCredential(contexts []string, subjectID string, subject map[string]interface{}, proof bool) (*verifiable.Credential, error) {
-	jsonldContexts := []string{"https://www.w3.org/2018/credentials/v1"}
+	jsonldContexts := defaultContexts
 	jsonldContexts = append(jsonldContexts, contexts...)
 
 	vc := &verifiable.Credential{
@@ -42,7 +53,7 @@ func (i *Issuer) NewCredential(contexts []string, subjectID string, subject map[
 }
 
 func (i *Issuer) NewPresentation(contexts []string, vc ...*verifiable.Credential) (*verifiable.Presentation, error) {
-	jsonldContexts := []string{"https://www.w3.org/2018/credentials/v1"}
+	jsonldContexts := defaultContexts
 	jsonldContexts = append(jsonldContexts, contexts...)
 
 	vp, err := verifiable.NewPresentation(verifiable.WithCredentials(vc...))
@@ -55,3 +66,17 @@ func (i *Issuer) NewPresentation(contexts []string, vc ...*verifiable.Credential
 
 	return vp, nil
 }
+
+func (i *Issuer) ParsePresentation(vpBytes []byte) (*verifiable.Presentation, error) {
+	fetcher := keyfetcher.NewWebKeyFetcher(i.httpClient)
+
+	return verifiable.ParsePresentation(
+		vpBytes,
+		verifiable.WithPresPublicKeyFetcher(fetcher),
+		verifiable.WithPresEmbeddedSignatureSuites(
+			jsonwebsignature2020.New(suite.WithVerifier(jsonwebsignature2020.NewPublicKeyVerifier())),
+		),
+		verifiable.WithPresJSONLDDocumentLoader(i.docLoader),
+		verifiable.WithPresStrictValidation(),
+	)
+}
diff --git a/internal/credential/keyfetcher/web_key_fetcher.go b/internal/credential/keyfetcher/web_key_fetcher.go
new file mode 100644
index 0000000..736611b
--- /dev/null
+++ b/internal/credential/keyfetcher/web_key_fetcher.go
@@ -0,0 +1,105 @@
+package keyfetcher
+
+import (
+	"crypto/ecdsa"
+	"crypto/ed25519"
+	"crypto/rsa"
+	"encoding/json"
+	"fmt"
+	"net/http"
+	"net/url"
+	"strings"
+
+	ariesjwk "github.com/hyperledger/aries-framework-go/pkg/doc/jose/jwk"
+	"github.com/hyperledger/aries-framework-go/pkg/doc/signature/verifier"
+	"github.com/hyperledger/aries-framework-go/pkg/doc/verifiable"
+	"github.com/square/go-jose/v3"
+
+	"code.vereign.com/gaiax/tsa/golib/errors"
+)
+
+type VerificationMethod struct {
+	ID           string           `json:"id"`
+	Type         string           `json:"type"`
+	PublicKeyJWK *jose.JSONWebKey `json:"publicKeyJWK"`
+}
+
+type WebKeyFetcher struct {
+	httpClient *http.Client
+}
+
+// NewWebKeyFetcher retrieves a public key by directly calling an HTTP URL.
+func NewWebKeyFetcher(httpClient *http.Client) verifiable.PublicKeyFetcher {
+	f := &WebKeyFetcher{httpClient: httpClient}
+	return f.fetch
+}
+
+// fetch a public key directly from an HTTP URL. issuerID is expected to be
+// URL like https://example.com/keys and keyID is the name of the key to be fetched.
+// If the keyID contains a fragment(#), it is removed when constructing the target URL.
+func (f *WebKeyFetcher) fetch(issuerID, keyID string) (*verifier.PublicKey, error) {
+	// If keyID is prefixed with hashtag(#) it must be removed
+	keyID = strings.TrimPrefix(keyID, "#")
+
+	// Construct URL like http://signer:8080/v1/keys/key-1
+	addr := fmt.Sprintf("%s/%s", issuerID, keyID)
+	uri, err := url.ParseRequestURI(addr)
+	if err != nil {
+		return nil, err
+	}
+
+	req, err := http.NewRequest("GET", uri.String(), nil)
+	if err != nil {
+		return nil, err
+	}
+
+	resp, err := f.httpClient.Do(req)
+	if err != nil {
+		return nil, err
+	}
+
+	if resp.StatusCode != http.StatusOK {
+		if resp.StatusCode == http.StatusNotFound {
+			return nil, errors.New(errors.NotFound, "key not found")
+		}
+		return nil, errors.New(errors.GetKind(resp.StatusCode), fmt.Errorf("unexpected response: %s", resp.Status))
+	}
+
+	var verificationMethod VerificationMethod
+	if err := json.NewDecoder(resp.Body).Decode(&verificationMethod); err != nil {
+		return nil, err
+	}
+
+	if verificationMethod.PublicKeyJWK == nil {
+		return nil, fmt.Errorf("public key not found after decoding response")
+	}
+
+	// We need to extract the Curve and Kty values as they are needed by the
+	// Aries public key verifiers.
+	curve, kty, err := keyParams(verificationMethod.PublicKeyJWK.Key)
+	if err != nil {
+		return nil, err
+	}
+
+	return &verifier.PublicKey{
+		Type: "JsonWebKey2020",
+		JWK: &ariesjwk.JWK{
+			JSONWebKey: *verificationMethod.PublicKeyJWK,
+			Crv:        curve,
+			Kty:        kty,
+		},
+	}, nil
+}
+
+func keyParams(key interface{}) (curve string, kty string, err error) {
+	switch k := key.(type) {
+	case *ecdsa.PublicKey:
+		return k.Curve.Params().Name, "EC", nil
+	case *ed25519.PublicKey:
+		return "ED25519", "OKP", nil
+	case *rsa.PublicKey:
+		return "", "RSA", nil
+	default:
+		return "", "", fmt.Errorf("unknown key type: %T", k)
+	}
+}
diff --git a/internal/service/infohub/infohubfakes/fake_cache.go b/internal/service/infohub/infohubfakes/fake_cache.go
index 2d15587..c96bb5f 100644
--- a/internal/service/infohub/infohubfakes/fake_cache.go
+++ b/internal/service/infohub/infohubfakes/fake_cache.go
@@ -25,6 +25,21 @@ type FakeCache struct {
 		result1 []byte
 		result2 error
 	}
+	SetStub        func(context.Context, string, string, string, []byte) error
+	setMutex       sync.RWMutex
+	setArgsForCall []struct {
+		arg1 context.Context
+		arg2 string
+		arg3 string
+		arg4 string
+		arg5 []byte
+	}
+	setReturns struct {
+		result1 error
+	}
+	setReturnsOnCall map[int]struct {
+		result1 error
+	}
 	invocations      map[string][][]interface{}
 	invocationsMutex sync.RWMutex
 }
@@ -96,11 +111,83 @@ func (fake *FakeCache) GetReturnsOnCall(i int, result1 []byte, result2 error) {
 	}{result1, result2}
 }
 
+func (fake *FakeCache) Set(arg1 context.Context, arg2 string, arg3 string, arg4 string, arg5 []byte) error {
+	var arg5Copy []byte
+	if arg5 != nil {
+		arg5Copy = make([]byte, len(arg5))
+		copy(arg5Copy, arg5)
+	}
+	fake.setMutex.Lock()
+	ret, specificReturn := fake.setReturnsOnCall[len(fake.setArgsForCall)]
+	fake.setArgsForCall = append(fake.setArgsForCall, struct {
+		arg1 context.Context
+		arg2 string
+		arg3 string
+		arg4 string
+		arg5 []byte
+	}{arg1, arg2, arg3, arg4, arg5Copy})
+	stub := fake.SetStub
+	fakeReturns := fake.setReturns
+	fake.recordInvocation("Set", []interface{}{arg1, arg2, arg3, arg4, arg5Copy})
+	fake.setMutex.Unlock()
+	if stub != nil {
+		return stub(arg1, arg2, arg3, arg4, arg5)
+	}
+	if specificReturn {
+		return ret.result1
+	}
+	return fakeReturns.result1
+}
+
+func (fake *FakeCache) SetCallCount() int {
+	fake.setMutex.RLock()
+	defer fake.setMutex.RUnlock()
+	return len(fake.setArgsForCall)
+}
+
+func (fake *FakeCache) SetCalls(stub func(context.Context, string, string, string, []byte) error) {
+	fake.setMutex.Lock()
+	defer fake.setMutex.Unlock()
+	fake.SetStub = stub
+}
+
+func (fake *FakeCache) SetArgsForCall(i int) (context.Context, string, string, string, []byte) {
+	fake.setMutex.RLock()
+	defer fake.setMutex.RUnlock()
+	argsForCall := fake.setArgsForCall[i]
+	return argsForCall.arg1, argsForCall.arg2, argsForCall.arg3, argsForCall.arg4, argsForCall.arg5
+}
+
+func (fake *FakeCache) SetReturns(result1 error) {
+	fake.setMutex.Lock()
+	defer fake.setMutex.Unlock()
+	fake.SetStub = nil
+	fake.setReturns = struct {
+		result1 error
+	}{result1}
+}
+
+func (fake *FakeCache) SetReturnsOnCall(i int, result1 error) {
+	fake.setMutex.Lock()
+	defer fake.setMutex.Unlock()
+	fake.SetStub = nil
+	if fake.setReturnsOnCall == nil {
+		fake.setReturnsOnCall = make(map[int]struct {
+			result1 error
+		})
+	}
+	fake.setReturnsOnCall[i] = struct {
+		result1 error
+	}{result1}
+}
+
 func (fake *FakeCache) Invocations() map[string][][]interface{} {
 	fake.invocationsMutex.RLock()
 	defer fake.invocationsMutex.RUnlock()
 	fake.getMutex.RLock()
 	defer fake.getMutex.RUnlock()
+	fake.setMutex.RLock()
+	defer fake.setMutex.RUnlock()
 	copiedInvocations := map[string][][]interface{}{}
 	for key, value := range fake.invocations {
 		copiedInvocations[key] = value
diff --git a/internal/service/infohub/infohubfakes/fake_credentials.go b/internal/service/infohub/infohubfakes/fake_credentials.go
index 97a0a58..f08c93d 100644
--- a/internal/service/infohub/infohubfakes/fake_credentials.go
+++ b/internal/service/infohub/infohubfakes/fake_credentials.go
@@ -39,6 +39,19 @@ type FakeCredentials struct {
 		result1 *verifiable.Presentation
 		result2 error
 	}
+	ParsePresentationStub        func([]byte) (*verifiable.Presentation, error)
+	parsePresentationMutex       sync.RWMutex
+	parsePresentationArgsForCall []struct {
+		arg1 []byte
+	}
+	parsePresentationReturns struct {
+		result1 *verifiable.Presentation
+		result2 error
+	}
+	parsePresentationReturnsOnCall map[int]struct {
+		result1 *verifiable.Presentation
+		result2 error
+	}
 	invocations      map[string][][]interface{}
 	invocationsMutex sync.RWMutex
 }
@@ -185,6 +198,75 @@ func (fake *FakeCredentials) NewPresentationReturnsOnCall(i int, result1 *verifi
 	}{result1, result2}
 }
 
+func (fake *FakeCredentials) ParsePresentation(arg1 []byte) (*verifiable.Presentation, error) {
+	var arg1Copy []byte
+	if arg1 != nil {
+		arg1Copy = make([]byte, len(arg1))
+		copy(arg1Copy, arg1)
+	}
+	fake.parsePresentationMutex.Lock()
+	ret, specificReturn := fake.parsePresentationReturnsOnCall[len(fake.parsePresentationArgsForCall)]
+	fake.parsePresentationArgsForCall = append(fake.parsePresentationArgsForCall, struct {
+		arg1 []byte
+	}{arg1Copy})
+	stub := fake.ParsePresentationStub
+	fakeReturns := fake.parsePresentationReturns
+	fake.recordInvocation("ParsePresentation", []interface{}{arg1Copy})
+	fake.parsePresentationMutex.Unlock()
+	if stub != nil {
+		return stub(arg1)
+	}
+	if specificReturn {
+		return ret.result1, ret.result2
+	}
+	return fakeReturns.result1, fakeReturns.result2
+}
+
+func (fake *FakeCredentials) ParsePresentationCallCount() int {
+	fake.parsePresentationMutex.RLock()
+	defer fake.parsePresentationMutex.RUnlock()
+	return len(fake.parsePresentationArgsForCall)
+}
+
+func (fake *FakeCredentials) ParsePresentationCalls(stub func([]byte) (*verifiable.Presentation, error)) {
+	fake.parsePresentationMutex.Lock()
+	defer fake.parsePresentationMutex.Unlock()
+	fake.ParsePresentationStub = stub
+}
+
+func (fake *FakeCredentials) ParsePresentationArgsForCall(i int) []byte {
+	fake.parsePresentationMutex.RLock()
+	defer fake.parsePresentationMutex.RUnlock()
+	argsForCall := fake.parsePresentationArgsForCall[i]
+	return argsForCall.arg1
+}
+
+func (fake *FakeCredentials) ParsePresentationReturns(result1 *verifiable.Presentation, result2 error) {
+	fake.parsePresentationMutex.Lock()
+	defer fake.parsePresentationMutex.Unlock()
+	fake.ParsePresentationStub = nil
+	fake.parsePresentationReturns = struct {
+		result1 *verifiable.Presentation
+		result2 error
+	}{result1, result2}
+}
+
+func (fake *FakeCredentials) ParsePresentationReturnsOnCall(i int, result1 *verifiable.Presentation, result2 error) {
+	fake.parsePresentationMutex.Lock()
+	defer fake.parsePresentationMutex.Unlock()
+	fake.ParsePresentationStub = nil
+	if fake.parsePresentationReturnsOnCall == nil {
+		fake.parsePresentationReturnsOnCall = make(map[int]struct {
+			result1 *verifiable.Presentation
+			result2 error
+		})
+	}
+	fake.parsePresentationReturnsOnCall[i] = struct {
+		result1 *verifiable.Presentation
+		result2 error
+	}{result1, result2}
+}
+
 func (fake *FakeCredentials) Invocations() map[string][][]interface{} {
 	fake.invocationsMutex.RLock()
 	defer fake.invocationsMutex.RUnlock()
@@ -192,6 +274,8 @@ func (fake *FakeCredentials) Invocations() map[string][][]interface{} {
 	defer fake.newCredentialMutex.RUnlock()
 	fake.newPresentationMutex.RLock()
 	defer fake.newPresentationMutex.RUnlock()
+	fake.parsePresentationMutex.RLock()
+	defer fake.parsePresentationMutex.RUnlock()
 	copiedInvocations := map[string][][]interface{}{}
 	for key, value := range fake.invocations {
 		copiedInvocations[key] = value
diff --git a/internal/service/infohub/service.go b/internal/service/infohub/service.go
index c793ca6..d5b1299 100644
--- a/internal/service/infohub/service.go
+++ b/internal/service/infohub/service.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"encoding/json"
 
+	"github.com/google/uuid"
 	"github.com/hyperledger/aries-framework-go/pkg/doc/verifiable"
 	"go.uber.org/zap"
 
@@ -30,11 +31,13 @@ type Policy interface {
 
 type Cache interface {
 	Get(ctx context.Context, key, namespace, scope string) ([]byte, error)
+	Set(ctx context.Context, key, namespace, scope string, value []byte) error
 }
 
 type Credentials interface {
 	NewCredential(contexts []string, subjectID string, subject map[string]interface{}, proof bool) (*verifiable.Credential, error)
 	NewPresentation(contexts []string, credentials ...*verifiable.Credential) (*verifiable.Presentation, error)
+	ParsePresentation(vpBytes []byte) (*verifiable.Presentation, error)
 }
 
 type Signer interface {
@@ -61,8 +64,60 @@ func New(storage Storage, policy Policy, cache Cache, cred Credentials, signer S
 	}
 }
 
+// Import the given data wrapped as Verifiable Presentation into the Cache.
+func (s *Service) Import(ctx context.Context, req *infohub.ImportRequest) (res *infohub.ImportResult, err error) {
+	logger := s.logger.With(zap.String("operation", "import"))
+
+	vp, err := s.credentials.ParsePresentation(req.Data)
+	if err != nil {
+		logger.Error("error parsing verifiable presentation", zap.Error(err))
+		return nil, err
+	}
+
+	// separate data entries can be wrapped in separate verifiable credentials;
+	// each one of them must be placed separately in the cache
+	var importedCredentials []string
+	for _, credential := range vp.Credentials() {
+		cred, ok := credential.(map[string]interface{})
+		if !ok {
+			logger.Warn("verifiable presentation contains unknown credential type")
+			return nil, errors.New(errors.BadRequest, "verifiable presentation contains unknown credential type")
+		}
+
+		if cred["credentialSubject"] == nil {
+			logger.Error("verifiable credential doesn't contain subject")
+			return nil, errors.New(errors.BadRequest, "verifiable credential doesn't contain subject")
+		}
+
+		subject, ok := cred["credentialSubject"].(map[string]interface{})
+		if !ok {
+			logger.Error("verifiable credential subject is not a map object")
+			return nil, errors.New(errors.BadRequest, "verifiable credential subject is not a map object")
+		}
+
+		subjectBytes, err := json.Marshal(subject)
+		if err != nil {
+			logger.Error("error encoding subject to json", zap.Error(err))
+			return nil, errors.New("error encoding subject to json")
+		}
+
+		importID := uuid.NewString()
+		if err := s.cache.Set(ctx, importID, "", "", subjectBytes); err != nil {
+			logger.Error("error saving imported data to cache", zap.Error(err))
+			continue
+		}
+		importedCredentials = append(importedCredentials, importID)
+	}
+
+	return &infohub.ImportResult{ImportIds: importedCredentials}, nil
+}
+
 func (s *Service) Export(ctx context.Context, req *infohub.ExportRequest) (interface{}, error) {
-	logger := s.logger.With(zap.String("exportName", req.ExportName))
+	logger := s.logger.With(
+		zap.String("operation", "export"),
+		zap.String("exportName", req.ExportName),
+	)
+
 	exportCfg, err := s.storage.ExportConfiguration(ctx, req.ExportName)
 	if err != nil {
 		logger.Error("error getting export configuration", zap.Error(err))
diff --git a/vendor/code.vereign.com/gaiax/tsa/golib/goadec/bytes_decoder.go b/vendor/code.vereign.com/gaiax/tsa/golib/goadec/bytes_decoder.go
new file mode 100644
index 0000000000000000000000000000000000000000..b3ce113e4a8e168e391219377381dbf375f04b0e
GIT binary patch
literal 758
zcmZXSJ#XAF42EaxSFi?J$cMllx*513L5gM!g4>D?vP|D8aU@qC4i1O=?<M__#K>Zk
z6v_7ysj7lUrC-z$;pkM08V2u1(_(<%PQerYOBf>DqjoL&P>NxbErfX;oR1(ymUpi)
zr=M~2w=yn8)3akSR;L(;T!@Th*o2&nl61_`R%BM=&n2Z0qN?ET95F1i;4vm|11Jj*
zhr=WJGgjl;GY~YhK|HC*mCuQQJ*qZP2)DTncP&MitllU{4?CtRmkSp>jVZ7qTtqfg
zfW^1ZaMG7|gi(mA5b-=9Od4xhh(75ETn)xidz!K5)Cu8BtB;%lcAG{Y`aeF$FTMtU
zeQzk+ZCSBwPL5z~MBgcmmums&z4J`f+^`Mxo|Ji86X9*&%eM4|$V@jKf7^pKJ?F1O
zpdyi7MHb#n)F;r~VJsyNpx`{M0>X}NZ|t$AvYm!K%&u&x@qGU0FW=?;yb)Q8ol1S=
z&@yg%Rpl?n?PRW%wJt6jUpHGrvLOxp46%h#^$CGFYWRN0Z$Oh8fv(FlJq_zW2!r|o

literal 0
HcmV?d00001

diff --git a/vendor/modules.txt b/vendor/modules.txt
index c042d0447de65efe01815982ff797e8bc84167fb..0e3f360843717aff94996dd0e5bc07478aa720b6 100644
GIT binary patch
delta 19
bcmZ2ka<OE>8P@#7l+@&jjY=C&$5{dZUu_8o

delta 10
Rcmcauva)2tnT=m!Ede5{1^NI0

-- 
GitLab