diff --git a/storage/gorm_logger.go b/storage/gorm_logger.go index 377a7c76ad..bcd1610638 100644 --- a/storage/gorm_logger.go +++ b/storage/gorm_logger.go @@ -1,3 +1,21 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + package storage import ( diff --git a/storage/gorm_logger_test.go b/storage/gorm_logger_test.go index a1ffc1e40b..5c27e90378 100644 --- a/storage/gorm_logger_test.go +++ b/storage/gorm_logger_test.go @@ -1,3 +1,21 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + package storage import ( diff --git a/vcr/credential/resolver.go b/vcr/credential/resolver.go index a95caa9b9d..cf34fe8b0c 100644 --- a/vcr/credential/resolver.go +++ b/vcr/credential/resolver.go @@ -38,9 +38,6 @@ func FindValidator(credential vc.VerifiableCredential) Validator { return nutsOrganizationCredentialValidator{} case NutsAuthorizationCredentialType: return nutsAuthorizationCredentialValidator{} - case StatusList2021CredentialType: - // TODO: is this needed? The only place where should be receiving StatusList2021Credentials is in the StatusList2021 caching layer, where we know what credential we should be receiving. - return statusList2021CredentialValidator{} } } } diff --git a/vcr/credential/resolver_test.go b/vcr/credential/resolver_test.go index 6b979a149b..7ee277741b 100644 --- a/vcr/credential/resolver_test.go +++ b/vcr/credential/resolver_test.go @@ -49,10 +49,6 @@ func TestFindValidator(t *testing.T) { t.Run("validator and builder found for NutsAuthorizationCredential", func(t *testing.T) { assert.IsType(t, nutsAuthorizationCredentialValidator{}, FindValidator(*ValidNutsAuthorizationCredential())) }) - - t.Run("validator and builder found for StatusList2021Credential", func(t *testing.T) { - assert.IsType(t, statusList2021CredentialValidator{}, FindValidator(ValidStatusList2021Credential(t))) - }) } func TestExtractTypes(t *testing.T) { diff --git a/vcr/credential/test.go b/vcr/credential/test.go index bdae044062..33de4bcd0e 100644 --- a/vcr/credential/test.go +++ b/vcr/credential/test.go @@ -97,28 +97,6 @@ func JWTNutsOrganizationCredential(t *testing.T, subjectID did.DID) vc.Verifiabl return *jwtVC } -func ValidStatusList2021Credential(_ testing.TB) vc.VerifiableCredential { - id := ssi.MustParseURI("https://example.com/credentials/status/3") - validFrom := time.Now() - validUntilTomorrow := validFrom.Add(24 * time.Hour) - return vc.VerifiableCredential{ - Context: []ssi.URI{vc.VCContextV1URI(), statusList2021ContextURI}, - ID: &id, - Type: []ssi.URI{vc.VerifiableCredentialTypeV1URI(), stringToURI(StatusList2021CredentialType)}, - Issuer: ssi.MustParseURI("did:example:12345"), - ValidFrom: &validFrom, - ValidUntil: &validUntilTomorrow, - CredentialStatus: nil, - CredentialSubject: []any{&StatusList2021CredentialSubject{ - Id: "https://example-com/status/3#list", - Type: StatusList2021CredentialSubjectType, - StatusPurpose: "revocation", - EncodedList: "H4sIAAAAAAAA_-zAsQAAAAACsNDypwqjZ2sAAAAAAAAAAAAAAAAAAACAtwUAAP__NxdfzQBAAAA=", // has bit 1 set to true - }}, - Proof: []any{vc.Proof{}}, - } -} - func stringToURI(input string) ssi.URI { return ssi.MustParseURI(input) } diff --git a/vcr/credential/types.go b/vcr/credential/types.go index 659e1c3669..d46410e045 100644 --- a/vcr/credential/types.go +++ b/vcr/credential/types.go @@ -21,7 +21,6 @@ package credential import ( ssi "github.com/nuts-foundation/go-did" - "github.com/nuts-foundation/nuts-node/jsonld" ) const ( @@ -85,46 +84,3 @@ type Resource struct { type BaseCredentialSubject struct { ID string `json:"id"` } - -const ( - // StatusList2021CredentialType is the type of StatusList2021Credential - StatusList2021CredentialType = "StatusList2021Credential" - // StatusList2021CredentialSubjectType is the credentialSubject.type in a StatusList2021Credential - StatusList2021CredentialSubjectType = "StatusList2021" - // StatusList2021EntryType is the credentialStatus.type that lists the entry of that credential on a list - StatusList2021EntryType = "StatusList2021Entry" -) - -var statusList2021ContextURI = ssi.MustParseURI(jsonld.W3cStatusList2021Context) -var statusList2021CredentialTypeURI = ssi.MustParseURI(StatusList2021CredentialType) - -// StatusList2021Entry is the "credentialStatus" property used by issuers to enable VerifiableCredential status information. -type StatusList2021Entry struct { - // ID is expected to be a URL that identifies the status information associated with the verifiable credential. - // It MUST NOT be the URL for the status list, which is in StatusListCredential. - ID string `json:"id,omitempty"` - // Type MUST be "StatusList2021Entry" - Type string `json:"type,omitempty"` - // StatusPurpose indicates what it means if the VerifiableCredential is on the list. - // The value is arbitrary, with predefined values `revocation` and `suspension`. - // This value must match credentialSubject.statusPurpose value in the VerifiableCredential. - StatusPurpose string `json:"statusPurpose,omitempty"` - // StatusListIndex is an arbitrary size integer greater than or equal to 0, expressed as a string. - // The value identifies the bit position of the status of the verifiable credential. - StatusListIndex string `json:"statusListIndex,omitempty"` - // The statusListCredential property MUST be a URL to a verifiable credential. - // When the URL is dereferenced, the resulting verifiable credential MUST have type property that includes the "StatusList2021Credential" value. - StatusListCredential string `json:"statusListCredential,omitempty"` -} - -type StatusList2021CredentialSubject struct { - // ID for the credential subject - Id string `json:"id"` - // Type MUST be "StatusList2021Credential" - Type string `json:"type"` - // StatusPurpose defines the reason credentials are listed. ('revocation', 'suspension') - StatusPurpose string `json:"statusPurpose"` - // EncodedList is the GZIP-compressed [RFC1952], base-64 encoded [RFC4648] bitstring values for the associated range - // of verifiable credential status values. The uncompressed bitstring MUST be at least 16KB in size. - EncodedList string `json:"encodedList"` -} diff --git a/vcr/credential/validator.go b/vcr/credential/validator.go index eb6049813b..41ff24f070 100644 --- a/vcr/credential/validator.go +++ b/vcr/credential/validator.go @@ -30,6 +30,7 @@ import ( "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/go-did/vc" + "github.com/nuts-foundation/nuts-node/vcr/statuslist2021" "github.com/nuts-foundation/nuts-node/vdr/resolver" "github.com/piprate/json-gold/ld" ) @@ -70,7 +71,7 @@ type AllFieldsDefinedValidator struct { // Validate implements Validator.Validate. func (d AllFieldsDefinedValidator) Validate(input vc.VerifiableCredential) error { - // Expand with safe mode enabled, which asserts that all properties are defined in the JSON-LD context. + // expand with safe mode enabled, which asserts that all properties are defined in the JSON-LD context. inputAsJSON, _ := input.MarshalJSON() document, err := ld.DocumentFromReader(bytes.NewReader(inputAsJSON)) if err != nil { @@ -145,17 +146,18 @@ func validateCredentialStatus(credential vc.VerifiableCredential) error { return errors.New("credentialStatus.type is required") } - // only accept StatusList2021EntryType for now - if credentialStatus.Type != StatusList2021EntryType { + // only accept StatusList2021Entry for now + if credentialStatus.Type != statuslist2021.EntryType { continue } + // TODO: AllFieldsDefined validator should be sufficient? - if !credential.ContainsContext(statusList2021ContextURI) { + if !credential.ContainsContext(statuslist2021.ContextURI) { return errors.New("StatusList2021 context is required") } // unmarshal as StatusList2021Entry - var cs StatusList2021Entry + var cs statuslist2021.Entry if err = json.Unmarshal(credentialStatus.Raw(), &cs); err != nil { return err } @@ -320,47 +322,3 @@ func validateNutsCredentialID(credential vc.VerifiableCredential) error { } return nil } - -// statusList2021CredentialValidator validates that all required fields of a StatusList2021CredentialType are present -type statusList2021CredentialValidator struct{} - -func (d statusList2021CredentialValidator) Validate(credential vc.VerifiableCredential) error { - if err := (defaultCredentialValidator{}).Validate(credential); err != nil { - return err - } - - { // Credential checks - if !credential.ContainsContext(statusList2021ContextURI) { - return failure("context '%s' is required", statusList2021ContextURI) - } - if !credential.IsType(statusList2021CredentialTypeURI) { - return failure("type '%s' is required", statusList2021CredentialTypeURI) - } - } - - { // CredentialSubject checks - var target []StatusList2021CredentialSubject - err := credential.UnmarshalCredentialSubject(&target) - if err != nil { - return failure(err.Error()) - } - // The spec is not clear if there could be multiple CredentialSubjects. This could allow 'revocation' and 'suspension' to be defined in a single credential. - // However, it is not defined how to select the correct list (StatusPurpose) when validating credentials that are using this StatusList2021Credential. - if len(target) != 1 { - return failure("single CredentialSubject expected") - } - cs := target[0] - - if cs.Type != StatusList2021CredentialSubjectType { - return failure("credentialSubject.type '%s' is required", StatusList2021CredentialSubjectType) - } - if cs.StatusPurpose == "" { - return failure("credentialSubject.statusPurpose is required") - } - if cs.EncodedList == "" { - return failure("credentialSubject.encodedList is required") - } - } - - return nil -} diff --git a/vcr/credential/validator_test.go b/vcr/credential/validator_test.go index 18e1236cfe..6211542cc5 100644 --- a/vcr/credential/validator_test.go +++ b/vcr/credential/validator_test.go @@ -23,6 +23,7 @@ import ( ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/jsonld" + "github.com/nuts-foundation/nuts-node/vcr/statuslist2021" "github.com/nuts-foundation/nuts-node/vdr" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" @@ -509,68 +510,6 @@ func TestDefaultCredentialValidator(t *testing.T) { }) } -func TestStatusList2021CredentialValidator_Validate(t *testing.T) { - t.Run("ok", func(t *testing.T) { - cred := ValidStatusList2021Credential(t) - err := statusList2021CredentialValidator{}.Validate(cred) - assert.NoError(t, err) - }) - t.Run("error - wraps defaultCredentialValidator", func(t *testing.T) { - cred := ValidStatusList2021Credential(t) - cred.Context = []ssi.URI{statusList2021CredentialTypeURI} - err := statusList2021CredentialValidator{}.Validate(cred) - assert.EqualError(t, err, "validation failed: default context is required") - }) - t.Run("error - missing status list context", func(t *testing.T) { - cred := ValidStatusList2021Credential(t) - cred.Context = []ssi.URI{vc.VCContextV1URI()} - err := statusList2021CredentialValidator{}.Validate(cred) - assert.EqualError(t, err, "validation failed: context 'https://w3id.org/vc/status-list/2021/v1' is required") - }) - t.Run("error - missing StatusList credential type", func(t *testing.T) { - cred := ValidStatusList2021Credential(t) - cred.Type = []ssi.URI{vc.VerifiableCredentialTypeV1URI()} - err := statusList2021CredentialValidator{}.Validate(cred) - assert.EqualError(t, err, "validation failed: type 'StatusList2021Credential' is required") - }) - t.Run("error - invalid credential subject", func(t *testing.T) { - cred := ValidStatusList2021Credential(t) - cred.CredentialSubject = []any{"{"} - err := statusList2021CredentialValidator{}.Validate(cred) - assert.EqualError(t, err, "validation failed: json: cannot unmarshal string into Go value of type credential.StatusList2021CredentialSubject") - }) - t.Run("error - wrong credential subject", func(t *testing.T) { - cred := ValidStatusList2021Credential(t) - cred.CredentialSubject = []any{NutsAuthorizationCredentialSubject{}} - err := statusList2021CredentialValidator{}.Validate(cred) - assert.EqualError(t, err, "validation failed: credentialSubject.type 'StatusList2021' is required") - }) - t.Run("error - multiple credentialSubject", func(t *testing.T) { - cred := ValidStatusList2021Credential(t) - cred.CredentialSubject = []any{StatusList2021CredentialSubject{}, StatusList2021CredentialSubject{}} - err := statusList2021CredentialValidator{}.Validate(cred) - assert.EqualError(t, err, "validation failed: single CredentialSubject expected") - }) - t.Run("error - missing credentialSubject.type", func(t *testing.T) { - cred := ValidStatusList2021Credential(t) - cred.CredentialSubject[0].(*StatusList2021CredentialSubject).Type = "" - err := statusList2021CredentialValidator{}.Validate(cred) - assert.EqualError(t, err, "validation failed: credentialSubject.type 'StatusList2021' is required") - }) - t.Run("error - missing statusPurpose", func(t *testing.T) { - cred := ValidStatusList2021Credential(t) - cred.CredentialSubject[0].(*StatusList2021CredentialSubject).StatusPurpose = "" - err := statusList2021CredentialValidator{}.Validate(cred) - assert.EqualError(t, err, "validation failed: credentialSubject.statusPurpose is required") - }) - t.Run("error - missing encodedList", func(t *testing.T) { - cred := ValidStatusList2021Credential(t) - cred.CredentialSubject[0].(*StatusList2021CredentialSubject).EncodedList = "" - err := statusList2021CredentialValidator{}.Validate(cred) - assert.EqualError(t, err, "validation failed: credentialSubject.encodedList is required") - }) -} - func Test_validateCredentialStatus(t *testing.T) { t.Run("ok - no credentialStatus", func(t *testing.T) { assert.NoError(t, validateCredentialStatus(vc.VerifiableCredential{})) @@ -591,13 +530,13 @@ func Test_validateCredentialStatus(t *testing.T) { assert.EqualError(t, err, "credentialStatus.type is required") }) - t.Run(StatusList2021EntryType, func(t *testing.T) { + t.Run(statuslist2021.EntryType, func(t *testing.T) { makeValidCSEntry := func() vc.VerifiableCredential { return vc.VerifiableCredential{ - Context: []ssi.URI{statusList2021ContextURI}, - CredentialStatus: []any{&StatusList2021Entry{ + Context: []ssi.URI{ssi.MustParseURI(jsonld.W3cStatusList2021Context)}, + CredentialStatus: []any{&statuslist2021.Entry{ ID: "https://example-com/credentials/status/3#94567", - Type: StatusList2021EntryType, + Type: statuslist2021.EntryType, StatusPurpose: "revocation", StatusListIndex: "94567", StatusListCredential: "https://example-com/credentials/status/3", @@ -621,31 +560,31 @@ func Test_validateCredentialStatus(t *testing.T) { }) t.Run("error - id == statusListCredential", func(t *testing.T) { cred := makeValidCSEntry() - cred.CredentialStatus[0].(*StatusList2021Entry).ID = cred.CredentialStatus[0].(*StatusList2021Entry).StatusListCredential + cred.CredentialStatus[0].(*statuslist2021.Entry).ID = cred.CredentialStatus[0].(*statuslist2021.Entry).StatusListCredential err := validateCredentialStatus(cred) assert.EqualError(t, err, "StatusList2021Entry.id is the same as the StatusList2021Entry.statusListCredential") }) t.Run("error - missing statusPurpose", func(t *testing.T) { cred := makeValidCSEntry() - cred.CredentialStatus[0].(*StatusList2021Entry).StatusPurpose = "" + cred.CredentialStatus[0].(*statuslist2021.Entry).StatusPurpose = "" err := validateCredentialStatus(cred) assert.EqualError(t, err, "StatusList2021Entry.statusPurpose is required") }) t.Run("error - statusListIndex is negative", func(t *testing.T) { cred := makeValidCSEntry() - cred.CredentialStatus[0].(*StatusList2021Entry).StatusListIndex = "-1" + cred.CredentialStatus[0].(*statuslist2021.Entry).StatusListIndex = "-1" err := validateCredentialStatus(cred) assert.EqualError(t, err, "invalid StatusList2021Entry.statusListIndex") }) t.Run("error - statusListIndex is not a number", func(t *testing.T) { cred := makeValidCSEntry() - cred.CredentialStatus[0].(*StatusList2021Entry).StatusListIndex = "one" + cred.CredentialStatus[0].(*statuslist2021.Entry).StatusListIndex = "one" err := validateCredentialStatus(cred) assert.EqualError(t, err, "invalid StatusList2021Entry.statusListIndex") }) t.Run("error - statusListCredential is not a valid URL", func(t *testing.T) { cred := makeValidCSEntry() - cred.CredentialStatus[0].(*StatusList2021Entry).StatusListCredential = "not a URL" + cred.CredentialStatus[0].(*statuslist2021.Entry).StatusListCredential = "not a URL" err := validateCredentialStatus(cred) assert.EqualError(t, err, "parse StatusList2021Entry.statusListCredential URL: parse \"not a URL\": invalid URI for request") }) diff --git a/vcr/issuer/issuer.go b/vcr/issuer/issuer.go index 2bbc1d8997..02be8e356e 100644 --- a/vcr/issuer/issuer.go +++ b/vcr/issuer/issuer.go @@ -24,7 +24,7 @@ import ( "errors" "fmt" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" - "github.com/nuts-foundation/nuts-node/vcr/statuslist" + "github.com/nuts-foundation/nuts-node/vcr/statuslist2021" "github.com/nuts-foundation/nuts-node/vdr/didnuts" "github.com/nuts-foundation/nuts-node/vdr/resolver" "time" @@ -56,7 +56,7 @@ var TimeFunc = time.Now func NewIssuer(store Store, vcrStore types.Writer, networkPublisher Publisher, openidHandlerFn func(ctx context.Context, id did.DID) (OpenIDHandler, error), didResolver resolver.DIDResolver, keyStore crypto.KeyStore, jsonldManager jsonld.JSONLD, trustConfig *trust.Config, - statusList statuslist.StatusList2021Issuer) Issuer { + statusList statuslist2021.StatusList2021Issuer) Issuer { keyResolver := vdrKeyResolver{ publicKeyResolver: resolver.DIDKeyResolver{Resolver: didResolver}, privateKeyResolver: keyStore, @@ -88,7 +88,7 @@ type issuer struct { jsonldManager jsonld.JSONLD vcrStore types.Writer walletResolver openid4vci.IdentifierResolver - statusListStore statuslist.StatusList2021Issuer + statusListStore statuslist2021.StatusList2021Issuer } // Issue creates a new credential, signs, stores it. @@ -222,7 +222,7 @@ func (i issuer) buildAndSignVC(ctx context.Context, template vc.VerifiableCreden } if options.WithStatusListRevocation { // add credential status - credentialStatusEntry, err := i.statusListStore.Create(ctx, *issuerDID, statuslist.StatusPurposeRevocation) + credentialStatusEntry, err := i.statusListStore.Create(ctx, *issuerDID, statuslist2021.StatusPurposeRevocation) if err != nil { return nil, err } @@ -337,14 +337,14 @@ func (i issuer) revokeStatusList(ctx context.Context, credentialID ssi.URI) erro // find the correct credentialStatus and revoke it on the relevant statuslist for _, status := range statuses { - if status.Type == credential.StatusList2021EntryType { - var slEntry credential.StatusList2021Entry + if status.Type == statuslist2021.EntryType { + var slEntry statuslist2021.Entry err = json.Unmarshal(status.Raw(), &slEntry) if err != nil { return err } // TODO: make sure it is the correct entry when we allow other purposes, or VC issuance that include other credential statuses - if slEntry.StatusPurpose != statuslist.StatusPurposeRevocation { + if slEntry.StatusPurpose != statuslist2021.StatusPurposeRevocation { continue } return i.statusListStore.Revoke(ctx, credentialID, slEntry) @@ -426,7 +426,7 @@ func (i issuer) SearchCredential(credentialType ssi.URI, issuer did.DID, subject const statusListValidity = 24 * time.Hour // TODO: make configurable var statusList2021ContextURI = ssi.MustParseURI(jsonld.W3cStatusList2021Context) -var statusList2021CredentialTypeURI = ssi.MustParseURI(credential.StatusList2021CredentialType) +var statusList2021CredentialTypeURI = ssi.MustParseURI(statuslist2021.CredentialType) func (i issuer) StatusList(ctx context.Context, issuerDID did.DID, page int) (*vc.VerifiableCredential, error) { // todo: get cached credential if available diff --git a/vcr/issuer/issuer_test.go b/vcr/issuer/issuer_test.go index 9d039e81c3..d2b1d88767 100644 --- a/vcr/issuer/issuer_test.go +++ b/vcr/issuer/issuer_test.go @@ -28,7 +28,7 @@ import ( "github.com/nuts-foundation/nuts-node/core" "github.com/nuts-foundation/nuts-node/storage" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" - "github.com/nuts-foundation/nuts-node/vcr/statuslist" + "github.com/nuts-foundation/nuts-node/vcr/statuslist2021" "github.com/nuts-foundation/nuts-node/vcr/verifier" "github.com/nuts-foundation/nuts-node/vdr/didweb" "github.com/nuts-foundation/nuts-node/vdr/resolver" @@ -168,7 +168,7 @@ func Test_issuer_buildAndSignVC(t *testing.T) { statuses, err := result.CredentialStatuses() require.NoError(t, err) require.Len(t, statuses, 1) - assert.Equal(t, credential.StatusList2021EntryType, statuses[0].Type) + assert.Equal(t, statuslist2021.EntryType, statuses[0].Type) }) t.Run("error - did:nuts", func(t *testing.T) { ctrl := gomock.NewController(t) @@ -787,7 +787,7 @@ func Test_issuer_revokeNetwork(t *testing.T) { func TestIssuer_revokeStatusList(t *testing.T) { issuerDID := did.MustParseDID("did:web:example.com:iam:123") - storeWithCred := func(c *gomock.Controller, entry credential.StatusList2021Entry) (*MockStore, ssi.URI) { + storeWithCred := func(c *gomock.Controller, entry statuslist2021.Entry) (*MockStore, ssi.URI) { credentialID := ssi.MustParseURI(issuerDID.String() + "#identifier") cred := &vc.VerifiableCredential{ ID: &credentialID, @@ -801,7 +801,7 @@ func TestIssuer_revokeStatusList(t *testing.T) { t.Run("ok", func(t *testing.T) { status := NewTestStatusListStore(t, issuerDID) - entry, err := status.Create(context.Background(), issuerDID, statuslist.StatusPurposeRevocation) + entry, err := status.Create(context.Background(), issuerDID, statuslist2021.StatusPurposeRevocation) require.NoError(t, err) issuerStore, credentialID := storeWithCred(gomock.NewController(t), *entry) sut := issuer{ @@ -816,7 +816,7 @@ func TestIssuer_revokeStatusList(t *testing.T) { }) t.Run("error - double revocation", func(t *testing.T) { status := NewTestStatusListStore(t, issuerDID) - entry, err := status.Create(context.Background(), issuerDID, statuslist.StatusPurposeRevocation) + entry, err := status.Create(context.Background(), issuerDID, statuslist2021.StatusPurposeRevocation) require.NoError(t, err) issuerStore, credentialID := storeWithCred(gomock.NewController(t), *entry) sut := issuer{ @@ -842,7 +842,7 @@ func TestIssuer_revokeStatusList(t *testing.T) { }) t.Run("error - statuslist credential not found", func(t *testing.T) { status := NewTestStatusListStore(t, issuerDID) - entry, err := status.Create(context.Background(), issuerDID, statuslist.StatusPurposeRevocation) + entry, err := status.Create(context.Background(), issuerDID, statuslist2021.StatusPurposeRevocation) require.NoError(t, err) entry.StatusListCredential = "unknown status list" issuerStore, credentialID := storeWithCred(gomock.NewController(t), *entry) @@ -858,8 +858,8 @@ func TestIssuer_revokeStatusList(t *testing.T) { t.Run("error - invalid credentialStatus", func(t *testing.T) { }) t.Run("error - no revokable credential status", func(t *testing.T) { - issuerStore, credentialID := storeWithCred(gomock.NewController(t), credential.StatusList2021Entry{ - Type: credential.StatusList2021EntryType, + issuerStore, credentialID := storeWithCred(gomock.NewController(t), statuslist2021.Entry{ + Type: statuslist2021.EntryType, StatusPurpose: "not revocation", }) sut := issuer{store: issuerStore} @@ -943,7 +943,7 @@ func TestIssuer_StatusList(t *testing.T) { trustConfig: trustConfig, statusListStore: NewTestStatusListStore(t, issuerDID), } - _, err = sut.statusListStore.Create(ctx, issuerDID, statuslist.StatusPurposeRevocation) + _, err = sut.statusListStore.Create(ctx, issuerDID, statuslist2021.StatusPurposeRevocation) require.NoError(t, err) result, err := sut.StatusList(ctx, issuerDID, 1) @@ -953,16 +953,16 @@ func TestIssuer_StatusList(t *testing.T) { require.NotNil(t, result) assert.Contains(t, result.Context, statusList2021ContextURI) assert.Equal(t, result.Issuer.String(), issuerDID.String()) - assert.True(t, result.IsType(ssi.MustParseURI(credential.StatusList2021CredentialType))) + assert.True(t, result.IsType(ssi.MustParseURI(statuslist2021.CredentialType))) // credential subject - var subjects []credential.StatusList2021CredentialSubject + var subjects []statuslist2021.CredentialSubject err = result.UnmarshalCredentialSubject(&subjects) require.NoError(t, err) require.Len(t, subjects, 1) assert.Equal(t, subjects[0].Id, issuerURL.JoinPath("statuslist", "1").String()) - assert.Equal(t, subjects[0].Type, credential.StatusList2021CredentialSubjectType) - assert.Equal(t, subjects[0].StatusPurpose, statuslist.StatusPurposeRevocation) + assert.Equal(t, subjects[0].Type, statuslist2021.CredentialSubjectType) + assert.Equal(t, subjects[0].StatusPurpose, statuslist2021.StatusPurposeRevocation) assert.NotEmpty(t, subjects[0].EncodedList, "") // verify credential -> trust is not added automatically @@ -994,7 +994,7 @@ func TestIssuer_StatusList(t *testing.T) { trustConfig: trustConfig, statusListStore: NewTestStatusListStore(t, issuerDID), } - _, err = sut.statusListStore.Create(ctx, issuerDID, statuslist.StatusPurposeRevocation) + _, err = sut.statusListStore.Create(ctx, issuerDID, statuslist2021.StatusPurposeRevocation) require.NoError(t, err) result, err := sut.StatusList(ctx, issuerDID, 1) @@ -1013,12 +1013,12 @@ func TestIssuer_StatusList(t *testing.T) { }) } -func NewTestStatusListStore(t testing.TB, dids ...did.DID) statuslist.StatusList2021Issuer { +func NewTestStatusListStore(t testing.TB, dids ...did.DID) statuslist2021.StatusList2021Issuer { storageEngine := storage.NewTestStorageEngine(t) require.NoError(t, storageEngine.Start()) db := storageEngine.GetSQLDatabase() storage.AddDIDtoSQLDB(t, db, dids...) - store, err := statuslist.NewStatusListStore(db) + store, err := statuslist2021.NewStatusListStore(db) require.NoError(t, err) return store } diff --git a/vcr/credential/statuslist2021/bitstring.go b/vcr/statuslist2021/bitstring.go similarity index 75% rename from vcr/credential/statuslist2021/bitstring.go rename to vcr/statuslist2021/bitstring.go index 71acc85c0c..0fac780239 100644 --- a/vcr/credential/statuslist2021/bitstring.go +++ b/vcr/statuslist2021/bitstring.go @@ -28,19 +28,19 @@ import ( var ErrIndexNotInBitstring = errors.New("index not in status list") const defaultBitstringLengthInBytes = 16 * 1024 // *8 = herd privacy of 16kB or 131072 bit -const MaxBitstringIndex = defaultBitstringLengthInBytes*8 - 1 +const maxBitstringIndex = defaultBitstringLengthInBytes*8 - 1 -// Bitstring is not thread-safe -type Bitstring []byte +// bitstring is not thread-safe +type bitstring []byte -// NewBitstring creates a new Bitstring with 16kB entries initialized to 0. -func NewBitstring() *Bitstring { - bs := Bitstring(make([]byte, defaultBitstringLengthInBytes)) +// newBitstring creates a new bitstring with 16kB entries initialized to 0. +func newBitstring() *bitstring { + bs := bitstring(make([]byte, defaultBitstringLengthInBytes)) return &bs } -// Bit returns the value of the Bitstring at statusListIndex, or (false, error) if the requested index is out of bounds. -func (bs *Bitstring) Bit(statusListIndex int) (bool, error) { +// bit returns the value of the bitstring at statusListIndex, or (false, error) if the requested index is out of bounds. +func (bs *bitstring) bit(statusListIndex int) (bool, error) { q, r := statusListIndex/8, byte(statusListIndex%8) if statusListIndex < 0 || q >= len(*bs) { return false, ErrIndexNotInBitstring @@ -48,8 +48,8 @@ func (bs *Bitstring) Bit(statusListIndex int) (bool, error) { return isSet((*bs)[q], r), nil } -// SetBit set the value of the bit at statusListIndex to 1, or returns an error when the index is out of bounds. -func (bs *Bitstring) SetBit(statusListIndex int, value bool) error { +// setBit set the value of the bit at statusListIndex to 1, or returns an error when the index is out of bounds. +func (bs *bitstring) setBit(statusListIndex int, value bool) error { q, r := statusListIndex/8, byte(statusListIndex%8) if statusListIndex < 0 || q >= len(*bs) { return ErrIndexNotInBitstring @@ -66,8 +66,8 @@ func isSet(b, r byte) bool { return b>>(7-r)&1 == 1 } -// Compress a StatusList2021 bitstring. The input is gzip compressed followed by base64 encoding. -func Compress(bitstring []byte) (string, error) { +// compress a StatusList2021 bitstring. The input is gzip compressed followed by base64 encoding. +func compress(bitstring []byte) (string, error) { // gzip compression var buf bytes.Buffer gz := gzip.NewWriter(&buf) @@ -81,14 +81,14 @@ func Compress(bitstring []byte) (string, error) { } // encode to base64 string. - // Bitstring Status List spec clarified this to be multibase base64URL encoding without padding. StatusList2021 spec is not multibase. + // bitstring Status List spec clarified this to be multibase base64URL encoding without padding. StatusList2021 spec is not multibase. return base64.RawURLEncoding.EncodeToString(buf.Bytes()), nil } -// Expand a compressed StatusList2021 bitstring. It first applies base64 decoding followed by gzip decompression. -func Expand(encodedList string) (Bitstring, error) { +// expand a compressed StatusList2021 bitstring. It first applies base64 decoding followed by gzip decompression. +func expand(encodedList string) (bitstring, error) { // base64 decode - // Bitstring Status List spec clarified this to be multibase base64URL encoding without padding. StatusList2021 spec is not multibase. + // bitstring Status List spec clarified this to be multibase base64URL encoding without padding. StatusList2021 spec is not multibase. enc := base64.RawURLEncoding if len(encodedList)%4 == 0 { // if encoding is a multiple of 4 it may or may not be padded. URLEncoding can handle both. diff --git a/vcr/credential/statuslist2021/bitstring_test.go b/vcr/statuslist2021/bitstring_test.go similarity index 71% rename from vcr/credential/statuslist2021/bitstring_test.go rename to vcr/statuslist2021/bitstring_test.go index 333273020c..37b9e74923 100644 --- a/vcr/credential/statuslist2021/bitstring_test.go +++ b/vcr/statuslist2021/bitstring_test.go @@ -26,7 +26,7 @@ import ( ) func TestBitstring_Bit(t *testing.T) { - bs := Bitstring{33} // 33 => 00100001 + bs := bitstring{33} // 33 => 00100001 type test struct { index int isSet bool @@ -46,7 +46,7 @@ func TestBitstring_Bit(t *testing.T) { {8, false, ErrIndexNotInBitstring}, } for _, tc := range tt { - v, err := bs.Bit(tc.index) + v, err := bs.bit(tc.index) assert.ErrorIs(t, err, tc.err) assert.Equal(t, tc.isSet, v) } @@ -54,23 +54,23 @@ func TestBitstring_Bit(t *testing.T) { func TestBitstring_SetBit(t *testing.T) { t.Run("ok - set value", func(t *testing.T) { - bs := Bitstring{0} - assert.NoError(t, bs.SetBit(2, true)) - assert.NoError(t, bs.SetBit(2, true)) // applies value, not a simple bit flip - assert.NoError(t, bs.SetBit(7, true)) - assert.Equal(t, Bitstring{33}, bs) // 33 => 00100001 + bs := bitstring{0} + assert.NoError(t, bs.setBit(2, true)) + assert.NoError(t, bs.setBit(2, true)) // applies value, not a simple bit flip + assert.NoError(t, bs.setBit(7, true)) + assert.Equal(t, bitstring{33}, bs) // 33 => 00100001 }) t.Run("ok - unset value", func(t *testing.T) { - bs := Bitstring{33} // 33 => 00100001 - assert.NoError(t, bs.SetBit(2, false)) - assert.NoError(t, bs.SetBit(2, false)) // applies value, not a simple bit flip - assert.Equal(t, Bitstring{1}, bs) + bs := bitstring{33} // 33 => 00100001 + assert.NoError(t, bs.setBit(2, false)) + assert.NoError(t, bs.setBit(2, false)) // applies value, not a simple bit flip + assert.Equal(t, bitstring{1}, bs) }) t.Run("error - index OOB", func(t *testing.T) { - bs := Bitstring{0} // single byte - assert.ErrorIs(t, bs.SetBit(-1, true), ErrIndexNotInBitstring) - assert.NoError(t, bs.SetBit(0, true)) - assert.ErrorIs(t, bs.SetBit(8, true), ErrIndexNotInBitstring) + bs := bitstring{0} // single byte + assert.ErrorIs(t, bs.setBit(-1, true), ErrIndexNotInBitstring) + assert.NoError(t, bs.setBit(0, true)) + assert.ErrorIs(t, bs.setBit(8, true), ErrIndexNotInBitstring) }) } @@ -78,45 +78,45 @@ func Test_CompressExpand(t *testing.T) { t.Run("ok - empty bitstring from example", func(t *testing.T) { // this EncodedList comes from the StatusList2021example https://www.w3.org/TR/2023/WD-vc-status-list-20230427/ exampleEncodedList := "H4sIAAAAAAAAA-3BMQEAAADCoPVPbQwfoAAAAAAAAAAAAAAAAAAAAIC3AYbSVKsAQAAA" - bs := NewBitstring() + bs := newBitstring() - expanded, err := Expand(exampleEncodedList) + expanded, err := expand(exampleEncodedList) assert.NoError(t, err) assert.Len(t, *bs, len(expanded), "comparison invalid if these are not the same (16kB)") assert.Equal(t, *bs, expanded) }) - t.Run("ok - input >> Compress >> Expand == input", func(t *testing.T) { + t.Run("ok - input >> compress >> expand == input", func(t *testing.T) { // make bitstring with random flags - bs := *NewBitstring() - assert.Equal(t, bs, *NewBitstring()) + bs := *newBitstring() + assert.Equal(t, bs, *newBitstring()) for i := 0; i < 10; i++ { n := rand.Intn(defaultBitstringLengthInBytes * 8) - assert.NoError(t, bs.SetBit(n, true)) + assert.NoError(t, bs.setBit(n, true)) } - assert.NotEqual(t, bs, *NewBitstring()) + assert.NotEqual(t, bs, *newBitstring()) // compress the input - compressed, err := Compress(bs) + compressed, err := compress(bs) require.NoError(t, err) // GZIP can contain some platform specific values meaning that we cannot compare it to a hardcoded result - compressedEmpty, err := Compress(*NewBitstring()) + compressedEmpty, err := compress(*newBitstring()) require.NoError(t, err) assert.NotEqual(t, compressed, compressedEmpty) // expand compressed and validate against original - expanded, err := Expand(compressed) + expanded, err := expand(compressed) assert.NoError(t, err) assert.Equal(t, bs, expanded) }) - t.Run("ok - Expand is padding agnostic", func(t *testing.T) { + t.Run("ok - expand is padding agnostic", func(t *testing.T) { // accepts padding padded := "H4sIAAAAAAAA_-zaMQ6FIBAE0P-NhaVH9ugm9lBJhpX3WpoJFcPuj1dc6QAwypkOAAAAAMBKts6Zr6qHa4By_ukAANUd6QAszDIIAMBUms8zrQG-z3SkQXGlpD0dgFJ6KwQAjKBjwzTuAAAA___vXAvwAEAAAA==" - padExpanded, err := Expand(padded) + padExpanded, err := expand(padded) require.NoError(t, err) // accepts no padding notPadded := "H4sIAAAAAAAA_-zaMQ6FIBAE0P-NhaVH9ugm9lBJhpX3WpoJFcPuj1dc6QAwypkOAAAAAMBKts6Zr6qHa4By_ukAANUd6QAszDIIAMBUms8zrQG-z3SkQXGlpD0dgFJ6KwQAjKBjwzTuAAAA___vXAvwAEAAAA" - nopadExpanded, err := Expand(notPadded) + nopadExpanded, err := expand(notPadded) require.NoError(t, err) // results are the same diff --git a/vcr/verifier/credential_status.go b/vcr/statuslist2021/credential_status.go similarity index 75% rename from vcr/verifier/credential_status.go rename to vcr/statuslist2021/credential_status.go index 7720bb0f0a..62627e0792 100644 --- a/vcr/verifier/credential_status.go +++ b/vcr/statuslist2021/credential_status.go @@ -16,7 +16,7 @@ * */ -package verifier +package statuslist2021 import ( "encoding/json" @@ -27,21 +27,27 @@ import ( "strconv" "time" - ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/vc" "github.com/nuts-foundation/nuts-node/core" - "github.com/nuts-foundation/nuts-node/vcr/credential" - "github.com/nuts-foundation/nuts-node/vcr/credential/statuslist2021" "github.com/nuts-foundation/nuts-node/vcr/log" "github.com/nuts-foundation/nuts-node/vcr/types" ) -type credentialStatus struct { +type VerifySignFn func(credentialToVerify vc.VerifiableCredential, validateAt *time.Time) error // TODO: replace with new SignatureVerifier interface? + +func NewCredentialStatus(client core.HTTPRequestDoer, signVerifier VerifySignFn) *CredentialStatus { + return &CredentialStatus{ + client: client, + verifySignature: signVerifier, + } +} + +type CredentialStatus struct { client core.HTTPRequestDoer - verifySignature func(credentialToVerify vc.VerifiableCredential, validateAt *time.Time) error // TODO: replace with new SignatureVerifier interface? + verifySignature VerifySignFn } -// statusList is an immutable struct containing all information needed to verify a credentialStatus +// statusList is an immutable struct containing all information needed to Verify a credentialStatus type statusList struct { // credential is the complete StatusList2021Credential this statusList is about credential *vc.VerifiableCredential @@ -50,21 +56,21 @@ type statusList struct { statusListCredential string // statusPurpose is the purpose listed in the StatusList2021Credential.credentialSubject statusPurpose string - // expanded StatusList2021 Bitstring - expanded statuslist2021.Bitstring + // expanded StatusList2021 bitstring + expanded bitstring // lastUpdated is the timestamp this statusList was generated lastUpdated time.Time } -// VerifyCredentialStatus returns a types.ErrRevoked when the credentialStatus contains a 'StatusList2021Entry' that can be resolved and lists the credential as 'revoked' +// Verify CredentialStatus returns a types.ErrRevoked when the credentialStatus contains a 'StatusList2021Entry' that can be resolved and lists the credential as 'revoked' // Other credentialStatus type/statusPurpose are ignored. Verification may fail with other non-standardized errors. -func (cs *credentialStatus) verify(credentialToVerify vc.VerifiableCredential) error { +func (cs *CredentialStatus) Verify(credentialToVerify vc.VerifiableCredential) error { if credentialToVerify.CredentialStatus == nil { return nil } statuses, err := credentialToVerify.CredentialStatuses() if err != nil { - // cannot happen. already validated in credential.defaultCredentialValidator{} + // cannot happen. already validated in defaultCredentialValidator{} return err } @@ -72,7 +78,7 @@ func (cs *credentialStatus) verify(credentialToVerify vc.VerifiableCredential) e // returns errors if processing fails -> TODO: hard/soft fail option? // returns types.ErrRevoked if correct type, purpose, and listed. for _, status := range statuses { - if status.Type != credential.StatusList2021EntryType { + if status.Type != EntryType { // ignore other credentialStatus.type // TODO: what log level? log.Logger(). @@ -80,7 +86,7 @@ func (cs *credentialStatus) verify(credentialToVerify vc.VerifiableCredential) e Info("ignoring credentialStatus with unknown type") continue } - var slEntry credential.StatusList2021Entry // CredentialStatus of the credentialToVerify + var slEntry Entry // CredentialStatus of the credentialToVerify if err = json.Unmarshal(status.Raw(), &slEntry); err != nil { // cannot happen. already validated in credential.defaultCredentialValidator{} return err @@ -109,7 +115,7 @@ func (cs *credentialStatus) verify(credentialToVerify vc.VerifiableCredential) e // cannot happen. already validated in credential.defaultCredentialValidator{} return err } - revoked, err := sList.expanded.Bit(index) + revoked, err := sList.expanded.bit(index) if err != nil { return err } @@ -120,14 +126,14 @@ func (cs *credentialStatus) verify(credentialToVerify vc.VerifiableCredential) e return nil } -func (cs *credentialStatus) statusList(statusListCredential string) (*statusList, error) { +func (cs *CredentialStatus) statusList(statusListCredential string) (*statusList, error) { // TODO: check if there is a cached version to return return cs.update(statusListCredential) } // update -func (cs *credentialStatus) update(statusListCredential string) (*statusList, error) { - // download and verify +func (cs *CredentialStatus) update(statusListCredential string) (*statusList, error) { + // download and Verify cred, err := cs.download(statusListCredential) if err != nil { return nil, err @@ -138,7 +144,7 @@ func (cs *credentialStatus) update(statusListCredential string) (*statusList, er } // make statusList - expanded, err := statuslist2021.Expand(credSubject.EncodedList) + expanded, err := expand(credSubject.EncodedList) if err != nil { // cant happen, already checked in verifyStatusList2021Credential return nil, err @@ -156,7 +162,7 @@ func (cs *credentialStatus) update(statusListCredential string) (*statusList, er } // download the StatusList2021Credential found at statusList2021Entry.statusListCredential -func (cs *credentialStatus) download(statusListCredential string) (*vc.VerifiableCredential, error) { +func (cs *CredentialStatus) download(statusListCredential string) (*vc.VerifiableCredential, error) { var cred vc.VerifiableCredential // VC containing CredentialStatus of the credentialToVerify req, err := http.NewRequest(http.MethodGet, statusListCredential, nil) if err != nil { @@ -171,7 +177,7 @@ func (cs *credentialStatus) download(statusListCredential string) (*vc.Verifiabl // log, don't fail log.Logger(). WithError(err). - WithField("method", "credentialStatus.download"). + WithField("method", "CredentialStatus.download"). Debug("failed to close response body") } }() @@ -186,14 +192,14 @@ func (cs *credentialStatus) download(statusListCredential string) (*vc.Verifiabl } // verifyStatusList2021Credential checks that the StatusList2021Credential is currently valid -func (cs *credentialStatus) verifyStatusList2021Credential(cred vc.VerifiableCredential) (*credential.StatusList2021CredentialSubject, error) { - // make sure we have the correct credential. - if len(cred.Type) > 2 || !cred.IsType(ssi.MustParseURI(credential.StatusList2021CredentialType)) { +func (cs *CredentialStatus) verifyStatusList2021Credential(cred vc.VerifiableCredential) (*CredentialSubject, error) { + // make sure we have the correct credential type. + if len(cred.Type) != 2 || !cred.IsType(credentialTypeURI) { return nil, errors.New("incorrect credential types") } - // returns statusList2021CredentialValidator, or Validate() fails because base type is missing - if err := credential.FindValidator(cred).Validate(cred); err != nil { + // validate credential. + if err := (credentialValidator{}).Validate(cred); err != nil { return nil, err } @@ -203,18 +209,18 @@ func (cs *credentialStatus) verifyStatusList2021Credential(cred vc.VerifiableCre } // check credentialSubject - var credSubjects []credential.StatusList2021CredentialSubject + var credSubjects []CredentialSubject if err := cred.UnmarshalCredentialSubject(&credSubjects); err != nil { - // cannot happen. already validated in credential.statusList2021CredentialValidator{} + // cannot happen. already validated in credentialValidator{} return nil, err } credSubject := credSubjects[0] // validators already ensured there is exactly 1 credentialSubject - _, err := statuslist2021.Expand(credSubject.EncodedList) + _, err := expand(credSubject.EncodedList) if err != nil { return nil, fmt.Errorf("credentialSubject.encodedList is invalid: %w", err) } - // verify signature + // Verify signature if err = cs.verifySignature(cred, nil); err != nil { return nil, err } diff --git a/vcr/verifier/credential_status_test.go b/vcr/statuslist2021/credential_status_test.go similarity index 74% rename from vcr/verifier/credential_status_test.go rename to vcr/statuslist2021/credential_status_test.go index a36c813e59..8a472ae8f6 100644 --- a/vcr/verifier/credential_status_test.go +++ b/vcr/statuslist2021/credential_status_test.go @@ -16,7 +16,7 @@ * */ -package verifier +package statuslist2021 import ( "encoding/json" @@ -28,64 +28,77 @@ import ( ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/vc" - "github.com/nuts-foundation/nuts-node/vcr/credential" + "github.com/nuts-foundation/nuts-node/vcr/assets" "github.com/nuts-foundation/nuts-node/vcr/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" ) +// copy of vcr/validNutsOrganizationCredential to prevent circular deps. +func validNutsOrganizationCredential(t *testing.T) vc.VerifiableCredential { + inputVC := vc.VerifiableCredential{} + vcJSON, err := assets.TestAssets.ReadFile("test_assets/vc.json") + if err != nil { + t.Fatal(err) + } + err = json.Unmarshal(vcJSON, &inputVC) + if err != nil { + t.Fatal(err) + } + return inputVC +} + func TestCredentialStatus_verify(t *testing.T) { t.Run("ok", func(t *testing.T) { cs, entry, _ := testSetup(t, false) - cred := credential.ValidNutsOrganizationCredential(t) + cred := validNutsOrganizationCredential(t) cred.CredentialStatus = []any{entry} - assert.NoError(t, cs.verify(cred)) + assert.NoError(t, cs.Verify(cred)) }) t.Run("ok - multiple credentialStatus", func(t *testing.T) { cs, entry, _ := testSetup(t, false) - cred := credential.ValidNutsOrganizationCredential(t) + cred := validNutsOrganizationCredential(t) cred.CredentialStatus = []any{entry, entry} - assert.NoError(t, cs.verify(cred)) + assert.NoError(t, cs.Verify(cred)) }) t.Run("ok - no credentialStatus", func(t *testing.T) { cs, _, _ := testSetup(t, false) - cred := credential.ValidNutsOrganizationCredential(t) + cred := validNutsOrganizationCredential(t) cred.CredentialStatus = nil - assert.NoError(t, cs.verify(cred)) + assert.NoError(t, cs.Verify(cred)) }) t.Run("ok - unknown credentialStatus.type is ignored", func(t *testing.T) { cs, entry, _ := testSetup(t, false) entry.Type = "SomethingElse" - cred := credential.ValidNutsOrganizationCredential(t) + cred := validNutsOrganizationCredential(t) cred.CredentialStatus = []any{entry} - assert.NoError(t, cs.verify(cred)) + assert.NoError(t, cs.Verify(cred)) }) t.Run("ok - revoked", func(t *testing.T) { cs, entry, _ := testSetup(t, true) // true - cred := credential.ValidNutsOrganizationCredential(t) + cred := validNutsOrganizationCredential(t) cred.CredentialStatus = []any{entry} - err := cs.verify(cred) + err := cs.Verify(cred) assert.ErrorIs(t, err, types.ErrRevoked) }) t.Run("ok - credentialStatus.statusPurpose != 'revocation' is ignored", func(t *testing.T) { cs, entry, _ := testSetup(t, true) // true entry.StatusPurpose = "suspension" - cred := credential.ValidNutsOrganizationCredential(t) + cred := validNutsOrganizationCredential(t) cred.CredentialStatus = []any{entry} - assert.NoError(t, cs.verify(cred)) + assert.NoError(t, cs.Verify(cred)) }) t.Run("error - cannot get statusList", func(t *testing.T) { cs, entry, _ := testSetup(t, false) cs.client = http.DefaultClient - cred := credential.ValidNutsOrganizationCredential(t) + cred := validNutsOrganizationCredential(t) cred.CredentialStatus = []any{entry} - assert.ErrorContains(t, cs.verify(cred), "tls: failed to verify certificate: x509: certificate signed by unknown authority") + assert.ErrorContains(t, cs.Verify(cred), "tls: failed to verify certificate: x509: certificate signed by unknown authority") }) t.Run("error - statusPurpose mismatch", func(t *testing.T) { // server that return StatusList2021Credential with statusPurpose == suspension - statusList2021Credential := credential.ValidStatusList2021Credential(t) - statusList2021Credential.CredentialSubject[0].(*credential.StatusList2021CredentialSubject).StatusPurpose = "suspension" + statusList2021Credential := ValidStatusList2021Credential(t) + statusList2021Credential.CredentialSubject[0].(*CredentialSubject).StatusPurpose = "suspension" credBytes, err := json.Marshal(statusList2021Credential) require.NoError(t, err) ts := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { @@ -101,19 +114,19 @@ func TestCredentialStatus_verify(t *testing.T) { // test credential entry.StatusListCredential = ts.URL - cred := credential.ValidNutsOrganizationCredential(t) + cred := validNutsOrganizationCredential(t) cred.CredentialStatus = []any{entry} - err = cs.verify(cred) + err = cs.Verify(cred) assert.EqualError(t, err, "StatusList2021Credential.credentialSubject.statusPuspose='suspension' does not match vc.credentialStatus.statusPurpose='revocation'") }) t.Run("error - credentialStatus.statusListIndex out of bounds", func(t *testing.T) { cs, entry, _ := testSetup(t, false) entry.StatusListIndex = "500000" // max is ±130k - cred := credential.ValidNutsOrganizationCredential(t) + cred := validNutsOrganizationCredential(t) cred.CredentialStatus = []any{entry} - assert.EqualError(t, cs.verify(cred), "index not in status list") + assert.EqualError(t, cs.Verify(cred), "index not in status list") }) } @@ -143,9 +156,7 @@ func TestCredentialStatus_update(t *testing.T) { }) t.Run("error - verifyStatusList2021Credential", func(t *testing.T) { cs, _, ts := testSetup(t, false) - mockVerifier := NewMockVerifier(gomock.NewController(t)) - mockVerifier.EXPECT().VerifySignature(gomock.Any(), nil).Return(errors.New("custom error")) - cs.verifySignature = mockVerifier.VerifySignature + cs.verifySignature = func(_ vc.VerifiableCredential, _ *time.Time) error { return errors.New("custom error") } sl, err := cs.update(ts.URL) @@ -156,7 +167,7 @@ func TestCredentialStatus_update(t *testing.T) { func TestCredentialStatus_download(t *testing.T) { t.Run("ok", func(t *testing.T) { - cred := credential.ValidStatusList2021Credential(t) // has bit 1 set + cred := ValidStatusList2021Credential(t) // has bit 1 set expected, err := json.Marshal(cred) require.NoError(t, err) ts := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { @@ -166,7 +177,7 @@ func TestCredentialStatus_download(t *testing.T) { })) defer ts.Close() - cs := credentialStatus{client: ts.Client()} + cs := CredentialStatus{client: ts.Client()} received, err := cs.download(ts.URL) assert.NoError(t, err) @@ -175,7 +186,7 @@ func TestCredentialStatus_download(t *testing.T) { assert.JSONEq(t, string(expected), string(actual)) }) t.Run("error - statusListCredential not a URL", func(t *testing.T) { - cs := credentialStatus{client: http.DefaultClient} + cs := CredentialStatus{client: http.DefaultClient} received, err := cs.download("%%") assert.EqualError(t, err, "parse \"%%\": invalid URL escape \"%%\"") assert.Nil(t, received) @@ -186,7 +197,7 @@ func TestCredentialStatus_download(t *testing.T) { })) defer ts.Close() - cs := credentialStatus{client: ts.Client()} + cs := CredentialStatus{client: ts.Client()} received, err := cs.download(ts.URL) assert.ErrorContains(t, err, "fetching StatusList2021Credential from") @@ -200,7 +211,7 @@ func TestCredentialStatus_download(t *testing.T) { })) defer ts.Close() - cs := &credentialStatus{client: ts.Client()} + cs := &CredentialStatus{client: ts.Client()} received, err := cs.download(ts.URL) assert.EqualError(t, err, "unexpected end of JSON input") @@ -209,60 +220,60 @@ func TestCredentialStatus_download(t *testing.T) { } func TestCredentialStatus_verifyStatusList2021Credential(t *testing.T) { - credentialStatusNoSignCheck := &credentialStatus{ + credentialStatusNoSignCheck := &CredentialStatus{ client: nil, verifySignature: func(credentialToVerify vc.VerifiableCredential, validateAt *time.Time) error { return nil }, } t.Run("ok", func(t *testing.T) { - cred := credential.ValidStatusList2021Credential(t) - expected := cred.CredentialSubject[0].(*credential.StatusList2021CredentialSubject) + cred := ValidStatusList2021Credential(t) + expected := cred.CredentialSubject[0].(*CredentialSubject) credSubj, err := credentialStatusNoSignCheck.verifyStatusList2021Credential(cred) assert.NoError(t, err) require.NotNil(t, credSubj) assert.Equal(t, *expected, *credSubj) }) t.Run("error - incorrect credential type", func(t *testing.T) { - cred := credential.ValidNutsOrganizationCredential(t) + cred := validNutsOrganizationCredential(t) credSubj, err := credentialStatusNoSignCheck.verifyStatusList2021Credential(cred) assert.EqualError(t, err, "incorrect credential types") assert.Nil(t, credSubj) }) t.Run("error - too many credential types", func(t *testing.T) { - cred := credential.ValidStatusList2021Credential(t) + cred := ValidStatusList2021Credential(t) cred.Type = append(cred.Type, ssi.MustParseURI("OneTooMany")) credSubj, err := credentialStatusNoSignCheck.verifyStatusList2021Credential(cred) assert.EqualError(t, err, "incorrect credential types") assert.Nil(t, credSubj) }) t.Run("error - credential validation failed", func(t *testing.T) { - cred := credential.ValidStatusList2021Credential(t) - cred.CredentialSubject[0].(*credential.StatusList2021CredentialSubject).Type = "wrong type" + cred := ValidStatusList2021Credential(t) + cred.CredentialSubject[0].(*CredentialSubject).Type = "wrong type" credSubj, err := credentialStatusNoSignCheck.verifyStatusList2021Credential(cred) - assert.EqualError(t, err, "validation failed: credentialSubject.type 'StatusList2021' is required") + assert.EqualError(t, err, "credentialSubject.type 'StatusList2021' is required") assert.Nil(t, credSubj) }) t.Run("error - contains CredentialStatus", func(t *testing.T) { - cred := credential.ValidStatusList2021Credential(t) + cred := ValidStatusList2021Credential(t) cred.CredentialStatus = []any{} credSubj, err := credentialStatusNoSignCheck.verifyStatusList2021Credential(cred) assert.EqualError(t, err, "StatusList2021Credential with a CredentialStatus is not supported") assert.Nil(t, credSubj) }) t.Run("error - invalid credentialSubject.encodedList", func(t *testing.T) { - cred := credential.ValidStatusList2021Credential(t) - cred.CredentialSubject[0].(*credential.StatusList2021CredentialSubject).EncodedList = "@" + cred := ValidStatusList2021Credential(t) + cred.CredentialSubject[0].(*CredentialSubject).EncodedList = "@" credSubj, err := credentialStatusNoSignCheck.verifyStatusList2021Credential(cred) assert.EqualError(t, err, "credentialSubject.encodedList is invalid: illegal base64 data at input byte 0") assert.Nil(t, credSubj) }) t.Run("error -invalid signature", func(t *testing.T) { - cred := credential.ValidStatusList2021Credential(t) - mockVerifier := NewMockVerifier(gomock.NewController(t)) - mockVerifier.EXPECT().VerifySignature(cred, nil).Return(errors.New("invalid signature")) - cs := credentialStatus{verifySignature: mockVerifier.VerifySignature} + cred := ValidStatusList2021Credential(t) + cs := CredentialStatus{verifySignature: func(credentialToVerify vc.VerifiableCredential, validateAt *time.Time) error { + return errors.New("invalid signature") + }} credSubj, err := cs.verifyStatusList2021Credential(cred) assert.EqualError(t, err, "invalid signature") assert.Nil(t, credSubj) @@ -270,12 +281,12 @@ func TestCredentialStatus_verifyStatusList2021Credential(t *testing.T) { } // testSetup returns -// - credentialStatus that does NOT verify signatures, and a client configured for the test server +// - credentialStatus that does NOT Verify signatures, and a client configured for the test server // - a StatusList2021Entry pointing to the test server, optionally provide a statusListIndex matching statusList2021Credential.encodedList to simulate revocation // - the test server -func testSetup(t testing.TB, entryIsRevoked bool) (*credentialStatus, credential.StatusList2021Entry, *httptest.Server) { +func testSetup(t testing.TB, entryIsRevoked bool) (*CredentialStatus, Entry, *httptest.Server) { // make test server - statusList2021Credential := credential.ValidStatusList2021Credential(t) // has bit 1 set + statusList2021Credential := ValidStatusList2021Credential(t) // has bit 1 set credBytes, err := json.Marshal(statusList2021Credential) if err != nil { t.Fatal(err) @@ -288,7 +299,7 @@ func testSetup(t testing.TB, entryIsRevoked bool) (*credentialStatus, credential t.Cleanup(func() { ts.Close() }) // make credentialStatus - credentialStatusNoSignCheck := &credentialStatus{ + credentialStatusNoSignCheck := &CredentialStatus{ client: ts.Client(), verifySignature: func(credentialToVerify vc.VerifiableCredential, validateAt *time.Time) error { return nil @@ -296,8 +307,8 @@ func testSetup(t testing.TB, entryIsRevoked bool) (*credentialStatus, credential } // make StatusList2021Entry - slEntry := credential.StatusList2021Entry{ - Type: credential.StatusList2021EntryType, + slEntry := Entry{ + Type: EntryType, StatusPurpose: "revocation", StatusListIndex: "76248", StatusListCredential: ts.URL, diff --git a/vcr/statuslist/store.go b/vcr/statuslist2021/store.go similarity index 85% rename from vcr/statuslist/store.go rename to vcr/statuslist2021/store.go index 80ac85c899..e765d3c153 100644 --- a/vcr/statuslist/store.go +++ b/vcr/statuslist2021/store.go @@ -1,4 +1,22 @@ -package statuslist +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package statuslist2021 import ( "context" @@ -9,8 +27,6 @@ import ( ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" - "github.com/nuts-foundation/nuts-node/vcr/credential" - "github.com/nuts-foundation/nuts-node/vcr/credential/statuslist2021" "github.com/nuts-foundation/nuts-node/vcr/types" "github.com/nuts-foundation/nuts-node/vdr/didweb" "gorm.io/driver/sqlite" @@ -38,15 +54,15 @@ const ( // them by setting the relevant bit on the StatusList. // Individual statuses should be derived from the StatusList2021Credential(s), not inspected here. type StatusList2021Issuer interface { - // CredentialSubject creates a StatusList2021CredentialSubject to incorporate in a StatusList2021Credential issued by issuer. - CredentialSubject(ctx context.Context, issuer did.DID, page int) (*credential.StatusList2021CredentialSubject, error) - // Create a credential.StatusList2021Entry that can be added to the credentialStatus of a VC. + // CredentialSubject creates a CredentialSubject to incorporate in a StatusList2021Credential issued by issuer. + CredentialSubject(ctx context.Context, issuer did.DID, page int) (*CredentialSubject, error) + // Create a StatusList2021Entry that can be added to the credentialStatus of a VC. // The corresponding credential will have a gap in the bitstring if the returned entry does not make it into a credential. - Create(ctx context.Context, issuer did.DID, purpose StatusPurpose) (*credential.StatusList2021Entry, error) + Create(ctx context.Context, issuer did.DID, purpose StatusPurpose) (*Entry, error) // Revoke by adding StatusList2021Entry to the list of revocations. // The credentialID is only used to allow reverse search of revocations, its issuer is NOT compared to the entry issuer. // Returns types.ErrRevoked if already revoked, or types.ErrNotFound when the entry.StatusListCredential is unknown. - Revoke(ctx context.Context, credentialID ssi.URI, entry credential.StatusList2021Entry) error + Revoke(ctx context.Context, credentialID ssi.URI, entry Entry) error } func (s statusListCredentialRecord) TableName() string { @@ -62,7 +78,7 @@ type statusListCredentialRecord struct { Issuer string // Page number corresponding to this SubjectID. Page int - // LastIssuedIndex on this page. Range: 0 <= StatusListIndex < statuslist2021.MaxBitstringIndex + // LastIssuedIndex on this page. Range: 0 <= StatusListIndex < statuslist2021.maxBitstringIndex LastIssuedIndex int // Revocations list all revocations for this SubjectID Revocations []revocationRecord `gorm:"foreignKey:StatusListCredential;references:SubjectID"` @@ -76,7 +92,7 @@ func (s revocationRecord) TableName() string { type revocationRecord struct { // StatusListCredential is the credentialSubject.ID this revocation belongs to. Example https://example.com/iam/id/statuslist/1 StatusListCredential string `gorm:"primaryKey"` - // StatusListIndex of the revoked status list entry. Range: 0 <= StatusListIndex <= statuslist2021.MaxBitstringIndex + // StatusListIndex of the revoked status list entry. Range: 0 <= StatusListIndex <= statuslist2021.maxBitstringIndex StatusListIndex int `gorm:"primaryKey;autoIncrement:false"` // CredentialID is the VC.ID of the credential revoked by this status list entry. // The value is stored as convenience during revocation, but is not validated. @@ -102,7 +118,7 @@ func NewStatusListStore(db *gorm.DB) (*sqlStore, error) { return &sqlStore{db: db}, nil } -func (s *sqlStore) CredentialSubject(ctx context.Context, issuer did.DID, page int) (*credential.StatusList2021CredentialSubject, error) { +func (s *sqlStore) CredentialSubject(ctx context.Context, issuer did.DID, page int) (*CredentialSubject, error) { statusListCredential, err := toStatusListCredential(issuer, page) if err != nil { return nil, err @@ -120,29 +136,29 @@ func (s *sqlStore) CredentialSubject(ctx context.Context, issuer did.DID, page i } // make encodedList - bitstring := statuslist2021.NewBitstring() + bitstring := newBitstring() for _, rev := range statuslist.Revocations { - if err = bitstring.SetBit(rev.StatusListIndex, true); err != nil { + if err = bitstring.setBit(rev.StatusListIndex, true); err != nil { // can't happen return nil, err } } - encodedList, err := statuslist2021.Compress(*bitstring) + encodedList, err := compress(*bitstring) if err != nil { // can't happen return nil, err } // return credential subject - return &credential.StatusList2021CredentialSubject{ + return &CredentialSubject{ Id: statusListCredential, - Type: credential.StatusList2021CredentialSubjectType, + Type: CredentialSubjectType, StatusPurpose: StatusPurposeRevocation, EncodedList: encodedList, }, nil } -func (s *sqlStore) Create(ctx context.Context, issuer did.DID, purpose StatusPurpose) (*credential.StatusList2021Entry, error) { +func (s *sqlStore) Create(ctx context.Context, issuer did.DID, purpose StatusPurpose) (*Entry, error) { if purpose != StatusPurposeRevocation { return nil, errUnsupportedPurpose } @@ -181,7 +197,7 @@ func (s *sqlStore) Create(ctx context.Context, issuer did.DID, purpose StatusPur // first time issuer; prepare to create a new Page / StatusListCredential credentialRecord = statusListCredentialRecord{ Issuer: issuer.String(), - LastIssuedIndex: statuslist2021.MaxBitstringIndex, // this will be incremented to move to page 1 + LastIssuedIndex: maxBitstringIndex, // this will be incremented to move to page 1 Page: 0, } } @@ -190,7 +206,7 @@ func (s *sqlStore) Create(ctx context.Context, issuer did.DID, purpose StatusPur credentialRecord.LastIssuedIndex++ // create new page (statusListCredential) if current is full - if credentialRecord.LastIssuedIndex > statuslist2021.MaxBitstringIndex { + if credentialRecord.LastIssuedIndex > maxBitstringIndex { credentialRecord.LastIssuedIndex = 0 credentialRecord.Page++ @@ -229,16 +245,16 @@ func (s *sqlStore) Create(ctx context.Context, issuer did.DID, purpose StatusPur break } - return &credential.StatusList2021Entry{ + return &Entry{ ID: fmt.Sprintf("%s#%d", credentialRecord.SubjectID, credentialRecord.LastIssuedIndex), - Type: credential.StatusList2021EntryType, + Type: EntryType, StatusPurpose: StatusPurposeRevocation, StatusListIndex: strconv.Itoa(credentialRecord.LastIssuedIndex), StatusListCredential: credentialRecord.SubjectID, }, nil } -func (s *sqlStore) Revoke(ctx context.Context, credentialID ssi.URI, entry credential.StatusList2021Entry) error { +func (s *sqlStore) Revoke(ctx context.Context, credentialID ssi.URI, entry Entry) error { // parse StatusListIndex statusListIndex, err := strconv.Atoi(entry.StatusListIndex) if err != nil { @@ -263,7 +279,7 @@ func (s *sqlStore) Revoke(ctx context.Context, credentialID ssi.URI, entry crede // validate StatusListIndex if statusListIndex < 0 || statusListIndex > statuslist.LastIssuedIndex { - return statuslist2021.ErrIndexNotInBitstring + return ErrIndexNotInBitstring } // revoke diff --git a/vcr/statuslist/store_test.go b/vcr/statuslist2021/store_test.go similarity index 89% rename from vcr/statuslist/store_test.go rename to vcr/statuslist2021/store_test.go index ae2bc4a54e..d33da9aecb 100644 --- a/vcr/statuslist/store_test.go +++ b/vcr/statuslist2021/store_test.go @@ -1,4 +1,22 @@ -package statuslist +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package statuslist2021 import ( "context" @@ -10,8 +28,6 @@ import ( ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" "github.com/nuts-foundation/nuts-node/storage" - "github.com/nuts-foundation/nuts-node/vcr/credential" - "github.com/nuts-foundation/nuts-node/vcr/credential/statuslist2021" "github.com/nuts-foundation/nuts-node/vcr/types" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -42,7 +58,7 @@ func TestSqlStore_Create(t *testing.T) { require.NoError(t, err) storage.AddDIDtoSQLDB(t, s.db, aliceDID, bobDID) // NOTE: most tests re-use same store - var entry *credential.StatusList2021Entry + var entry *Entry t.Run("ok", func(t *testing.T) { @@ -57,7 +73,7 @@ func TestSqlStore_Create(t *testing.T) { assert.Equal(t, statusListCredential, entry.StatusListCredential) assert.Equal(t, "0", entry.StatusListIndex) assert.Equal(t, fmt.Sprintf("%s#0", statusListCredential), entry.ID) - assert.Equal(t, credential.StatusList2021EntryType, entry.Type) + assert.Equal(t, EntryType, entry.Type) assert.Equal(t, StatusPurposeRevocation, entry.StatusPurpose) }) t.Run("second entry", func(t *testing.T) { @@ -78,7 +94,7 @@ func TestSqlStore_Create(t *testing.T) { // set last_issued_index to max value for a single credential so the next entry will be in page 2 s.db.Model(&statusListCredentialRecord{}). Where("subject_id = ?", statusListCredential). - Update("last_issued_index", statuslist2021.MaxBitstringIndex) + Update("last_issued_index", maxBitstringIndex) statusListCredential, _ = toStatusListCredential(aliceDID, 2) // now expect page 2 to be used entry, err = s.Create(nil, aliceDID, StatusPurposeRevocation) @@ -178,7 +194,7 @@ func TestSqlStore_Create(t *testing.T) { defer storePG.writeLock.Unlock() raceFn(storePG) // To confirm there was a race condition on the page creation (can't happen with SQLite), check the logs for: - // 2024/02/12 19:53:20 .../nuts-node/vcr/statuslist/store.go:196 duplicated key not allowed + // 2024/02/12 19:53:20 .../nuts-node/vcr/statuslist2021/store.go:196 duplicated key not allowed // If this error was logged and the test did not fail it is handled correctly. }) }) @@ -227,7 +243,7 @@ func TestSqlStore_Revoke(t *testing.T) { t.Run("error - statusListIndex OOB", func(t *testing.T) { cEntry := entry cEntry.StatusListIndex = "10" - assert.ErrorIs(t, s.Revoke(nil, ssi.URI{}, cEntry), statuslist2021.ErrIndexNotInBitstring) + assert.ErrorIs(t, s.Revoke(nil, ssi.URI{}, cEntry), ErrIndexNotInBitstring) }) } @@ -242,11 +258,11 @@ func TestSqlStore_CredentialSubject(t *testing.T) { entry := *entryP t.Run("ok - empty bitstring", func(t *testing.T) { - encodedList, err := statuslist2021.Compress(*statuslist2021.NewBitstring()) + encodedList, err := compress(*newBitstring()) assert.NoError(t, err) - expectedCS := credential.StatusList2021CredentialSubject{ + expectedCS := CredentialSubject{ Id: entry.StatusListCredential, - Type: credential.StatusList2021CredentialSubjectType, + Type: CredentialSubjectType, StatusPurpose: StatusPurposeRevocation, EncodedList: encodedList, } @@ -264,7 +280,7 @@ func TestSqlStore_CredentialSubject(t *testing.T) { assert.NoError(t, err) require.NotNil(t, cs) - bs, err := statuslist2021.Expand(cs.EncodedList) + bs, err := expand(cs.EncodedList) assert.NoError(t, err) assert.NotEmpty(t, bs) }) diff --git a/vcr/statuslist2021/test.go b/vcr/statuslist2021/test.go new file mode 100644 index 0000000000..399825c8d4 --- /dev/null +++ b/vcr/statuslist2021/test.go @@ -0,0 +1,48 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package statuslist2021 + +import ( + "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/go-did/vc" + "testing" + "time" +) + +func ValidStatusList2021Credential(_ testing.TB) vc.VerifiableCredential { + id := ssi.MustParseURI("https://example.com/credentials/status/3") + validFrom := time.Now() + validUntilTomorrow := validFrom.Add(24 * time.Hour) + return vc.VerifiableCredential{ + Context: []ssi.URI{vc.VCContextV1URI(), ContextURI}, + ID: &id, + Type: []ssi.URI{vc.VerifiableCredentialTypeV1URI(), ssi.MustParseURI(CredentialType)}, + Issuer: ssi.MustParseURI("did:example:12345"), + ValidFrom: &validFrom, + ValidUntil: &validUntilTomorrow, + CredentialStatus: nil, + CredentialSubject: []any{&CredentialSubject{ + Id: "https://example-com/status/3#list", + Type: CredentialSubjectType, + StatusPurpose: "revocation", + EncodedList: "H4sIAAAAAAAA_-zAsQAAAAACsNDypwqjZ2sAAAAAAAAAAAAAAAAAAACAtwUAAP__NxdfzQBAAAA=", // has bit 1 set to true + }}, + Proof: []any{vc.Proof{}}, + } +} diff --git a/vcr/statuslist2021/types.go b/vcr/statuslist2021/types.go new file mode 100644 index 0000000000..801c1366d7 --- /dev/null +++ b/vcr/statuslist2021/types.go @@ -0,0 +1,67 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package statuslist2021 + +import ( + "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/nuts-node/jsonld" +) + +const ( + // CredentialType is the type of StatusList2021Credential + CredentialType = "StatusList2021Credential" + // CredentialSubjectType is the credentialSubject.type in a StatusList2021Credential + CredentialSubjectType = "StatusList2021" + // EntryType is the credentialStatus.type that lists the entry of that credential on a list + EntryType = "StatusList2021Entry" +) + +var ContextURI = ssi.MustParseURI(jsonld.W3cStatusList2021Context) +var credentialTypeURI = ssi.MustParseURI(CredentialType) + +// Entry is the "credentialStatus" property used by issuers to enable VerifiableCredential status information. +type Entry struct { + // ID is expected to be a URL that identifies the status information associated with the verifiable credential. + // It MUST NOT be the URL for the status list, which is in StatusListCredential. + ID string `json:"id,omitempty"` + // Type MUST be "StatusList2021Entry" + Type string `json:"type,omitempty"` + // StatusPurpose indicates what it means if the VerifiableCredential is on the list. + // The value is arbitrary, with predefined values `revocation` and `suspension`. + // This value must match credentialSubject.statusPurpose value in the VerifiableCredential. + StatusPurpose string `json:"statusPurpose,omitempty"` + // StatusListIndex is an arbitrary size integer greater than or equal to 0, expressed as a string. + // The value identifies the bit position of the status of the verifiable credential. + StatusListIndex string `json:"statusListIndex,omitempty"` + // The statusListCredential property MUST be a URL to a verifiable credential. + // When the URL is dereferenced, the resulting verifiable credential MUST have type property that includes the "StatusList2021Credential" value. + StatusListCredential string `json:"statusListCredential,omitempty"` +} + +type CredentialSubject struct { + // ID for the credential subject + Id string `json:"id"` + // Type MUST be "StatusList2021Credential" + Type string `json:"type"` + // StatusPurpose defines the reason credentials are listed. ('revocation', 'suspension') + StatusPurpose string `json:"statusPurpose"` + // EncodedList is the GZIP-compressed [RFC1952], base-64 encoded [RFC4648] bitstring values for the associated range + // of verifiable credential status values. The uncompressed bitstring MUST be at least 16KB in size. + EncodedList string `json:"encodedList"` +} diff --git a/vcr/statuslist2021/validator.go b/vcr/statuslist2021/validator.go new file mode 100644 index 0000000000..e417cfae26 --- /dev/null +++ b/vcr/statuslist2021/validator.go @@ -0,0 +1,104 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package statuslist2021 + +import ( + "errors" + "fmt" + "github.com/nuts-foundation/go-did/vc" +) + +// TODO: copy from credential package, merge with other +type defaultCredentialValidator struct { +} + +func (d defaultCredentialValidator) Validate(credential vc.VerifiableCredential) error { + if !credential.IsType(vc.VerifiableCredentialTypeV1URI()) { + return errors.New("type 'VerifiableCredential' is required") + } + + if !credential.ContainsContext(vc.VCContextV1URI()) { + return errors.New("default context is required") + } + + if credential.ID == nil { + return errors.New("'ID' is required") + } + + // 'issuanceDate' must be present, but can be zero if replaced by alias 'validFrom' + if (credential.IssuanceDate == nil || credential.IssuanceDate.IsZero()) && + (credential.ValidFrom == nil || credential.ValidFrom.IsZero()) { + return errors.New("'issuanceDate' is required") + } + + if credential.Format() == vc.JSONLDCredentialProofFormat && credential.Proof == nil { + return errors.New("'proof' is required for JSON-LD credentials") + } + + //// CredentialStatus is not specific to the credential type and the syntax (not status) should be checked here. + //if err := credential.validateCredentialStatus(credential); err != nil { + // return fmt.Errorf("invalid credentialStatus: %s", err) + //} + + return nil +} + +// credentialValidator validates that all required fields of a StatusList2021Credential are present +type credentialValidator struct{} + +func (d credentialValidator) Validate(credential vc.VerifiableCredential) error { + if err := (defaultCredentialValidator{}).Validate(credential); err != nil { + return err + } + + { // Credential checks + if !credential.ContainsContext(ContextURI) { + return fmt.Errorf("context '%s' is required", ContextURI) + } + if !credential.IsType(credentialTypeURI) { + return fmt.Errorf("type '%s' is required", credentialTypeURI) + } + } + + { // CredentialSubject checks + var target []CredentialSubject + err := credential.UnmarshalCredentialSubject(&target) + if err != nil { + return err + } + // The spec is not clear if there could be multiple CredentialSubjects. This could allow 'revocation' and 'suspension' to be defined in a single credential. + // However, it is not defined how to select the correct list (StatusPurpose) when validating credentials that are using this StatusList2021Credential. + if len(target) != 1 { + return errors.New("single CredentialSubject expected") + } + cs := target[0] + + if cs.Type != CredentialSubjectType { + return fmt.Errorf("credentialSubject.type '%s' is required", CredentialSubjectType) + } + if cs.StatusPurpose == "" { + return errors.New("credentialSubject.statusPurpose is required") + } + if cs.EncodedList == "" { + return errors.New("credentialSubject.encodedList is required") + } + } + + return nil +} diff --git a/vcr/statuslist2021/validator_test.go b/vcr/statuslist2021/validator_test.go new file mode 100644 index 0000000000..dd008fcf2a --- /dev/null +++ b/vcr/statuslist2021/validator_test.go @@ -0,0 +1,88 @@ +/* + * Copyright (C) 2024 Nuts community + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package statuslist2021 + +import ( + "github.com/nuts-foundation/go-did" + "github.com/nuts-foundation/go-did/vc" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestStatusList2021CredentialValidator_Validate(t *testing.T) { + t.Run("ok", func(t *testing.T) { + cred := ValidStatusList2021Credential(t) + err := credentialValidator{}.Validate(cred) + assert.NoError(t, err) + }) + t.Run("error - wraps defaultCredentialValidator", func(t *testing.T) { + cred := ValidStatusList2021Credential(t) + cred.Context = []ssi.URI{credentialTypeURI} + err := credentialValidator{}.Validate(cred) + assert.EqualError(t, err, "default context is required") + }) + t.Run("error - missing status list context", func(t *testing.T) { + cred := ValidStatusList2021Credential(t) + cred.Context = []ssi.URI{vc.VCContextV1URI()} + err := credentialValidator{}.Validate(cred) + assert.EqualError(t, err, "context 'https://w3id.org/vc/status-list/2021/v1' is required") + }) + t.Run("error - missing StatusList credential type", func(t *testing.T) { + cred := ValidStatusList2021Credential(t) + cred.Type = []ssi.URI{vc.VerifiableCredentialTypeV1URI()} + err := credentialValidator{}.Validate(cred) + assert.EqualError(t, err, "type 'StatusList2021Credential' is required") + }) + t.Run("error - invalid credential subject", func(t *testing.T) { + cred := ValidStatusList2021Credential(t) + cred.CredentialSubject = []any{"{"} + err := credentialValidator{}.Validate(cred) + assert.EqualError(t, err, "json: cannot unmarshal string into Go value of type statuslist2021.CredentialSubject") + }) + t.Run("error - wrong credential subject", func(t *testing.T) { + cred := ValidStatusList2021Credential(t) + cred.CredentialSubject = []any{struct{}{}} + err := credentialValidator{}.Validate(cred) + assert.EqualError(t, err, "credentialSubject.type 'StatusList2021' is required") + }) + t.Run("error - multiple credentialSubject", func(t *testing.T) { + cred := ValidStatusList2021Credential(t) + cred.CredentialSubject = []any{CredentialSubject{}, CredentialSubject{}} + err := credentialValidator{}.Validate(cred) + assert.EqualError(t, err, "single CredentialSubject expected") + }) + t.Run("error - missing credentialSubject.type", func(t *testing.T) { + cred := ValidStatusList2021Credential(t) + cred.CredentialSubject[0].(*CredentialSubject).Type = "" + err := credentialValidator{}.Validate(cred) + assert.EqualError(t, err, "credentialSubject.type 'StatusList2021' is required") + }) + t.Run("error - missing statusPurpose", func(t *testing.T) { + cred := ValidStatusList2021Credential(t) + cred.CredentialSubject[0].(*CredentialSubject).StatusPurpose = "" + err := credentialValidator{}.Validate(cred) + assert.EqualError(t, err, "credentialSubject.statusPurpose is required") + }) + t.Run("error - missing encodedList", func(t *testing.T) { + cred := ValidStatusList2021Credential(t) + cred.CredentialSubject[0].(*CredentialSubject).EncodedList = "" + err := credentialValidator{}.Validate(cred) + assert.EqualError(t, err, "credentialSubject.encodedList is required") + }) +} diff --git a/vcr/vcr.go b/vcr/vcr.go index 4fa7defb2f..9a3084fb70 100644 --- a/vcr/vcr.go +++ b/vcr/vcr.go @@ -29,7 +29,7 @@ import ( "github.com/nuts-foundation/nuts-node/pki" "github.com/nuts-foundation/nuts-node/vcr/credential" "github.com/nuts-foundation/nuts-node/vcr/openid4vci" - "github.com/nuts-foundation/nuts-node/vcr/statuslist" + "github.com/nuts-foundation/nuts-node/vcr/statuslist2021" "github.com/nuts-foundation/nuts-node/vdr" "github.com/nuts-foundation/nuts-node/vdr/resolver" "io/fs" @@ -263,7 +263,7 @@ func (c *vcr) Configure(config core.ServerConfig) error { c.walletHttpClient = core.NewStrictHTTPClient(config.Strictmode, c.config.OpenID4VCI.Timeout, tlsConfig) c.openidSessionStore = c.storageClient.GetSessionDatabase() } - status, err := statuslist.NewStatusListStore(c.storageClient.GetSQLDatabase()) + status, err := statuslist2021.NewStatusListStore(c.storageClient.GetSQLDatabase()) if err != nil { return err } diff --git a/vcr/verifier/verifier.go b/vcr/verifier/verifier.go index 71a6ce5f74..35c3011c0a 100644 --- a/vcr/verifier/verifier.go +++ b/vcr/verifier/verifier.go @@ -22,6 +22,7 @@ import ( "encoding/json" "errors" "fmt" + "github.com/nuts-foundation/nuts-node/vcr/statuslist2021" "strings" "time" @@ -57,7 +58,7 @@ type verifier struct { store Store trustConfig *trust.Config signatureVerifier - credentialStatus *credentialStatus + credentialStatus *statuslist2021.CredentialStatus } // VerificationError is used to describe a VC/VP verification failure. @@ -91,10 +92,8 @@ func NewVerifier(store Store, didResolver resolver.DIDResolver, keyResolver reso keyResolver: keyResolver, jsonldManager: jsonldManager, } - v.credentialStatus = &credentialStatus{ - client: client, - verifySignature: v.signatureVerifier.VerifySignature, - } + v.credentialStatus = statuslist2021.NewCredentialStatus(client, v.signatureVerifier.VerifySignature) + return v } @@ -126,7 +125,7 @@ func (v verifier) Verify(credentialToVerify vc.VerifiableCredential, allowUntrus } // Check the credentialStatus if the credential is revoked - err := v.credentialStatus.verify(credentialToVerify) + err := v.credentialStatus.Verify(credentialToVerify) if err != nil { // soft fail, only return an error when revocation is confirmed and log everything else if errors.Is(err, types.ErrRevoked) { diff --git a/vcr/verifier/verifier_test.go b/vcr/verifier/verifier_test.go index 09ad3e0025..387acf1214 100644 --- a/vcr/verifier/verifier_test.go +++ b/vcr/verifier/verifier_test.go @@ -22,6 +22,7 @@ import ( crypt "crypto" "encoding/json" "errors" + "github.com/nuts-foundation/nuts-node/vcr/statuslist2021" "github.com/nuts-foundation/nuts-node/vcr/test" "net/http" "net/http/httptest" @@ -133,7 +134,7 @@ func TestVerifier_Verify(t *testing.T) { t.Run("validate credentialStatus", func(t *testing.T) { // make StatusList2021Credential with a revocation bit set - statusListCred := credential.ValidStatusList2021Credential(t) + statusListCred := statuslist2021.ValidStatusList2021Credential(t) statusListCredBytes, err := json.Marshal(statusListCred) require.NoError(t, err) statusListIndex := 1 // bit 1 is set in slCred @@ -144,9 +145,9 @@ func TestVerifier_Verify(t *testing.T) { })) // statusListEntry for credentialToValidate without statusListIndex - slEntry := credential.StatusList2021Entry{ + slEntry := statuslist2021.Entry{ ID: "https://example-com/credentials/status/3#statusListIndex", - Type: credential.StatusList2021EntryType, + Type: statuslist2021.EntryType, StatusPurpose: "revocation", StatusListCredential: ts.URL, } @@ -155,7 +156,7 @@ func TestVerifier_Verify(t *testing.T) { http.DefaultClient = ts.Client() // newMockContext sets credentialStatus.client to http.DefaultClient ctx := newMockContext(t) ctx.store.EXPECT().GetRevocations(gomock.Any()).Return([]*credential.Revocation{{}}, ErrNotFound).AnyTimes() - ctx.verifier.credentialStatus.verifySignature = func(credentialToVerify vc.VerifiableCredential, validateAt *time.Time) error { return nil } // don't check signatures on 'downloaded' StatusList2021Credentials + ctx.verifier.credentialStatus = statuslist2021.NewCredentialStatus(http.DefaultClient, func(_ vc.VerifiableCredential, _ *time.Time) error { return nil }) // don't check signatures on 'downloaded' StatusList2021Credentials cred := credential.ValidNutsOrganizationCredential(t) cred.Context = append(cred.Context, ssi.MustParseURI(jsonld.W3cStatusList2021Context)) @@ -189,9 +190,9 @@ func TestVerifier_Verify(t *testing.T) { ts := httptest.NewTLSServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { writer.WriteHeader(400) })) - slEntry := credential.StatusList2021Entry{ + slEntry := statuslist2021.Entry{ ID: "not relevant", - Type: credential.StatusList2021EntryType, + Type: statuslist2021.EntryType, StatusPurpose: "revocation", StatusListIndex: "1", StatusListCredential: ts.URL, //