nuts-foundation/nuts-node

View on GitHub
vdr/didnuts/validators.go

Summary

Maintainability
A
0 mins
Test Coverage
B
86%
/*
 * 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 didnuts

import (
    "encoding/json"
    "errors"
    "fmt"
    "github.com/lestrrat-go/jwx/v2/jwk"
    ssi "github.com/nuts-foundation/go-did"
    "github.com/nuts-foundation/go-did/did"
    "github.com/nuts-foundation/nuts-node/network/transport"
    "github.com/nuts-foundation/nuts-node/vdr/resolver"
)

// NetworkDocumentValidator creates a DID Document validator that checks for inconsistencies in the DID Document:
// - validate it according to the W3C DID Core Data Model specification
// - validate it according to the Nuts DID Method specification:
//   - it checks validationMethods for the following conditions:
//   - every validationMethod id must have a fragment
//   - every validationMethod id should have the DID prefix
//   - every validationMethod id must be unique
//   - it checks services for the following conditions:
//   - every service id must have a fragment
//   - every service id should have the DID prefix
//   - every service id must be unique
//   - every service type must be unique
func NetworkDocumentValidator() did.Validator {
    return &did.MultiValidator{Validators: []did.Validator{
        did.W3CSpecValidator{},
        verificationMethodValidator{},
        basicServiceValidator{},
    }}
}

// ManagedDocumentValidator extends NetworkDocumentValidator with extra safety checks to be performed on DID documents managed by this node before they are published on the network.
func ManagedDocumentValidator(serviceResolver resolver.ServiceResolver) did.Validator {
    return &did.MultiValidator{Validators: []did.Validator{
        NetworkDocumentValidator(),
        managedServiceValidator{serviceResolver},
    }}
}

// verificationMethodValidator validates the Verification Methods of a Nuts DID Document.
type verificationMethodValidator struct{}

func (v verificationMethodValidator) Validate(document did.Document) error {
    knownKeyIds := make(map[string]bool, 0)
    for _, method := range document.VerificationMethod {
        if err := verifyDocumentEntryID(document.ID, method.ID.URI(), knownKeyIds); err != nil {
            return fmt.Errorf("invalid verificationMethod: %w", err)
        }
        if err := v.verifyThumbprint(method); err != nil {
            return fmt.Errorf("invalid verificationMethod: %w", err)
        }
    }
    return nil
}

func (v verificationMethodValidator) verifyThumbprint(method *did.VerificationMethod) error {
    keyAsJWK, err := method.JWK()
    if err != nil {
        return fmt.Errorf("unable to get JWK: %w", err)
    }
    _ = jwk.AssignKeyID(keyAsJWK)
    if keyAsJWK.KeyID() != method.ID.Fragment {
        return errors.New("key thumbprint does not match ID")
    }
    return nil
}

type InvalidServiceError struct {
    Cause error
}

func (e InvalidServiceError) Error() string {
    return "invalid service: " + e.Cause.Error()
}

func (e InvalidServiceError) Unwrap() error {
    return e.Cause
}

// basicServiceValidator validates service.ID and service.Type of the Services of a DID Document.
// To be used on DID documents received through the network.
type basicServiceValidator struct{}

func (b basicServiceValidator) Validate(document did.Document) error {
    knownServiceIDs := make(map[string]bool, 0)
    knownServiceTypes := make(map[string]bool, 0)
    for _, service := range document.Service {
        // service.id
        if err := verifyDocumentEntryID(document.ID, service.ID, knownServiceIDs); err != nil {
            return InvalidServiceError{err}
        }

        // service.type
        if knownServiceTypes[service.Type] {
            // RFC006 §4: A DID Document MAY NOT contain more than one service with the same type.
            return InvalidServiceError{resolver.ErrDuplicateService}
        }
        knownServiceTypes[service.Type] = true
    }
    return nil
}

// managedServiceValidator only validates the Service.ServiceEndpoint in a DID document.
// Correctness of a service endpoint is the responsibility of the controller. Services on DID documents received through the network should therefor not be validated.
// This validator is exists to guarantee that the service endpoints are at least valid at time of publication.
// Should be used together with basicServiceValidator for full service validation.
type managedServiceValidator struct {
    serviceResolver resolver.ServiceResolver
}

func (m managedServiceValidator) Validate(document did.Document) error {
    // normalize services for consistent type checking.
    // TODO: this should probably happen somewhere else
    bytes, err := json.Marshal(document)
    if err != nil {
        return InvalidServiceError{err}
    }
    if err = document.UnmarshalJSON(bytes); err != nil {
        return InvalidServiceError{err}
    }

    // make sure that it resolves when if it's a reference
    var resolvedEndpoint any
    // Cache resolved DID documents because most of the time all (compound) services will refer to the same DID document in all service references.
    cache := make(map[string]*did.Document, 1)
    cache[document.ID.String()] = &document // add the updated document to the cache in case it contains self-references
    for _, service := range document.Service {
        switch se := service.ServiceEndpoint.(type) {
        case map[string]interface{}:
            knownKeys := make(map[string]bool, len(se))
            resolvedCompoundEndpoint := make(map[string]any, len(se)) // don't know if returned type is string or another map
            for name, endpoint := range se {
                if _, exists := knownKeys[name]; exists {
                    err = errors.New("duplicate service key in compound service")
                    break
                }
                knownKeys[name] = true
                // resolve by treating endpoints as individual services
                if resolvedEndpoint, err = m.resolveOrReturnEndpoint(did.Service{ServiceEndpoint: endpoint}, cache); err != nil {
                    break
                }
                resolvedCompoundEndpoint[name] = resolvedEndpoint
            }
            resolvedEndpoint = resolvedCompoundEndpoint
        case []interface{}:
            // RFC006 only allows maps or string, not sets.
            // Since service is not a map, and go-did normalizes everything to plurals, assume this is a string.
            resolvedEndpoint, err = m.resolveOrReturnEndpoint(service, cache)
        case string:
            resolvedEndpoint, err = m.resolveOrReturnEndpoint(service, cache)
        default:
            err = errors.New("invalid service format")
        }
        if err != nil {
            return InvalidServiceError{err}
        }

        // specific service.Type need additional validation
        resolvedService := did.Service{
            ID:              service.ID,
            Type:            service.Type,
            ServiceEndpoint: resolvedEndpoint,
        }
        if err = serviceTypeValidation(resolvedService); err != nil {
            return InvalidServiceError{fmt.Errorf("%s: %w", service.Type, err)}
        }
    }
    return nil
}

func (m managedServiceValidator) resolveOrReturnEndpoint(service did.Service, cache map[string]*did.Document) (any, error) {
    var serviceEndpoint string
    if err := service.UnmarshalServiceEndpoint(&serviceEndpoint); err != nil {
        return nil, errors.New("invalid service format")
    }
    // make sure that it resolves if it is a reference
    if resolver.IsServiceReference(serviceEndpoint) {
        serviceURI, err := ssi.ParseURI(serviceEndpoint)
        if err != nil {
            return nil, err
        }
        if err = resolver.ValidateServiceReference(*serviceURI); err != nil {
            return nil, err
        }
        resolvedService, err := m.serviceResolver.ResolveEx(*serviceURI, 0, resolver.DefaultMaxServiceReferenceDepth, cache)
        if err != nil {
            return nil, err
        }
        return resolvedService.ServiceEndpoint, nil
    }
    return serviceEndpoint, nil
}

func serviceTypeValidation(service did.Service) error {
    switch service.Type {
    case "NutsComm":
        return validateNutsCommEndpoint(service)
    case "node-contact-info":
        return validateNodeContactInfo(service)
    default:
        // no extra type-based validation
        return nil
    }
}

func validateNutsCommEndpoint(service did.Service) error {
    // RFC015 $3.2: NutsComm rules
    var ncEndpoint transport.NutsCommURL
    if err := service.UnmarshalServiceEndpoint(&ncEndpoint); err != nil {
        return err
    }
    return nil
}

func validateNodeContactInfo(service did.Service) error {
    // RFC006 §4.2 Contact information
    var endpointMap map[string]string
    if err := service.UnmarshalServiceEndpoint(&endpointMap); err != nil {
        return errors.New("not a map")
    }
    if _, ok := endpointMap["email"]; !ok {
        return errors.New("missing email")
    }
    return nil
}

func verifyDocumentEntryID(owner did.DID, entryID ssi.URI, knownIDs map[string]bool) error {
    // Check the ID has a fragment
    if entryID.Fragment == "" {
        return errors.New("ID must have a fragment")
    }
    // Check if this ID was part of a previous entry
    entryIDStr := entryID.String()
    if knownIDs[entryIDStr] {
        return errors.New("ID must be unique")
    }
    entryID.Fragment = ""
    if owner.String() != entryID.String() {
        return errors.New("ID must have document prefix")
    }
    knownIDs[entryIDStr] = true
    return nil
}