diff --git a/vcr/credential/resolver_test.go b/vcr/credential/resolver_test.go index 86627292c..e13d96f2c 100644 --- a/vcr/credential/resolver_test.go +++ b/vcr/credential/resolver_test.go @@ -42,13 +42,17 @@ func TestFindValidator(t *testing.T) { assert.IsType(t, defaultCredentialValidator{}, FindValidator(vc.VerifiableCredential{})) }) - t.Run("validator and builder found for NutsOrganizationCredential", func(t *testing.T) { + t.Run("validator found for NutsOrganizationCredential", func(t *testing.T) { assert.IsType(t, nutsOrganizationCredentialValidator{}, FindValidator(test.ValidNutsOrganizationCredential(t))) }) - t.Run("validator and builder found for NutsAuthorizationCredential", func(t *testing.T) { + t.Run("validator found for NutsAuthorizationCredential", func(t *testing.T) { assert.IsType(t, nutsAuthorizationCredentialValidator{}, FindValidator(test.ValidNutsAuthorizationCredential(t))) }) + + t.Run("validator found for X509Credential", func(t *testing.T) { + assert.IsType(t, x509CredentialValidator{}, FindValidator(test.ValidX509Credential(t))) + }) } func TestExtractTypes(t *testing.T) { diff --git a/vcr/credential/validator.go b/vcr/credential/validator.go index 997718432..3e8b3d0f9 100644 --- a/vcr/credential/validator.go +++ b/vcr/credential/validator.go @@ -25,6 +25,7 @@ import ( "fmt" "github.com/nuts-foundation/nuts-node/crypto" "github.com/nuts-foundation/nuts-node/vdr/didx509" + "net/url" "strings" "github.com/nuts-foundation/go-did/did" @@ -288,29 +289,54 @@ func (d x509CredentialValidator) Validate(credential vc.VerifiableCredential) er // validatePolicyAssertions checks if the credentialSubject claims match the did issuer policies func validatePolicyAssertions(credential vc.VerifiableCredential) error { - // add a : to the end of the string so we can always add an end character for string matching - // this eliminates the possibility to use substrings as assertions. - policyString := credential.Issuer.String() + ":" - // get base form of all credentialSubject var target = make([]map[string]interface{}, 0) if err := credential.UnmarshalCredentialSubject(&target); err != nil { return err } - if len(target) != 1 { - return errors.New("single CredentialSubject expected") + + // we create a map of policyName to policyValue, then we split the policyValue into another map + policyMap := make(map[string]map[string]string) + policies := strings.Split(credential.Issuer.String(), "::") + if len(policies) < 2 { + return fmt.Errorf("invalid did:x509 policy") + } + for _, policy := range policies[1:] { + policySplit := strings.Split(policy, ":") + if len(policySplit) < 2 { + return fmt.Errorf("invalid did:x509 policy '%s'", policy) + } + policyName := policySplit[0] + policyMap[policyName] = make(map[string]string) + for i := 1; i < len(policySplit); i += 2 { + unscaped, _ := url.PathUnescape(policySplit[i+1]) + policyMap[policyName][policySplit[i]] = unscaped + } } - credentialSubject := target[0] - // remove id from target - delete(credentialSubject, "id") + // we usually don't use multiple credentialSubjects, but for this validation it doesn't matter + for _, credentialSubject := range target { + // remove id from target + delete(credentialSubject, "id") - // for each assertion create a string as "%s:%s" with key/value - // check if the resulting string is present in the policyString - for key, value := range credentialSubject { - assertionString := fmt.Sprintf("%s:%s:", key, value) - if !strings.Contains(policyString, assertionString) { - return fmt.Errorf("assertion '%s' not found in issuer policy", assertionString) + // for each assertion create a string as "%s:%s" with key/value + // check if the resulting string is present in the policyString + for key, value := range credentialSubject { + split := strings.Split(key, ":") + if len(split) != 2 { + return fmt.Errorf("invalid credentialSubject assertion name '%s'", key) + } + policyValueMap, ok := policyMap[split[0]] + if !ok { + return fmt.Errorf("policy '%s' not found in did:x509 policy", split[0]) + } + policyValue, ok := policyValueMap[split[1]] + if !ok { + return fmt.Errorf("assertion '%s' not found in did:x509 policy", key) + } + if value != policyValue { + return fmt.Errorf("invalid assertion value '%s' for '%s' did:x509 policy", value, key) + } } } diff --git a/vcr/credential/validator_test.go b/vcr/credential/validator_test.go index 7690e714c..cbc498dff 100644 --- a/vcr/credential/validator_test.go +++ b/vcr/credential/validator_test.go @@ -20,6 +20,9 @@ package credential import ( + "testing" + "time" + "github.com/lestrrat-go/jwx/v2/jwt" ssi "github.com/nuts-foundation/go-did" "github.com/nuts-foundation/go-did/did" @@ -30,8 +33,6 @@ import ( "github.com/nuts-foundation/nuts-node/vdr" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" - "testing" - "time" ) func init() { @@ -514,19 +515,51 @@ func TestX509CredentialValidator_Validate(t *testing.T) { assert.ErrorIs(t, err, errValidation) assert.ErrorIs(t, err, did.ErrInvalidDID) }) - t.Run("invalid assertion value", func(t *testing.T) { - x509credential := test.ValidX509Credential(t, func(builder *jwt.Builder) *jwt.Builder { - builder.Claim("vc", map[string]interface{}{ - "credentialSubject": map[string]interface{}{ + + t.Run("failed validation", func(t *testing.T) { + + testCases := []struct { + name string + claim map[string]interface{} + expectedError string + }{ + { + name: "invalid assertion value", + claim: map[string]interface{}{ "san:otherName": "A_BIG_STRIN", }, - }) - return builder - }) + expectedError: "invalid assertion value 'A_BIG_STRIN' for 'san:otherName' did:x509 policy", + }, + { + name: "unknown assertion", + claim: map[string]interface{}{ + "san:ip": "10.0.0.1", + }, + expectedError: "assertion 'san:ip' not found in did:x509 policy", + }, + { + name: "unknown policy", + claim: map[string]interface{}{ + "stan:ip": "10.0.0.1", + }, + expectedError: "policy 'stan' not found in did:x509 policy", + }, + } - err := validator.Validate(x509credential) + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + x509credential := test.ValidX509Credential(t, func(builder *jwt.Builder) *jwt.Builder { + builder.Claim("vc", map[string]interface{}{ + "credentialSubject": tc.claim, + }) + return builder + }) - assert.ErrorIs(t, err, errValidation) - assert.ErrorContains(t, err, "assertion 'san:otherName:A_BIG_STRIN:' not found in issuer policy") + err := validator.Validate(x509credential) + + assert.ErrorIs(t, err, errValidation) + assert.ErrorContains(t, err, tc.expectedError) + }) + } }) } diff --git a/vcr/test/credentials.go b/vcr/test/credentials.go index 7c39d2f6f..149f9c3b3 100644 --- a/vcr/test/credentials.go +++ b/vcr/test/credentials.go @@ -142,7 +142,7 @@ func ValidX509Credential(t *testing.T, options ...credentialOption) vc.Verifiabl rootCertificate := certs[len(certs)-1] rootKey := keys[len(keys)-1] rootHash := sha256.Sum256(rootCertificate.Raw) - rootDID := did.MustParseDID(fmt.Sprintf("did:x509:0:%s:%s::san:otherName:%s", "sha256", base64.RawURLEncoding.EncodeToString(rootHash[:]), otherNameValue)) + rootDID := did.MustParseDID(fmt.Sprintf("did:x509:0:%s:%s::subject:C:NL:O:NUTS%%20Foundation:L:Amsterdam:CN:www.example.com::san:otherName:%s", "sha256", base64.RawURLEncoding.EncodeToString(rootHash[:]), otherNameValue)) x5c := cert.Chain{} for _, cert := range certs { _ = x5c.AddString(base64.StdEncoding.EncodeToString(cert.Raw)) @@ -160,9 +160,13 @@ func ValidX509Credential(t *testing.T, options ...credentialOption) vc.Verifiabl Subject("did:example:1"). Claim("vc", map[string]interface{}{ "@context": []string{"https://www.w3.org/2018/credentials/v1"}, - "type": []string{"VerifiableCredential"}, + "type": []string{vc.VerifiableCredentialType, "X509Credential"}, "credentialSubject": map[string]interface{}{ "id": rootDID.String(), + "subject:C": "NL", + "subject:O": "NUTS Foundation", + "subject:L": "Amsterdam", + "subject:CN": "www.example.com", "san:otherName": otherNameValue, }, })