diff --git a/pkg/doc/verifiable/credential.go b/pkg/doc/verifiable/credential.go index 469154d30..c83aad3b8 100644 --- a/pkg/doc/verifiable/credential.go +++ b/pkg/doc/verifiable/credential.go @@ -183,9 +183,35 @@ const defaultSchema = `{ } ` +// https://www.w3.org/TR/vc-data-model/#data-schemas const jsonSchema2018Type = "JsonSchemaValidator2018" -const vpType = "VerifiablePresentation" +const ( + // https://www.w3.org/TR/vc-data-model/#base-context + baseContext = "https://www.w3.org/2018/credentials/v1" + + // https://www.w3.org/TR/vc-data-model/#types + vcType = "VerifiableCredential" + + // https://www.w3.org/TR/vc-data-model/#presentations-0 + vpType = "VerifiablePresentation" +) + +// vcModelValidationMode defines constraint put on context and type of VC. +type vcModelValidationMode int + +const ( + // baseContextValidation when defined it's validated that only the fields and values (when applicable) + // are present in the document. No extra fields are allowed (outside of credentialSubject). + baseContextValidation vcModelValidationMode = iota + + // baseContextExtendedValidation when set it's validated that fields that are specified in base context are + // as specified. Additional fields are allowed. + baseContextExtendedValidation + + // jsonldValidation Use the JSON LD parser for validation. + jsonldValidation +) // SchemaCache defines a cache of credential schemas. type SchemaCache interface { @@ -397,6 +423,9 @@ type credentialOpts struct { issuerPublicKeyFetcher PublicKeyFetcher disabledCustomSchema bool schemaLoader *CredentialSchemaLoader + modelValidationMode vcModelValidationMode + allowedCustomContexts map[string]bool + allowedCustomTypes map[string]bool } // CredentialOpt is the Verifiable Credential decoding option @@ -426,6 +455,41 @@ func WithCredentialSchemaLoader(loader *CredentialSchemaLoader) CredentialOpt { } } +// WithJSONLDValidation uses the JSON LD parser for validation. +func WithJSONLDValidation() CredentialOpt { + return func(opts *credentialOpts) { + opts.modelValidationMode = jsonldValidation + } +} + +// WithBaseContextValidation validates that only the fields and values (when applicable) are present +// in the document. No extra fields are allowed (outside of credentialSubject). +func WithBaseContextValidation() CredentialOpt { + return func(opts *credentialOpts) { + opts.modelValidationMode = baseContextValidation + } +} + +// WithBaseContextExtendedValidation validates that fields that are specified in base context are as specified. +// Additional fields are allowed +func WithBaseContextExtendedValidation(customContexts, customTypes []string) CredentialOpt { + return func(opts *credentialOpts) { + opts.modelValidationMode = baseContextExtendedValidation + + opts.allowedCustomContexts = make(map[string]bool) + for _, context := range customContexts { + opts.allowedCustomContexts[context] = true + } + opts.allowedCustomContexts[baseContext] = true + + opts.allowedCustomTypes = make(map[string]bool) + for _, context := range customTypes { + opts.allowedCustomTypes[context] = true + } + opts.allowedCustomTypes[vcType] = true + } +} + // decodeIssuer decodes raw issuer. // // Issuer can be defined by: @@ -506,10 +570,10 @@ func decodeCredentialSchema(data []byte) ([]TypedID, error) { // by checking CustomFields of Credential and/or unmarshalling the JSON to custom date structure. func NewCredential(vcData []byte, opts ...CredentialOpt) (*Credential, []byte, error) { // Apply options. - crOpts := parseCredentialOpts(opts) + vcOpts := parseCredentialOpts(opts) // Decode credential (e.g. from JWT). - vcDataDecoded, err := decodeRaw(vcData, crOpts.issuerPublicKeyFetcher) + vcDataDecoded, err := decodeRaw(vcData, vcOpts.issuerPublicKeyFetcher) if err != nil { return nil, nil, fmt.Errorf("decode new credential: %w", err) } @@ -534,7 +598,7 @@ func NewCredential(vcData []byte, opts ...CredentialOpt) (*Credential, []byte, e } // Validate raw credential. - err = validate(vcDataDecoded, schemas, crOpts) + err = validate(vcDataDecoded, schemas, vcOpts) if err != nil { return nil, nil, fmt.Errorf("validate new credential: %w", err) } @@ -545,9 +609,60 @@ func NewCredential(vcData []byte, opts ...CredentialOpt) (*Credential, []byte, e return nil, nil, fmt.Errorf("build new credential: %w", err) } + err = postValidateCredential(vc, vcOpts) + if err != nil { + return nil, nil, err + } + return vc, vcDataDecoded, nil } +func postValidateCredential(vc *Credential, vcOpts *credentialOpts) error { + // Credential and type constraint. + switch vcOpts.modelValidationMode { + case jsonldValidation: + // todo support JSON-LD validation (https://github.com/hyperledger/aries-framework-go/issues/952) + return nil + + case baseContextValidation: + return validateBaseOnlyContextType(vc) + + case baseContextExtendedValidation: + return validateCustomContextType(vc, vcOpts) + + default: + return fmt.Errorf("unsupported vcModelValidationMode: %v", vcOpts.modelValidationMode) + } +} + +func validateBaseOnlyContextType(vc *Credential) error { + if len(vc.Types) > 1 || vc.Types[0] != vcType { + return errors.New("violated type constraint: not base only type defined") + } + + if len(vc.Context) > 1 || vc.Context[0] != baseContext { + return errors.New("violated @context constraint: not base only @context defined") + } + + return nil +} + +func validateCustomContextType(vc *Credential, vcOpts *credentialOpts) error { + for _, vcContext := range vc.Context { + if _, ok := vcOpts.allowedCustomContexts[vcContext]; !ok { + return fmt.Errorf("not allowed @context: %s", vcContext) + } + } + + for _, vcType := range vc.Types { + if _, ok := vcOpts.allowedCustomTypes[vcType]; !ok { + return fmt.Errorf("not allowed type: %s", vcType) + } + } + + return nil +} + // CustomCredentialProducer is a factory for Credentials with extended data model. type CustomCredentialProducer interface { // Accept checks if producer is capable of building extended Credential data model. @@ -656,7 +771,9 @@ func loadCredentialSchemas(vcBytes []byte) ([]TypedID, error) { } func parseCredentialOpts(opts []CredentialOpt) *credentialOpts { - crOpts := &credentialOpts{} + crOpts := &credentialOpts{ + modelValidationMode: jsonldValidation, + } for _, opt := range opts { opt(crOpts) diff --git a/pkg/doc/verifiable/credential_test.go b/pkg/doc/verifiable/credential_test.go index 109abb654..0fc91381e 100644 --- a/pkg/doc/verifiable/credential_test.go +++ b/pkg/doc/verifiable/credential_test.go @@ -646,6 +646,50 @@ func TestWithCredentialSchemaLoader(t *testing.T) { require.Nil(t, opts.schemaLoader.cache) } +func TestWithAnyContextAndType(t *testing.T) { + credentialOpt := WithJSONLDValidation() + require.NotNil(t, credentialOpt) + + opts := &credentialOpts{} + credentialOpt(opts) + require.Equal(t, jsonldValidation, opts.modelValidationMode) + require.Empty(t, opts.allowedCustomContexts) + require.Empty(t, opts.allowedCustomTypes) +} + +func TestWithBaseOnlyContextAndType(t *testing.T) { + credentialOpt := WithBaseContextValidation() + require.NotNil(t, credentialOpt) + + opts := &credentialOpts{} + credentialOpt(opts) + require.Equal(t, baseContextValidation, opts.modelValidationMode) + require.Empty(t, opts.allowedCustomContexts) + require.Empty(t, opts.allowedCustomTypes) +} + +func TestWithCustomContextAndType(t *testing.T) { + credentialOpt := WithBaseContextExtendedValidation( + []string{"https://www.w3.org/2018/credentials/examples/v1"}, + []string{"UniversityDegreeCredential", "AlumniCredential"}) + require.NotNil(t, credentialOpt) + + opts := &credentialOpts{} + credentialOpt(opts) + require.Equal(t, baseContextExtendedValidation, opts.modelValidationMode) + + require.Equal(t, map[string]bool{ + "https://www.w3.org/2018/credentials/v1": true, + "https://www.w3.org/2018/credentials/examples/v1": true}, + opts.allowedCustomContexts) + + require.Equal(t, map[string]bool{ + "VerifiableCredential": true, + "UniversityDegreeCredential": true, + "AlumniCredential": true}, + opts.allowedCustomTypes) +} + func TestCustomCredentialJsonSchemaValidator2018(t *testing.T) { testServer := httptest.NewServer(http.HandlerFunc(func(res http.ResponseWriter, req *http.Request) { rawMap := make(map[string]interface{}) @@ -1135,3 +1179,100 @@ func TestCredential_CreatePresentation(t *testing.T) { require.Equal(t, []string{"VerifiablePresentation"}, vp.Type) require.Equal(t, vc.Context, vp.Context) } + +func TestCredential_postValidateCredential(t *testing.T) { + r := require.New(t) + + t.Run("test AnyContextAndType constraint", func(t *testing.T) { + r.NoError(postValidateCredential(&Credential{}, + &credentialOpts{modelValidationMode: jsonldValidation})) + }) + + t.Run("test BaseOnlyContextAndType constraint", func(t *testing.T) { + r.NoError(postValidateCredential( + &Credential{ + Types: []string{"VerifiableCredential"}, + Context: []string{"https://www.w3.org/2018/credentials/v1"}}, + &credentialOpts{modelValidationMode: baseContextValidation})) + + err := postValidateCredential( + &Credential{ + Types: []string{"VerifiableCredential", "UniversityDegreeCredential"}, + Context: []string{"https://www.w3.org/2018/credentials/v1"}}, + &credentialOpts{modelValidationMode: baseContextValidation}) + r.Error(err) + r.EqualError(err, "violated type constraint: not base only type defined") + + err = postValidateCredential( + &Credential{ + Types: []string{"UniversityDegreeCredential"}, + Context: []string{"https://www.w3.org/2018/credentials/v1"}}, + &credentialOpts{modelValidationMode: baseContextValidation}) + r.Error(err) + r.EqualError(err, "violated type constraint: not base only type defined") + + err = postValidateCredential( + &Credential{ + Types: []string{"VerifiableCredential"}, + Context: []string{"https://www.w3.org/2018/credentials/v1", "https://www.exaple.org/udc/v1"}}, + &credentialOpts{modelValidationMode: baseContextValidation}) + r.Error(err) + r.EqualError(err, "violated @context constraint: not base only @context defined") + + err = postValidateCredential( + &Credential{ + Types: []string{"VerifiableCredential"}, + Context: []string{"https://www.exaple.org/udc/v1"}}, + &credentialOpts{modelValidationMode: baseContextValidation}) + r.Error(err) + r.EqualError(err, "violated @context constraint: not base only @context defined") + }) + + t.Run("test CustomContextAndType constraint", func(t *testing.T) { + r.NoError(postValidateCredential( + &Credential{ + Types: []string{"VerifiableCredential", "AlumniCredential"}, + Context: []string{"https://www.w3.org/2018/credentials/v1", "https://www.exaple.org/alumni/v1"}}, + &credentialOpts{ + modelValidationMode: baseContextExtendedValidation, + allowedCustomTypes: map[string]bool{ + "VerifiableCredential": true, + "AlumniCredential": true}, + allowedCustomContexts: map[string]bool{ + "https://www.w3.org/2018/credentials/v1": true, + "https://www.exaple.org/alumni/v1": true}, + })) + + err := postValidateCredential( + &Credential{ + Types: []string{"VerifiableCredential", "UniversityDegreeCredential"}, + Context: []string{"https://www.w3.org/2018/credentials/v1", "https://www.exaple.org/alumni/v1"}}, + &credentialOpts{ + modelValidationMode: baseContextExtendedValidation, + allowedCustomTypes: map[string]bool{ + "VerifiableCredential": true, + "AlumniCredential": true}, + allowedCustomContexts: map[string]bool{ + "https://www.w3.org/2018/credentials/v1": true, + "https://www.exaple.org/alumni/v1": true}, + }) + r.Error(err) + r.EqualError(err, "not allowed type: UniversityDegreeCredential") + + err = postValidateCredential( + &Credential{ + Types: []string{"VerifiableCredential", "AlumniCredential"}, + Context: []string{"https://www.w3.org/2018/credentials/v1", "https://www.exaple.org/udc/v1"}}, + &credentialOpts{ + modelValidationMode: baseContextExtendedValidation, + allowedCustomTypes: map[string]bool{ + "VerifiableCredential": true, + "AlumniCredential": true}, + allowedCustomContexts: map[string]bool{ + "https://www.w3.org/2018/credentials/v1": true, + "https://www.exaple.org/alumni/v1": true}, + }) + r.Error(err) + r.EqualError(err, "not allowed @context: https://www.exaple.org/udc/v1") + }) +} diff --git a/pkg/doc/verifiable/test-suite/verifiable_suite_test.go b/pkg/doc/verifiable/test-suite/verifiable_suite_test.go index 9d44ae18c..b56c7e7dd 100644 --- a/pkg/doc/verifiable/test-suite/verifiable_suite_test.go +++ b/pkg/doc/verifiable/test-suite/verifiable_suite_test.go @@ -17,6 +17,7 @@ import ( "fmt" "io/ioutil" "os" + "path/filepath" "github.com/square/go-jose/v3" @@ -46,7 +47,7 @@ func main() { if *isPresentation { encodeVPToJSON(vcBytes) } else { - encodeVCToJSON(vcBytes) + encodeVCToJSON(vcBytes, filepath.Base(inputFile)) } return @@ -187,8 +188,16 @@ func parseKeys(packedKeys string) (private, public interface{}) { return privateKey, publicKey } -func encodeVCToJSON(vcBytes []byte) { - credential, _, err := verifiable.NewCredential(vcBytes, verifiable.WithNoCustomSchemaCheck()) +func encodeVCToJSON(vcBytes []byte, testFileName string) { + vcOpts := []verifiable.CredentialOpt{verifiable.WithNoCustomSchemaCheck()} + + // This are special test cases which should be made more precise in VC Test Suite. + // See https://github.com/w3c/vc-test-suite/issues/96 for more information. + if testFileName == "example-1-bad-cardinality.jsonld" || testFileName == "example-3-bad-cardinality.jsonld" { + vcOpts = append(vcOpts, verifiable.WithBaseContextValidation()) + } + + credential, _, err := verifiable.NewCredential(vcBytes, vcOpts...) if err != nil { abort("failed to decode credential: %v", err) } diff --git a/scripts/run_vc_test_suite.sh b/scripts/run_vc_test_suite.sh index 7ce351eb3..bdd48a782 100755 --- a/scripts/run_vc_test_suite.sh +++ b/scripts/run_vc_test_suite.sh @@ -54,3 +54,12 @@ cat ${BUILD_DIR}/${VC_TEST_SUITE}/summary.json echo "See full test suite results at ${SUITE_DIR}/implementations/${REPORT_NAME}-report.json" cd $DIR + +echo +if grep -q "\"failures\": 0" ${BUILD_DIR}/${VC_TEST_SUITE}/summary.json; then + echo "Verifiable Credential test suite passed!" + exit 0 +else + echo "Verifiable Credential test suite did not pass" + exit 1 +fi