Skip to content

Commit

Permalink
add validator for x509 credential attributes
Browse files Browse the repository at this point in the history
  • Loading branch information
woutslakhorst committed Dec 18, 2024
1 parent 187d5fa commit da23114
Show file tree
Hide file tree
Showing 5 changed files with 154 additions and 0 deletions.
1 change: 1 addition & 0 deletions vcr/credential/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ func FindValidator(credential vc.VerifiableCredential) Validator {
return nutsOrganizationCredentialValidator{}
case NutsAuthorizationCredentialType:
return nutsAuthorizationCredentialValidator{}
case X509CredentialType: return x509CredentialValidator{}
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions vcr/credential/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ const (
NutsOrganizationCredentialType = "NutsOrganizationCredential"
// NutsAuthorizationCredentialType is the VC type for a NutsAuthorizationCredential
NutsAuthorizationCredentialType = "NutsAuthorizationCredential"
// X509CredentialType is the VC type for a X509Credential
X509CredentialType = "X509Credential"
// NutsV1Context is the nuts V1 json-ld context
NutsV1Context = "https://nuts.nl/credentials/v1"
)
Expand Down
63 changes: 63 additions & 0 deletions vcr/credential/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import (
"encoding/json"
"errors"
"fmt"
"github.com/nuts-foundation/nuts-node/crypto"
"github.com/nuts-foundation/nuts-node/vdr/didx509"
"strings"

"github.com/nuts-foundation/go-did/did"
Expand Down Expand Up @@ -253,3 +255,64 @@ func validateNutsCredentialID(credential vc.VerifiableCredential) error {
}
return nil
}

// x509CredentialValidator checks the did:x509 issuer and if the credentialSubject claims match the x509 certificate
type x509CredentialValidator struct {
}

func (d x509CredentialValidator) Validate(credential vc.VerifiableCredential) error {
didX509Issuer, err := did.ParseDID(credential.Issuer.String())
if err != nil {
return errors.Join(errValidation, err)
}
x509resolver := didx509.NewResolver()
resolveMetadata := resolver.ResolveMetadata{}
if credential.Format() == vc.JWTCredentialProofFormat {
headers, err := crypto.ExtractProtectedHeaders(credential.Raw())
if err != nil {
return fmt.Errorf("%w: invalid JWT headers: %w", errValidation, err)
}
resolveMetadata.JwtProtectedHeaders = headers
}
_, _, err = x509resolver.Resolve(*didX509Issuer, &resolveMetadata)
if err != nil {
return fmt.Errorf("%w: invalid issuer: %w", errValidation, err)
}

if err = validatePolicyAssertions(credential); err != nil {
return fmt.Errorf("%w: %w", errValidation, err)
}

return (defaultCredentialValidator{}).Validate(credential)
}

// 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")
}
credentialSubject := target[0]

// 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)
}
}

return nil
}
37 changes: 37 additions & 0 deletions vcr/credential/validator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@
package credential

import (
"github.com/lestrrat-go/jwx/v2/jwt"
ssi "github.com/nuts-foundation/go-did"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/go-did/vc"
"github.com/nuts-foundation/nuts-node/jsonld"
"github.com/nuts-foundation/nuts-node/vcr/revocation"
Expand Down Expand Up @@ -493,3 +495,38 @@ func Test_validateCredentialStatus(t *testing.T) {
})
})
}

func TestX509CredentialValidator_Validate(t *testing.T) {
validator := x509CredentialValidator{}

t.Run("ok", func(t *testing.T) {
x509credential := test.ValidX509Credential(t)

err := validator.Validate(x509credential)

assert.NoError(t, err)
})
t.Run("invalid did", func(t *testing.T) {
x509credential := vc.VerifiableCredential{Issuer: ssi.MustParseURI("not_a_did")}

err := validator.Validate(x509credential)

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{}{
"san:otherName": "A_BIG_STRIN",
},
})
return builder
})

err := validator.Validate(x509credential)

assert.ErrorIs(t, err, errValidation)
assert.ErrorContains(t, err, "assertion 'san:otherName:A_BIG_STRIN:' not found in issuer policy")
})
}
51 changes: 51 additions & 0 deletions vcr/test/credentials.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,16 @@ import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"github.com/lestrrat-go/jwx/v2/cert"
"github.com/lestrrat-go/jwx/v2/jwa"
"github.com/lestrrat-go/jwx/v2/jws"
"github.com/lestrrat-go/jwx/v2/jwt"
"github.com/nuts-foundation/go-did/did"
"github.com/nuts-foundation/nuts-node/test/pki"
"github.com/nuts-foundation/nuts-node/vcr/assets"
"github.com/stretchr/testify/require"
"testing"
Expand Down Expand Up @@ -126,3 +132,48 @@ func ValidStatusList2021Credential(t testing.TB) vc.VerifiableCredential {
Proof: []any{vc.Proof{}},
}
}

type credentialOption func(*jwt.Builder) *jwt.Builder

func ValidX509Credential(t *testing.T, options ...credentialOption) vc.VerifiableCredential {
otherNameValue := "A_BIG_STRING"
certs, keys, err := pki.BuildCertChain([]string{otherNameValue}, "123")
require.NoError(t, err)
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))
x5c := cert.Chain{}
for _, cert := range certs {
_ = x5c.AddString(base64.StdEncoding.EncodeToString(cert.Raw))
}

x5t := sha256.Sum256(certs[0].Raw)
headers := jws.NewHeaders()
_ = headers.Set(jws.X509CertChainKey, &x5c)
err = headers.Set(jws.X509CertThumbprintS256Key, base64.RawURLEncoding.EncodeToString(x5t[:]))
require.NoError(t, err)
builder := jwt.NewBuilder().
JwtID(fmt.Sprintf("%s#1", rootDID)).
Issuer(rootDID.String()).
NotBefore(time.Now()).
Subject("did:example:1").
Claim("vc", map[string]interface{}{
"@context": []string{"https://www.w3.org/2018/credentials/v1"},
"type": []string{"VerifiableCredential"},
"credentialSubject": map[string]interface{}{
"id": rootDID.String(),
"san:otherName": otherNameValue,
},
})
for _, option := range options {
builder = option(builder)
}
token, err := builder.Build()
require.NoError(t, err)
s, err := jwt.Sign(token, jwt.WithKey(jwa.RS256, rootKey, jws.WithProtectedHeaders(headers)))
require.NoError(t, err)
credential, err := vc.ParseVerifiableCredential(string(s))
require.NoError(t, err)
return *credential
}

0 comments on commit da23114

Please sign in to comment.