nuts-foundation/nuts-node

View on GitHub
vcr/credential/validator.go

Summary

Maintainability
A
0 mins
Test Coverage
A
97%
/*
 * Nuts node
 * Copyright (C) 2021 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 <https://www.gnu.org/licenses/>.
 *
 */

package credential

import (
    "encoding/json"
    "errors"
    "fmt"
    "strings"

    "github.com/nuts-foundation/go-did/did"
    "github.com/nuts-foundation/go-did/vc"
    "github.com/nuts-foundation/nuts-node/vcr/revocation"
    "github.com/nuts-foundation/nuts-node/vdr/resolver"
)

// Validator is the interface specific VC verification.
// Every VC will have its own rules of verification.
type Validator interface {
    // Validate the given credential according to the rules of the VC type.
    Validate(credential vc.VerifiableCredential) error
}

// errValidation is a common error indicating validation failed
var errValidation = errors.New("validation failed")

type defaultCredentialValidator struct {
}

func (d defaultCredentialValidator) Validate(credential vc.VerifiableCredential) error {
    if !credential.IsType(vc.VerifiableCredentialTypeV1URI()) {
        return fmt.Errorf("%w: type 'VerifiableCredential' is required", errValidation)
    }

    if !credential.ContainsContext(vc.VCContextV1URI()) {
        return fmt.Errorf("%w: default context is required", errValidation)
    }

    if credential.Issuer.String() == "" {
        return fmt.Errorf("%w: 'issuer' is required", errValidation)
    }

    if credential.ID == nil {
        return fmt.Errorf("%w: 'ID' is required", errValidation)
    }

    if credential.IssuanceDate.IsZero() {
        return fmt.Errorf("%w: 'issuanceDate' is required", errValidation)
    }

    // CredentialStatus is not specific to the credential type and the syntax (not status) should be checked here.
    if err := validateCredentialStatus(credential); err != nil {
        return fmt.Errorf("%w: invalid credentialStatus: %w", errValidation, err)
    }

    return nil
}

// validateCredentialStatus validates the
func validateCredentialStatus(credential vc.VerifiableCredential) error {
    // exit if the credential does not contain a status
    if credential.CredentialStatus == nil {
        return nil
    }

    // get base form of all credentialStatus
    statuses, err := credential.CredentialStatuses()
    if err != nil {
        return err
    }

    // validate per CredentialStatus.Type
    for _, credentialStatus := range statuses {
        // base requirements
        if credentialStatus.ID.String() == "" {
            return errors.New("credentialStatus.id is required")
        }
        if credentialStatus.Type == "" {
            return errors.New("credentialStatus.type is required")
        }

        // only validate StatusList2021Entry for now
        switch credentialStatus.Type {
        case revocation.StatusList2021EntryType:
            // TODO: json.AllFieldsDefined validator should be sufficient?
            if !credential.ContainsContext(revocation.StatusList2021ContextURI) {
                return errors.New("StatusList2021 context is required")
            }

            var cs revocation.StatusList2021Entry
            if err = json.Unmarshal(credentialStatus.Raw(), &cs); err != nil {
                return err
            }
            if err = cs.Validate(); err != nil {
                return err
            }
        }
    }

    // valid
    return nil
}

// nutsOrganizationCredentialValidator checks if there's a 'name' and 'city' in the 'organization' struct
type nutsOrganizationCredentialValidator struct {
}

