diff --git a/Makefile b/Makefile index e03eb7b..9b43db5 100644 --- a/Makefile +++ b/Makefile @@ -81,6 +81,8 @@ mock: ## Generate all the mocks (for tests) @mockgen -source=credential/parser.go -package testutil -destination testutil/credential_mocks.go @mockgen -package testutil -destination testutil/dataverse_client_mocks.go -mock_names QueryClient=MockDataverseQueryClient github.com/axone-protocol/axone-contract-schema/go/dataverse-schema/v5 QueryClient @mockgen -package testutil -destination testutil/cognitarium_client_mocks.go -mock_names QueryClient=MockCognitariumQueryClient github.com/axone-protocol/axone-contract-schema/go/cognitarium-schema/v5 QueryClient + @mockgen -package testutil -destination testutil/signer_mocks.go github.com/hyperledger/aries-framework-go/pkg/doc/verifiable Signer + @mockgen -source=credential/generate.go -package testutil -destination testutil/generate_mocks.go ## Help: .PHONY: help diff --git a/credential/auth.go b/credential/auth.go index 0c1e9c1..a37173f 100644 --- a/credential/auth.go +++ b/credential/auth.go @@ -45,12 +45,12 @@ func (ac *AuthClaim) From(vc *verifiable.Credential) error { var _ Parser[*AuthClaim] = (*AuthParser)(nil) type AuthParser struct { - *credentialParser + *DefaultParser } func NewAuthParser(documentLoader ld.DocumentLoader) *AuthParser { return &AuthParser{ - credentialParser: &credentialParser{documentLoader: documentLoader}, + DefaultParser: &DefaultParser{documentLoader: documentLoader}, } } diff --git a/credential/auth_test.go b/credential/auth_test.go index 3e554fa..b849a7a 100644 --- a/credential/auth_test.go +++ b/credential/auth_test.go @@ -8,22 +8,10 @@ import ( "testing" "github.com/axone-protocol/axone-sdk/credential" - jld "github.com/hyperledger/aries-framework-go/pkg/doc/ld" - "github.com/hyperledger/aries-framework-go/pkg/doc/ldcontext" - mockldstore "github.com/hyperledger/aries-framework-go/pkg/mock/ld" - mockprovider "github.com/hyperledger/aries-framework-go/pkg/mock/provider" + "github.com/axone-protocol/axone-sdk/testutil" . "github.com/smartystreets/goconvey/convey" ) -var ( - //go:embed testdata/contexts/credentials-v1.jsonld - mockCredentialsV1JSONLD []byte - //go:embed testdata/contexts/authentication-v4.jsonld - mockAuthenticationV4JSONLD []byte - //go:embed testdata/contexts/security-v2.jsonld - mockSecurityV2JSONLD []byte -) - func TestAuthParser_ParseSigned(t *testing.T) { tests := []struct { name string @@ -114,20 +102,7 @@ func TestAuthParser_ParseSigned(t *testing.T) { raw, err := os.ReadFile(test.file) So(err, ShouldBeNil) - docLoader, err := jld.NewDocumentLoader(createMockCtxProvider(), jld.WithExtraContexts( - ldcontext.Document{ - URL: "https://w3id.org/axone/ontology/v4/schema/credential/digital-service/authentication/", - Content: mockAuthenticationV4JSONLD, - }, - ldcontext.Document{ - URL: "https://www.w3.org/2018/credentials/v1", - Content: mockCredentialsV1JSONLD, - }, - ldcontext.Document{ - URL: "https://w3id.org/security/v2", - Content: mockSecurityV2JSONLD, - }, - )) + docLoader, err := testutil.MockDocumentLoader() So(err, ShouldBeNil) parser := credential.NewAuthParser(docLoader) @@ -149,12 +124,3 @@ func TestAuthParser_ParseSigned(t *testing.T) { }) } } - -func createMockCtxProvider() *mockprovider.Provider { - p := &mockprovider.Provider{ - ContextStoreValue: mockldstore.NewMockContextStore(), - RemoteProviderStoreValue: mockldstore.NewMockRemoteProviderStore(), - } - - return p -} diff --git a/credential/errors.go b/credential/errors.go index 0f923dc..2c11f20 100644 --- a/credential/errors.go +++ b/credential/errors.go @@ -14,6 +14,10 @@ const ( ErrExtractClaim MessageError = "failed to extract claim" ErrParse MessageError = "failed to parse verifiable credential" ErrMalformed MessageError = "malformed verifiable credential" + + ErrSign MessageError = "failed to sign verifiable credential" + ErrNoParser MessageError = "no parser provided" + ErrGenerate MessageError = "failed to Generate verifiable credential" ) type VCError struct { diff --git a/credential/generate.go b/credential/generate.go new file mode 100644 index 0000000..c897a2c --- /dev/null +++ b/credential/generate.go @@ -0,0 +1,95 @@ +package credential + +import ( + "bytes" + "time" + + "github.com/hyperledger/aries-framework-go/pkg/doc/signature/jsonld" + "github.com/hyperledger/aries-framework-go/pkg/doc/signature/suite" + "github.com/hyperledger/aries-framework-go/pkg/doc/signature/suite/ecdsasecp256k1signature2019" + "github.com/hyperledger/aries-framework-go/pkg/doc/verifiable" +) + +// Generator is a verifiable credential generator. +type Generator struct { + vc Descriptor + signer verifiable.Signer + signerDID string + parser *DefaultParser +} + +// New allow to Generate a verifiable credential with the given credential descriptor. +// Example: +// +// vc, err := credential.New( +// template.NewGovernance( +// "datasetID", +// "addr", +// WithID[*GovernanceDescriptor]("id") +// ), +// WithParser(parser), +// WithSignature(signer, "did:key:...")). // Signature is optional and Generate a not signed VC if not provided. +// Generate() +func New(descriptor Descriptor, opts ...Option) *Generator { + g := &Generator{ + vc: descriptor, + } + for _, opt := range opts { + opt(g) + } + return g +} + +// Option is a function that configures a Generator. +type Option func(*Generator) + +func WithParser(parser *DefaultParser) Option { + return func(g *Generator) { + g.parser = parser + } +} + +func WithSigner(signer verifiable.Signer, did string) Option { + return func(g *Generator) { + g.signer = signer + g.signerDID = did + } +} + +func (generator *Generator) Generate() (*verifiable.Credential, error) { + raw, err := generator.vc.Generate() + if err != nil { + return nil, NewVCError(ErrGenerate, err) + } + + if generator.parser == nil { + return nil, NewVCError(ErrNoParser, nil) + } + cred, err := generator.parser.parse(raw.Bytes()) + if err != nil { + return nil, NewVCError(ErrParse, err) + } + + if generator.signer != nil { + err := cred.AddLinkedDataProof(&verifiable.LinkedDataProofContext{ + Created: generator.vc.IssuedAt(), + SignatureType: "EcdsaSecp256k1Signature2019", + Suite: ecdsasecp256k1signature2019.New(suite.WithSigner(generator.signer)), + SignatureRepresentation: verifiable.SignatureJWS, + VerificationMethod: generator.signerDID, + Purpose: generator.vc.ProofPurpose(), + }, jsonld.WithDocumentLoader(generator.parser.documentLoader)) + if err != nil { + return nil, NewVCError(ErrSign, err) + } + } + + return cred, nil +} + +// Descriptor is an interface representing the description of a verifiable credential. +type Descriptor interface { + IssuedAt() *time.Time + Generate() (*bytes.Buffer, error) + ProofPurpose() string +} diff --git a/credential/generate_test.go b/credential/generate_test.go new file mode 100644 index 0000000..8dd985f --- /dev/null +++ b/credential/generate_test.go @@ -0,0 +1,116 @@ +//nolint:lll +package credential_test + +import ( + "bytes" + "errors" + "testing" + + "github.com/axone-protocol/axone-sdk/credential" + "github.com/axone-protocol/axone-sdk/testutil" + . "github.com/smartystreets/goconvey/convey" + "go.uber.org/mock/gomock" +) + +func TestGenerator_Generate(t *testing.T) { + t.Run("without parser", func(t *testing.T) { + Convey("Given a credential generator with mocked descriptor", t, func() { + controller := gomock.NewController(t) + defer controller.Finish() + + mockDescriptor := testutil.NewMockDescriptor(controller) + mockDescriptor.EXPECT().Generate().Return(nil, nil).Times(1) + generator := credential.New(mockDescriptor) + + Convey("When generating a credential", func() { + vc, err := generator.Generate() + Convey("Then an error should be returned", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, "no parser provided") + So(vc, ShouldBeNil) + }) + }) + }) + }) + + t.Run("error generate", func(t *testing.T) { + Convey("Given a credential generator with mocked descriptor", t, func() { + controller := gomock.NewController(t) + defer controller.Finish() + + mockDescriptor := testutil.NewMockDescriptor(controller) + mockDescriptor.EXPECT().Generate().Return(nil, errors.New("failed")).Times(1) + generator := credential.New(mockDescriptor) + + Convey("When generating a credential", func() { + vc, err := generator.Generate() + Convey("Then an error should be returned", func() { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, credential.NewVCError(credential.ErrGenerate, errors.New("failed")).Error()) + So(vc, ShouldBeNil) + }) + }) + }) + }) + + t.Run("with signer", func(t *testing.T) { + Convey("Given a credential generator with mocked descriptor", t, func() { + controller := gomock.NewController(t) + defer controller.Finish() + + buf := bytes.NewBufferString(`{"@context":["https://www.w3.org/2018/credentials/v1"],"type":["VerifiableCredential"],"credentialSubject":{"id":"did:example:123"}}`) + mockDescriptor := testutil.NewMockDescriptor(controller) + mockDescriptor.EXPECT().Generate().Return(buf, nil).Times(1) + mockDescriptor.EXPECT().IssuedAt().Times(1) + mockDescriptor.EXPECT().ProofPurpose().Return("proof").Times(1) + + mockSigner := testutil.NewMockSigner(controller) + mockSigner.EXPECT().Sign(gomock.Any()).Return([]byte("signature"), nil).Times(1) + mockSigner.EXPECT().Alg().AnyTimes() + + loader, _ := testutil.MockDocumentLoader() + + generator := credential.New(mockDescriptor, + credential.WithParser(credential.NewDefaultParser(loader)), + credential.WithSigner(mockSigner, "did:example:123"), + ) + + Convey("When generating a credential", func() { + vc, err := generator.Generate() + Convey("Then VC should be returned with proofs", func() { + So(err, ShouldBeNil) + So(vc, ShouldNotBeNil) + So(len(vc.Proofs), ShouldEqual, 1) + So(vc.Proofs[0]["verificationMethod"], ShouldEqual, "did:example:123") + So(vc.Proofs[0]["proofPurpose"], ShouldEqual, "proof") + }) + }) + }) + }) + + t.Run("without signer", func(t *testing.T) { + Convey("Given a credential generator with mocked descriptor", t, func() { + controller := gomock.NewController(t) + defer controller.Finish() + + buf := bytes.NewBufferString(`{"@context":["https://www.w3.org/2018/credentials/v1"],"type":["VerifiableCredential"],"credentialSubject":{"id":"did:example:123"}}`) + mockDescriptor := testutil.NewMockDescriptor(controller) + mockDescriptor.EXPECT().Generate().Return(buf, nil).Times(1) + + loader, _ := testutil.MockDocumentLoader() + + generator := credential.New(mockDescriptor, + credential.WithParser(credential.NewDefaultParser(loader)), + ) + + Convey("When generating a credential", func() { + vc, err := generator.Generate() + Convey("Then VC should be returned without proofs", func() { + So(err, ShouldBeNil) + So(vc, ShouldNotBeNil) + So(len(vc.Proofs), ShouldEqual, 0) + }) + }) + }) + }) +} diff --git a/credential/parser.go b/credential/parser.go index 1594142..1bc2b80 100644 --- a/credential/parser.go +++ b/credential/parser.go @@ -21,11 +21,28 @@ type Parser[T Claim] interface { ParseSigned(raw []byte) (T, error) } -type credentialParser struct { +type DefaultParser struct { documentLoader ld.DocumentLoader } -func (cp *credentialParser) parseSigned(raw []byte) (*verifiable.Credential, error) { +func NewDefaultParser(documentLoader ld.DocumentLoader) *DefaultParser { + return &DefaultParser{documentLoader: documentLoader} +} + +func (cp *DefaultParser) parse(raw []byte) (*verifiable.Credential, error) { + vc, err := verifiable.ParseCredential( + raw, + verifiable.WithJSONLDValidation(), + verifiable.WithPublicKeyFetcher(NewVDRKeyResolverWithSecp256k1(Secp256k1PubKeyFetcher).PublicKeyFetcher), + verifiable.WithJSONLDDocumentLoader(cp.documentLoader), + ) + if err != nil { + return nil, NewVCError(ErrParse, err) + } + return vc, nil +} + +func (cp *DefaultParser) parseSigned(raw []byte) (*verifiable.Credential, error) { vc, err := verifiable.ParseCredential( raw, verifiable.WithJSONLDValidation(), diff --git a/credential/template/dataset.go b/credential/template/dataset.go new file mode 100644 index 0000000..8bbd772 --- /dev/null +++ b/credential/template/dataset.go @@ -0,0 +1,101 @@ +package template + +import ( + "bytes" + _ "embed" + gotemplate "text/template" + "time" + + "github.com/axone-protocol/axone-sdk/credential" + "github.com/axone-protocol/axone-sdk/dataverse" + "github.com/google/uuid" +) + +//go:embed vc-desc-tpl.jsonld +var datasetTemplate string + +var _ credential.Descriptor = &DatasetDescriptor{} + +// DatasetDescriptor is a descriptor for generate a dataset description VC. +// See https://docs.axone.xyz/ontology/next/schemas/credential-dataset-description +type DatasetDescriptor struct { + id string + datasetDID string + title string + description string + format string + tags []string + topic string + issuanceDate *time.Time +} + +func NewDataset(datasetDID, title string, opts ...Option[*DatasetDescriptor]) *DatasetDescriptor { + t := time.Now().UTC() + d := &DatasetDescriptor{ + id: uuid.New().String(), + datasetDID: datasetDID, + title: title, + issuanceDate: &t, + } + for _, opt := range opts { + opt(d) + } + return d +} + +func (d *DatasetDescriptor) setID(id string) { + d.id = id +} + +func (d *DatasetDescriptor) setDescription(description string) { + d.description = description +} + +func (d *DatasetDescriptor) setFormat(format string) { + d.format = format +} + +func (d *DatasetDescriptor) setTags(tags []string) { + d.tags = tags +} + +func (d *DatasetDescriptor) setTopic(topic string) { + d.topic = topic +} + +func (d *DatasetDescriptor) setIssuanceDate(t time.Time) { + d.issuanceDate = &t +} + +func (d *DatasetDescriptor) IssuedAt() *time.Time { + return d.issuanceDate +} + +func (d *DatasetDescriptor) ProofPurpose() string { + return "assertionMethod" +} + +func (d *DatasetDescriptor) Generate() (*bytes.Buffer, error) { + tpl, err := gotemplate.New("datasetDescriptionVC").Parse(datasetTemplate) + if err != nil { + return nil, err + } + + buf := bytes.Buffer{} + err = tpl.Execute(&buf, map[string]any{ + "NamespacePrefix": dataverse.W3IDPrefix, + "CredID": d.id, + "DatasetDID": d.datasetDID, + "Title": d.title, + "Description": d.description, + "Format": d.format, + "Tags": d.tags, + "Topic": d.topic, + "IssuedAt": d.issuanceDate.Format(time.RFC3339), + }) + if err != nil { + return nil, err + } + + return &buf, nil +} diff --git a/credential/template/dataset_test.go b/credential/template/dataset_test.go new file mode 100644 index 0000000..0ae6999 --- /dev/null +++ b/credential/template/dataset_test.go @@ -0,0 +1,90 @@ +package template + +import ( + "testing" + "time" + + "github.com/axone-protocol/axone-sdk/credential" + "github.com/axone-protocol/axone-sdk/testutil" + "github.com/hyperledger/aries-framework-go/pkg/doc/verifiable" + . "github.com/smartystreets/goconvey/convey" +) + +func TestDatasetDescriptor_Generate(t *testing.T) { + tests := []struct { + name string + vc credential.Descriptor + wantErr error + check func(*verifiable.Credential) + }{ + { + name: "Valid dataset VC", + vc: NewDataset( + "datasetID", + "title", + WithID[*DatasetDescriptor]("id"), + WithDescription[*DatasetDescriptor]("description"), + WithFormat[*DatasetDescriptor]("format"), + WithTags[*DatasetDescriptor]([]string{"tag1", "tag2"}), + WithTopic[*DatasetDescriptor]("topic"), + WithIssuanceDate[*DatasetDescriptor](time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + ), + check: func(vc *verifiable.Credential) { + So(vc.ID, ShouldEqual, "https://w3id.org/axone/ontology/v4/schema/credential/dataset/description/id") + So(vcSubject(vc).ID, ShouldEqual, "datasetID") + So(vc.Issuer.ID, ShouldEqual, "datasetID") + So(vcSubject(vc).CustomFields["hasTitle"], ShouldEqual, "title") + So(vcSubject(vc).CustomFields["hasDescription"], ShouldEqual, "description") + So(vcSubject(vc).CustomFields["hasFormat"], ShouldEqual, "format") + So(vcSubject(vc).CustomFields["hasTag"], ShouldResemble, []interface{}{"tag1", "tag2"}) + So(vcSubject(vc).CustomFields["hasTopic"], ShouldEqual, "topic") + So(vc.Issued.Time, ShouldEqual, time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)) + }, + }, + { + name: "Valid dataset VC without options", + vc: NewDataset("datasetID", "title"), + check: func(vc *verifiable.Credential) { + So(vc.ID, ShouldStartWith, "https://w3id.org/axone/ontology/v4/schema/credential/dataset/description/") + So(vcSubject(vc).ID, ShouldEqual, "datasetID") + So(vc.Issuer.ID, ShouldEqual, "datasetID") + So(vcSubject(vc).CustomFields["hasTitle"], ShouldEqual, "title") + So(vcSubject(vc).CustomFields["hasDescription"], ShouldEqual, "") + So(vcSubject(vc).CustomFields["hasFormat"], ShouldEqual, "") + So(vcSubject(vc).CustomFields["hasTag"], ShouldResemble, []interface{}{}) + So(vcSubject(vc).CustomFields["hasTopic"], ShouldEqual, "") + So(vc.Issued.Time, ShouldHappenWithin, time.Second, time.Now().UTC()) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + Convey("Given a credential generator", t, func() { + docLoader, err := testutil.MockDocumentLoader() + So(err, ShouldBeNil) + + parser := credential.NewDefaultParser(docLoader) + generator := credential.New( + test.vc, + credential.WithParser(parser)) + + Convey("When a dataset VC is generated", func() { + vc, err := generator.Generate() + + Convey("Then the dataset VC should be generated", func() { + if test.wantErr != nil { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, test.wantErr.Error()) + So(vc, ShouldBeNil) + } else { + So(vc, ShouldNotBeNil) + test.check(vc) + So(err, ShouldBeNil) + } + }) + }) + }) + }) + } +} diff --git a/credential/template/governance.go b/credential/template/governance.go new file mode 100644 index 0000000..74367bf --- /dev/null +++ b/credential/template/governance.go @@ -0,0 +1,80 @@ +package template + +import ( + "bytes" + _ "embed" + gotemplate "text/template" + "time" + + "github.com/axone-protocol/axone-sdk/credential" + "github.com/axone-protocol/axone-sdk/dataverse" + "github.com/google/uuid" +) + +//go:embed vc-gov-tpl.jsonld +var governanceTemplate string + +var _ credential.Descriptor = &GovernanceDescriptor{} + +// GovernanceDescriptor is a descriptor for generate a credential governance text VC. +// See https://docs.axone.xyz/ontology/next/schemas/credential-governance-text +type GovernanceDescriptor struct { + id string + datasetDID string + govAddr string + issuanceDate *time.Time +} + +// NewGovernance creates a new governance verifiable credential descriptor. +// DatasetDID and GovAddr are required. If ID is not provided, it will be generated. +// If issuance date is not provided, it will be set to the current time at descriptor instantiation. +func NewGovernance(datasetDID, govAddr string, opts ...Option[*GovernanceDescriptor]) *GovernanceDescriptor { + t := time.Now().UTC() + g := &GovernanceDescriptor{ + id: uuid.New().String(), + datasetDID: datasetDID, + govAddr: govAddr, + issuanceDate: &t, + } + for _, opt := range opts { + opt(g) + } + return g +} + +func (g *GovernanceDescriptor) setID(id string) { + g.id = id +} + +func (g *GovernanceDescriptor) setIssuanceDate(t time.Time) { + g.issuanceDate = &t +} + +func (g *GovernanceDescriptor) IssuedAt() *time.Time { + return g.issuanceDate +} + +func (g *GovernanceDescriptor) ProofPurpose() string { + return "authentication" +} + +func (g *GovernanceDescriptor) Generate() (*bytes.Buffer, error) { + tpl, err := gotemplate.New("governanceVC").Parse(governanceTemplate) + if err != nil { + return nil, err + } + + buf := bytes.Buffer{} + err = tpl.Execute(&buf, map[string]string{ + "NamespacePrefix": dataverse.W3IDPrefix, + "CredID": g.id, + "DatasetDID": g.datasetDID, + "GovAddr": g.govAddr, + "IssuedAt": g.issuanceDate.Format(time.RFC3339), + }) + if err != nil { + return nil, err + } + + return &buf, nil +} diff --git a/credential/template/governance_test.go b/credential/template/governance_test.go new file mode 100644 index 0000000..e4ac1a1 --- /dev/null +++ b/credential/template/governance_test.go @@ -0,0 +1,90 @@ +package template + +import ( + "testing" + "time" + + "github.com/axone-protocol/axone-sdk/credential" + "github.com/axone-protocol/axone-sdk/testutil" + "github.com/hyperledger/aries-framework-go/pkg/doc/verifiable" + . "github.com/smartystreets/goconvey/convey" +) + +func TestGovernanceDescriptor_Generate(t *testing.T) { + tests := []struct { + name string + vc credential.Descriptor + wantErr error + check func(*verifiable.Credential) + }{ + { + name: "Valid governance VC", + vc: NewGovernance( + "datasetID", + "addr", + WithID[*GovernanceDescriptor]("id"), + WithIssuanceDate[*GovernanceDescriptor](time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + ), + check: func(vc *verifiable.Credential) { + So(vc.ID, ShouldEqual, "https://w3id.org/axone/ontology/v4/schema/credential/governance/text/id") + So(vcSubject(vc).ID, ShouldEqual, "datasetID") + So(vc.Issuer.ID, ShouldEqual, "datasetID") + So(vcSubject(vc).CustomFields["isGovernedBy"].(map[string]interface{})["fromGovernance"], ShouldEqual, "addr") + So(vc.Issued.Time, ShouldEqual, time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)) + }, + }, + { + name: "Valid governance VC with default value", + vc: NewGovernance( + "datasetID", + "addr", + ), + check: func(vc *verifiable.Credential) { + So(vc.ID, ShouldStartWith, "https://w3id.org/axone/ontology/v4/schema/credential/governance/text/") + So(vcSubject(vc).ID, ShouldEqual, "datasetID") + So(vc.Issuer.ID, ShouldEqual, "datasetID") + So(vcSubject(vc).CustomFields["isGovernedBy"].(map[string]interface{})["fromGovernance"], ShouldEqual, "addr") + So(vc.Issued.Time, ShouldHappenWithin, time.Second, time.Now().UTC()) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + Convey("Given a credential generator", t, func() { + docLoader, err := testutil.MockDocumentLoader() + So(err, ShouldBeNil) + + parser := credential.NewDefaultParser(docLoader) + generator := credential.New( + test.vc, + credential.WithParser(parser)) + + Convey("When a governance VC is generated", func() { + vc, err := generator.Generate() + + Convey("Then the governance VC should be generated", func() { + if test.wantErr != nil { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, test.wantErr.Error()) + So(vc, ShouldBeNil) + } else { + So(vc, ShouldNotBeNil) + test.check(vc) + So(err, ShouldBeNil) + } + }) + }) + }) + }) + } +} + +func vcSubject(vc *verifiable.Credential) verifiable.Subject { + subjects, ok := vc.Subject.([]verifiable.Subject) + if !ok { + panic("invalid subject type") + } + + return subjects[0] +} diff --git a/credential/template/options.go b/credential/template/options.go new file mode 100644 index 0000000..0690d39 --- /dev/null +++ b/credential/template/options.go @@ -0,0 +1,100 @@ +package template + +import ( + "time" + + "github.com/axone-protocol/axone-sdk/credential" +) + +type Option[T credential.Descriptor] func(descriptor T) + +type HasID interface { + setID(string) +} + +func WithID[T interface { + HasID + credential.Descriptor +}](id string) Option[T] { + return func(descriptor T) { + descriptor.setID(id) + } +} + +type HasDatasetDID interface { + setDatasetDID(string) +} + +func WithDatasetDID[T interface { + HasDatasetDID + credential.Descriptor +}](did string) Option[T] { + return func(descriptor T) { + descriptor.setDatasetDID(did) + } +} + +type HasDescription interface { + setDescription(string) +} + +func WithDescription[T interface { + HasDescription + credential.Descriptor +}](description string) Option[T] { + return func(descriptor T) { + descriptor.setDescription(description) + } +} + +type HasFormat interface { + setFormat(string) +} + +func WithFormat[T interface { + HasFormat + credential.Descriptor +}](format string) Option[T] { + return func(descriptor T) { + descriptor.setFormat(format) + } +} + +type HasTags interface { + setTags([]string) +} + +func WithTags[T interface { + HasTags + credential.Descriptor +}](tags []string) Option[T] { + return func(descriptor T) { + descriptor.setTags(tags) + } +} + +type HasTopic interface { + setTopic(string) +} + +func WithTopic[T interface { + HasTopic + credential.Descriptor +}](topic string) Option[T] { + return func(descriptor T) { + descriptor.setTopic(topic) + } +} + +type HasIssuanceDate interface { + setIssuanceDate(time.Time) +} + +func WithIssuanceDate[T interface { + HasIssuanceDate + credential.Descriptor +}](t time.Time) Option[T] { + return func(descriptor T) { + descriptor.setIssuanceDate(t) + } +} diff --git a/credential/template/publication.go b/credential/template/publication.go new file mode 100644 index 0000000..460ca72 --- /dev/null +++ b/credential/template/publication.go @@ -0,0 +1,82 @@ +package template + +import ( + "bytes" + _ "embed" + gotemplate "text/template" + "time" + + "github.com/axone-protocol/axone-sdk/credential" + "github.com/axone-protocol/axone-sdk/dataverse" + "github.com/google/uuid" +) + +//go:embed vc-publication-tpl.jsonld +var publicationTemplate string + +var _ credential.Descriptor = &PublicationDescriptor{} + +// PublicationDescriptor is a descriptor for generate a digital resource publication VC. +// See https://docs.axone.xyz/ontology/next/schemas/credential-digital-resource-publication +type PublicationDescriptor struct { + id string + datasetDID string + datasetURI string + storageDID string + issuanceDate *time.Time +} + +func NewPublication(datasetDID, datasetURI, storageDID string, + opts ...Option[*PublicationDescriptor], +) *PublicationDescriptor { + t := time.Now().UTC() + p := &PublicationDescriptor{ + id: uuid.New().String(), + datasetDID: datasetDID, + datasetURI: datasetURI, + storageDID: storageDID, + issuanceDate: &t, + } + for _, opt := range opts { + opt(p) + } + return p +} + +func (d *PublicationDescriptor) setID(id string) { + d.id = id +} + +func (d *PublicationDescriptor) setIssuanceDate(t time.Time) { + d.issuanceDate = &t +} + +func (d *PublicationDescriptor) IssuedAt() *time.Time { + return d.issuanceDate +} + +func (d *PublicationDescriptor) ProofPurpose() string { + return "assertionMethod" +} + +func (d *PublicationDescriptor) Generate() (*bytes.Buffer, error) { + tpl, err := gotemplate.New("publicationVC").Parse(publicationTemplate) + if err != nil { + return nil, err + } + + buf := bytes.Buffer{} + err = tpl.Execute(&buf, map[string]any{ + "NamespacePrefix": dataverse.W3IDPrefix, + "CredID": d.id, + "DatasetDID": d.datasetDID, + "DatasetURI": d.datasetURI, + "StorageDID": d.storageDID, + "IssuedAt": d.issuanceDate.Format(time.RFC3339), + }) + if err != nil { + return nil, err + } + + return &buf, nil +} diff --git a/credential/template/publication_test.go b/credential/template/publication_test.go new file mode 100644 index 0000000..e9cd893 --- /dev/null +++ b/credential/template/publication_test.go @@ -0,0 +1,85 @@ +package template + +import ( + "testing" + "time" + + "github.com/axone-protocol/axone-sdk/credential" + "github.com/axone-protocol/axone-sdk/testutil" + "github.com/hyperledger/aries-framework-go/pkg/doc/verifiable" + . "github.com/smartystreets/goconvey/convey" +) + +func TestPublicationDescriptor_Generate(t *testing.T) { + tests := []struct { + name string + vc credential.Descriptor + wantErr error + check func(*verifiable.Credential) + }{ + { + name: "Valid publication VC", + vc: NewPublication( + "datasetID", + "datasetURI", + "storageID", + WithID[*PublicationDescriptor]("id"), + WithIssuanceDate[*PublicationDescriptor](time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)), + ), + check: func(vc *verifiable.Credential) { + So(vc.ID, ShouldEqual, "https://w3id.org/axone/ontology/v4/schema/credential/digital-resource/publication/id") + So(vcSubject(vc).ID, ShouldEqual, "datasetID") + So(vc.Issuer.ID, ShouldEqual, "storageID") + So(vcSubject(vc).CustomFields["hasIdentifier"], ShouldEqual, "datasetURI") + So(vcSubject(vc).CustomFields["servedBy"], ShouldEqual, "storageID") + So(vc.Issued.Time, ShouldEqual, time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)) + }, + }, + { + name: "Valid publication VC with default value", + vc: NewPublication( + "datasetID", + "datasetURI", + "storageID", + ), + check: func(vc *verifiable.Credential) { + So(vc.ID, ShouldStartWith, "https://w3id.org/axone/ontology/v4/schema/credential/digital-resource/publication/") + So(vcSubject(vc).ID, ShouldEqual, "datasetID") + So(vc.Issuer.ID, ShouldEqual, "storageID") + So(vcSubject(vc).CustomFields["hasIdentifier"], ShouldEqual, "datasetURI") + So(vcSubject(vc).CustomFields["servedBy"], ShouldEqual, "storageID") + So(vc.Issued.Time, ShouldHappenWithin, time.Second, time.Now().UTC()) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + Convey("Given a credential generator", t, func() { + docLoader, err := testutil.MockDocumentLoader() + So(err, ShouldBeNil) + + parser := credential.NewDefaultParser(docLoader) + generator := credential.New( + test.vc, + credential.WithParser(parser)) + + Convey("When a publication VC is generated", func() { + vc, err := generator.Generate() + + Convey("Then the publication VC should be generated", func() { + if test.wantErr != nil { + So(err, ShouldNotBeNil) + So(err.Error(), ShouldEqual, test.wantErr.Error()) + So(vc, ShouldBeNil) + } else { + So(vc, ShouldNotBeNil) + test.check(vc) + So(err, ShouldBeNil) + } + }) + }) + }) + }) + } +} diff --git a/credential/template/vc-desc-tpl.jsonld b/credential/template/vc-desc-tpl.jsonld new file mode 100644 index 0000000..2cc96a0 --- /dev/null +++ b/credential/template/vc-desc-tpl.jsonld @@ -0,0 +1,21 @@ +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "{{ .NamespacePrefix }}/schema/credential/dataset/description/" + ], + "type": [ + "VerifiableCredential", + "DatasetDescriptionCredential" + ], + "id": "{{ .NamespacePrefix }}/schema/credential/dataset/description/{{ .CredID }}", + "credentialSubject": { + "id": "{{ .DatasetDID }}", + "hasTitle": "{{ .Title }}", + "hasDescription": "{{ .Description }}", + "hasFormat": "{{ .Format }}", + "hasTag": [{{ with .Tags }}{{ range $i, $tag := . }}{{ if $i }},{{ end }}"{{ $tag }}"{{ end }}{{ end }}], + "hasTopic":"{{ .Topic }}" + }, + "issuanceDate": "{{ .IssuedAt }}", + "issuer": "{{ .DatasetDID }}" +} diff --git a/credential/template/vc-gov-tpl.jsonld b/credential/template/vc-gov-tpl.jsonld new file mode 100644 index 0000000..6190168 --- /dev/null +++ b/credential/template/vc-gov-tpl.jsonld @@ -0,0 +1,20 @@ +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "{{ .NamespacePrefix }}/schema/credential/governance/text/" + ], + "type": [ + "VerifiableCredential", + "GovernanceTextCredential" + ], + "id": "{{ .NamespacePrefix }}/schema/credential/governance/text/{{ .CredID }}", + "credentialSubject": { + "id": "{{ .DatasetDID }}", + "isGovernedBy": { + "type": "GovernanceText", + "fromGovernance": "{{ .GovAddr }}" + } + }, + "issuanceDate": "{{ .IssuedAt }}", + "issuer": "{{ .DatasetDID }}" +} diff --git a/credential/template/vc-publication-tpl.jsonld b/credential/template/vc-publication-tpl.jsonld new file mode 100644 index 0000000..79256a6 --- /dev/null +++ b/credential/template/vc-publication-tpl.jsonld @@ -0,0 +1,18 @@ +{ + "@context": [ + "https://www.w3.org/2018/credentials/v1", + "{{ .NamespacePrefix }}/schema/credential/digital-resource/publication/" + ], + "type": [ + "VerifiableCredential", + "DigitalResourcePublicationCredential" + ], + "id": "{{ .NamespacePrefix }}/schema/credential/digital-resource/publication/{{ .CredID }}", + "credentialSubject": { + "id": "{{ .DatasetDID }}", + "hasIdentifier": "{{ .DatasetURI }}", + "servedBy": "{{ .StorageDID }}" + }, + "issuanceDate": "{{ .IssuedAt }}", + "issuer": "{{ .StorageDID }}" +} diff --git a/credential/testdata/contexts/authentication-v4.jsonld b/testutil/contexts/authentication-v4.jsonld similarity index 100% rename from credential/testdata/contexts/authentication-v4.jsonld rename to testutil/contexts/authentication-v4.jsonld diff --git a/credential/testdata/contexts/credentials-v1.jsonld b/testutil/contexts/credentials-v1.jsonld similarity index 100% rename from credential/testdata/contexts/credentials-v1.jsonld rename to testutil/contexts/credentials-v1.jsonld diff --git a/testutil/contexts/dataset-v4.jsonld b/testutil/contexts/dataset-v4.jsonld new file mode 100644 index 0000000..511e933 --- /dev/null +++ b/testutil/contexts/dataset-v4.jsonld @@ -0,0 +1,39 @@ +{ + "@context": { + "@protected": true, + "id": "@id", + "type": "@type", + "DatasetDescriptionCredential": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/dataset/description/DatasetDescriptionCredential" + }, + "hasDescription": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/dataset/description/hasDescription" + }, + "hasFormat": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/dataset/description/hasFormat", + "@type": "@id" + }, + "hasGeoCoverage": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/dataset/description/hasGeoCoverage", + "@type": "@id" + }, + "hasImage": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/dataset/description/hasImage", + "@type": "@id" + }, + "hasTag": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/dataset/description/hasTag" + }, + "hasTemporalCoverage": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/dataset/description/hasTemporalCoverage", + "@type": "@id" + }, + "hasTitle": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/dataset/description/hasTitle" + }, + "hasTopic": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/dataset/description/hasTopic", + "@type": "@id" + } + } +} diff --git a/testutil/contexts/governance-text-v4.jsonld b/testutil/contexts/governance-text-v4.jsonld new file mode 100644 index 0000000..7450221 --- /dev/null +++ b/testutil/contexts/governance-text-v4.jsonld @@ -0,0 +1,58 @@ +{ + "@context": { + "@protected": true, + "id": "@id", + "type": "@type", + "Article": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/governance/text/Article" + }, + "Chapter": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/governance/text/Chapter" + }, + "GovernanceText": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/governance/text/GovernanceText" + }, + "GovernanceTextCredential": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/governance/text/GovernanceTextCredential" + }, + "Paragraph": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/governance/text/Paragraph" + }, + "Section": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/governance/text/Section" + }, + "fromGovernance": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/governance/text/fromGovernance", + "@type": "@id" + }, + "hasArticle": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/governance/text/hasArticle", + "@type": "@id" + }, + "hasChapter": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/governance/text/hasChapter", + "@type": "@id" + }, + "hasContent": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/governance/text/hasContent" + }, + "hasOrdinalNumber": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/governance/text/hasOrdinalNumber" + }, + "hasParagraph": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/governance/text/hasParagraph", + "@type": "@id" + }, + "hasSection": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/governance/text/hasSection", + "@type": "@id" + }, + "hasTitle": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/governance/text/hasTitle" + }, + "isGovernedBy": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/governance/text/isGovernedBy", + "@type": "@id" + } + } +} diff --git a/testutil/contexts/publication-v4.jsonld b/testutil/contexts/publication-v4.jsonld new file mode 100644 index 0000000..54dd97d --- /dev/null +++ b/testutil/contexts/publication-v4.jsonld @@ -0,0 +1,18 @@ +{ + "@context": { + "@protected": true, + "id": "@id", + "type": "@type", + "DigitalResourcePublicationCredential": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/digital-resource/publication/DigitalResourcePublicationCredential" + }, + "hasIdentifier": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/digital-resource/publication/hasIdentifier", + "@type": "@id" + }, + "servedBy": { + "@id": "https://w3id.org/axone/ontology/v4/schema/credential/digital-resource/publication/servedBy", + "@type": "@id" + } + } +} diff --git a/credential/testdata/contexts/security-v2.jsonld b/testutil/contexts/security-v2.jsonld similarity index 100% rename from credential/testdata/contexts/security-v2.jsonld rename to testutil/contexts/security-v2.jsonld diff --git a/testutil/generate_mocks.go b/testutil/generate_mocks.go new file mode 100644 index 0000000..89e8b46 --- /dev/null +++ b/testutil/generate_mocks.go @@ -0,0 +1,84 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: credential/generate.go +// +// Generated by this command: +// +// mockgen -source=credential/generate.go -package testutil -destination testutil/generate_mocks.go +// + +// Package testutil is a generated GoMock package. +package testutil + +import ( + bytes "bytes" + reflect "reflect" + time "time" + + gomock "go.uber.org/mock/gomock" +) + +// MockDescriptor is a mock of Descriptor interface. +type MockDescriptor struct { + ctrl *gomock.Controller + recorder *MockDescriptorMockRecorder +} + +// MockDescriptorMockRecorder is the mock recorder for MockDescriptor. +type MockDescriptorMockRecorder struct { + mock *MockDescriptor +} + +// NewMockDescriptor creates a new mock instance. +func NewMockDescriptor(ctrl *gomock.Controller) *MockDescriptor { + mock := &MockDescriptor{ctrl: ctrl} + mock.recorder = &MockDescriptorMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDescriptor) EXPECT() *MockDescriptorMockRecorder { + return m.recorder +} + +// Generate mocks base method. +func (m *MockDescriptor) Generate() (*bytes.Buffer, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Generate") + ret0, _ := ret[0].(*bytes.Buffer) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Generate indicates an expected call of Generate. +func (mr *MockDescriptorMockRecorder) Generate() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Generate", reflect.TypeOf((*MockDescriptor)(nil).Generate)) +} + +// IssuedAt mocks base method. +func (m *MockDescriptor) IssuedAt() *time.Time { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "IssuedAt") + ret0, _ := ret[0].(*time.Time) + return ret0 +} + +// IssuedAt indicates an expected call of IssuedAt. +func (mr *MockDescriptorMockRecorder) IssuedAt() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IssuedAt", reflect.TypeOf((*MockDescriptor)(nil).IssuedAt)) +} + +// ProofPurpose mocks base method. +func (m *MockDescriptor) ProofPurpose() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ProofPurpose") + ret0, _ := ret[0].(string) + return ret0 +} + +// ProofPurpose indicates an expected call of ProofPurpose. +func (mr *MockDescriptorMockRecorder) ProofPurpose() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProofPurpose", reflect.TypeOf((*MockDescriptor)(nil).ProofPurpose)) +} diff --git a/testutil/loader.go b/testutil/loader.go new file mode 100644 index 0000000..f4e3763 --- /dev/null +++ b/testutil/loader.go @@ -0,0 +1,63 @@ +package testutil + +import ( + _ "embed" + + jld "github.com/hyperledger/aries-framework-go/pkg/doc/ld" + "github.com/hyperledger/aries-framework-go/pkg/doc/ldcontext" + mockldstore "github.com/hyperledger/aries-framework-go/pkg/mock/ld" + mockprovider "github.com/hyperledger/aries-framework-go/pkg/mock/provider" +) + +var ( + //go:embed contexts/credentials-v1.jsonld + mockCredentialsV1JSONLD []byte + //go:embed contexts/authentication-v4.jsonld + mockAuthenticationV4JSONLD []byte + //go:embed contexts/governance-text-v4.jsonld + mockGovernanceTextV4JSONLD []byte + //go:embed contexts/security-v2.jsonld + mockSecurityV2JSONLD []byte + //go:embed contexts/dataset-v4.jsonld + mockDatasetV4JSONLD []byte + //go:embed contexts/publication-v4.jsonld + mockPublicationV4JSONLD []byte +) + +func MockDocumentLoader() (*jld.DocumentLoader, error) { + return jld.NewDocumentLoader(createMockCtxProvider(), jld.WithExtraContexts( + ldcontext.Document{ + URL: "https://w3id.org/axone/ontology/v4/schema/credential/digital-service/authentication/", + Content: mockAuthenticationV4JSONLD, + }, + ldcontext.Document{ + URL: "https://w3id.org/axone/ontology/v4/schema/credential/governance/text/", + Content: mockGovernanceTextV4JSONLD, + }, + ldcontext.Document{ + URL: "https://www.w3.org/2018/credentials/v1", + Content: mockCredentialsV1JSONLD, + }, + ldcontext.Document{ + URL: "https://w3id.org/security/v2", + Content: mockSecurityV2JSONLD, + }, + ldcontext.Document{ + URL: "https://w3id.org/axone/ontology/v4/schema/credential/dataset/description/", + Content: mockDatasetV4JSONLD, + }, + ldcontext.Document{ + URL: "https://w3id.org/axone/ontology/v4/schema/credential/digital-resource/publication/", + Content: mockPublicationV4JSONLD, + }, + )) +} + +func createMockCtxProvider() *mockprovider.Provider { + p := &mockprovider.Provider{ + ContextStoreValue: mockldstore.NewMockContextStore(), + RemoteProviderStoreValue: mockldstore.NewMockRemoteProviderStore(), + } + + return p +} diff --git a/testutil/signer_mocks.go b/testutil/signer_mocks.go new file mode 100644 index 0000000..6fc86cf --- /dev/null +++ b/testutil/signer_mocks.go @@ -0,0 +1,68 @@ +// Code generated by MockGen. DO NOT EDIT. +// Source: github.com/hyperledger/aries-framework-go/pkg/doc/verifiable (interfaces: Signer) +// +// Generated by this command: +// +// mockgen -package testutil -destination testutil/signer_mocks.go github.com/hyperledger/aries-framework-go/pkg/doc/verifiable Signer +// + +// Package testutil is a generated GoMock package. +package testutil + +import ( + reflect "reflect" + + gomock "go.uber.org/mock/gomock" +) + +// MockSigner is a mock of Signer interface. +type MockSigner struct { + ctrl *gomock.Controller + recorder *MockSignerMockRecorder +} + +// MockSignerMockRecorder is the mock recorder for MockSigner. +type MockSignerMockRecorder struct { + mock *MockSigner +} + +// NewMockSigner creates a new mock instance. +func NewMockSigner(ctrl *gomock.Controller) *MockSigner { + mock := &MockSigner{ctrl: ctrl} + mock.recorder = &MockSignerMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockSigner) EXPECT() *MockSignerMockRecorder { + return m.recorder +} + +// Alg mocks base method. +func (m *MockSigner) Alg() string { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Alg") + ret0, _ := ret[0].(string) + return ret0 +} + +// Alg indicates an expected call of Alg. +func (mr *MockSignerMockRecorder) Alg() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Alg", reflect.TypeOf((*MockSigner)(nil).Alg)) +} + +// Sign mocks base method. +func (m *MockSigner) Sign(arg0 []byte) ([]byte, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "Sign", arg0) + ret0, _ := ret[0].([]byte) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Sign indicates an expected call of Sign. +func (mr *MockSignerMockRecorder) Sign(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Sign", reflect.TypeOf((*MockSigner)(nil).Sign), arg0) +}