func (d nutsOrganizationCredentialValidator) Validate(credential vc.VerifiableCredential) error {
    var target = make([]NutsOrganizationCredentialSubject, 0)

    err := validateNutsCredentialID(credential)
    if err != nil {
        return err
    }

    if !credential.IsType(*NutsOrganizationCredentialTypeURI) {
        return fmt.Errorf("%w: type '%s' is required", errValidation, NutsOrganizationCredentialType)
    }

    if !credential.ContainsContext(NutsV1ContextURI) {
        return fmt.Errorf("%w: context '%s' is required", errValidation, NutsV1ContextURI.String())
    }

    // if it fails, length check will trigger
    _ = credential.UnmarshalCredentialSubject(&target)
    if len(target) != 1 {
        return fmt.Errorf("%w: single CredentialSubject expected", errValidation)
    }
    cs := target[0]

    if cs.Organization == nil {
        return fmt.Errorf("%w: 'credentialSubject.organization' is empty", errValidation)
    }
    if cs.ID == "" {
        return fmt.Errorf("%w: 'credentialSubject.ID' is nil", errValidation)
    }
    if _, err = did.ParseDID(cs.ID); err != nil {
        return fmt.Errorf("%w: invalid 'credentialSubject.id': %w", errValidation, err)
    }

    if n, ok := cs.Organization["name"]; !ok || len(strings.TrimSpace(n)) == 0 {
        return fmt.Errorf("%w: 'credentialSubject.name' is empty", errValidation)
    }

    if c, ok := cs.Organization["city"]; !ok || len(strings.TrimSpace(c)) == 0 {
        return fmt.Errorf("%w: 'credentialSubject.city' is empty", errValidation)
    }

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

// nutsAuthorizationCredentialValidator checks for mandatory fields: id, purposeOfUse.
// Also checks whether the specified resources
type nutsAuthorizationCredentialValidator struct {
}

func (d nutsAuthorizationCredentialValidator) Validate(credential vc.VerifiableCredential) error {
    var target = make([]NutsAuthorizationCredentialSubject, 0)

    err := validateNutsCredentialID(credential)
    if err != nil {
        return err
    }

    if !credential.IsType(*NutsAuthorizationCredentialTypeURI) {
        return fmt.Errorf("%w: type '%s' is required", errValidation, NutsAuthorizationCredentialType)
    }

    if !credential.ContainsContext(NutsV1ContextURI) {
        return fmt.Errorf("%w: context '%s' is required", errValidation, NutsV1ContextURI.String())
    }

    // if it fails, length check will trigger
    _ = credential.UnmarshalCredentialSubject(&target)
    if len(target) != 1 {
        return fmt.Errorf("%w: single CredentialSubject expected", errValidation)
    }
    cs := target[0]

    if len(strings.TrimSpace(cs.ID)) == 0 {
        return fmt.Errorf("%w: 'credentialSubject.ID' is nil", errValidation)
    }
    if _, err = did.ParseDID(cs.ID); err != nil {
        return fmt.Errorf("%w: invalid 'credentialSubject.id': %w", errValidation, err)
    }
    if len(strings.TrimSpace(cs.PurposeOfUse)) == 0 {
        return fmt.Errorf("%w: 'credentialSubject.PurposeOfUse' is nil", errValidation)
    }

    err = validateResources(cs.Resources)
    if err != nil {
        return err
    }

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

func validOperationTypes() []string {
    return []string{"read", "vread", "update", "patch", "delete", "history", "create", "search", "document"}
}

func validateResources(resources []Resource) error {
    for _, r := range resources {
        if len(strings.TrimSpace(r.Path)) == 0 {
            return fmt.Errorf("%w: 'credentialSubject.Resources[].Path' is required'", errValidation)
        }
        if len(r.Operations) == 0 {
            return fmt.Errorf("%w: 'credentialSubject.Resources[].Operations[]' requires at least one value", errValidation)
        }
        for _, o := range r.Operations {
            if !validOperation(o) {
                return fmt.Errorf("%w: 'credentialSubject.Resources[].Operations[]' contains an invalid operation '%s'", errValidation, o)
            }
        }
    }

    return nil
}

func validOperation(operation string) bool {
    for _, o := range validOperationTypes() {
        if o == strings.ToLower(operation) {
            return true
        }
    }
    return false
}

func validateNutsCredentialID(credential vc.VerifiableCredential) error {
    id, err := resolver.GetDIDFromURL(credential.ID.String())
    if err != nil {
        return err
    }
    if id.String() != credential.Issuer.String() {
        return fmt.Errorf("%w: credential ID must start with issuer", errValidation)
    }
    return nil
}