nuts-foundation/nuts-node

View on GitHub
vdr/resolver/service.go

Summary

Maintainability
A
0 mins
Test Coverage
A
91%
/*
 * Copyright (C) 2023 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 resolver

import (
    "errors"
    "fmt"
    "github.com/nuts-foundation/go-did"
    "github.com/nuts-foundation/go-did/did"
    "strings"
)

// ErrDuplicateService is returned when a DID Document contains a multiple services with the same type
var ErrDuplicateService = errors.New("service type is duplicate")

// ErrServiceNotFound is returned when the service is not found on a DID
var ErrServiceNotFound = errors.New("service not found in DID Document")

// ErrServiceReferenceToDeep is returned when a service reference is chain is nested too deeply.
var ErrServiceReferenceToDeep = errors.New("service references are nested to deeply before resolving to a non-reference")

// ServiceResolver allows looking up DID document services, following references.
type ServiceResolver interface {
    // Resolve looks up the DID document of the specified query and then tries to find the service with the specified type.
    // The query must be in the form of a service query, e.g. `did:nuts:12345/serviceEndpoint?type=some-type`.
    // The maxDepth indicates how deep references are followed. If maxDepth = 0, no references are followed (and an error is returned if the given query resolves to a reference).
    // If the DID document or service is not found, a reference can't be resolved or the references exceed maxDepth, an error is returned.
    Resolve(query ssi.URI, maxDepth int) (did.Service, error)

    // ResolveEx tries to resolve a DID service from the given endpoint URI, following references (URIs that begin with 'did:').
    // When the endpoint is a reference it resolves it up until the (per spec) max reference depth. When resolving a reference it recursively calls itself with depth + 1.
    // The documentCache map is used to avoid resolving the same document over and over again, which might be a (slightly more) expensive operation.
    ResolveEx(endpoint ssi.URI, depth int, maxDepth int, documentCache map[string]*did.Document) (did.Service, error)
}

// DefaultMaxServiceReferenceDepth holds the default max. allowed depth for DID service references.
const DefaultMaxServiceReferenceDepth = 5

// DIDServiceResolver is a wrapper around a DID store that allows resolving services, following references.
type DIDServiceResolver struct {
    Resolver DIDResolver
}

func (s DIDServiceResolver) Resolve(query ssi.URI, maxDepth int) (did.Service, error) {
    return s.ResolveEx(query, 0, maxDepth, map[string]*did.Document{})
}

func (s DIDServiceResolver) ResolveEx(endpoint ssi.URI, depth int, maxDepth int, documentCache map[string]*did.Document) (did.Service, error) {
    if depth >= maxDepth {
        return did.Service{}, ErrServiceReferenceToDeep
    }

    referencedDID, err := GetDIDFromURL(endpoint.String())
    if err != nil {
        // Shouldn't happen, because only DID URLs are passed?
        return did.Service{}, err
    }
    var document *did.Document
    if document = documentCache[referencedDID.String()]; document == nil {
        document, _, err = s.Resolver.Resolve(referencedDID, nil)
        if err != nil {
            return did.Service{}, err
        }
        documentCache[referencedDID.String()] = document
    }

    var service *did.Service
    for _, curr := range document.Service {
        if curr.Type == endpoint.Query().Get(serviceTypeQueryParameter) {
            // If there are multiple services with the same type the document is conflicted.
            // This can happen temporarily during a service update (delete old, add new).
            // Both endpoints are likely to be active in the timeframe that the conflict exists, so picking the first entry is preferred for availability over an error.
            service = &curr
            break
        }
    }
    if service == nil {
        return did.Service{}, ErrServiceNotFound
    }

    var endpointURL string
    if service.UnmarshalServiceEndpoint(&endpointURL) == nil {
        // Service endpoint is a string, if it's a reference we need to resolve it
        if IsServiceReference(endpointURL) {
            // Looks like a reference, recurse
            resolvedEndpointURI, err := ssi.ParseURI(endpointURL)
            if err != nil {
                return did.Service{}, err
            }
            err = ValidateServiceReference(*resolvedEndpointURI)
            if err != nil {
                return did.Service{}, err
            }
            return s.ResolveEx(*resolvedEndpointURI, depth+1, maxDepth, documentCache)
        }
    }
    return *service, nil
}

const serviceTypeQueryParameter = "type"
const serviceEndpointPath = "/serviceEndpoint"

// MakeServiceReference creates a service reference, which can be used as query when looking up services.
func MakeServiceReference(subjectDID did.DID, serviceType string) ssi.URI {
    ref := subjectDID.URI()
    ref.Opaque += serviceEndpointPath
    ref.Fragment = ""
    ref.RawQuery = fmt.Sprintf("%s=%s", serviceTypeQueryParameter, serviceType)
    return ref
}

// IsServiceReference checks whether the given endpoint string looks like a service reference (e.g. did:nuts:1234/serviceType?type=HelloWorld).
func IsServiceReference(endpoint string) bool {
    return strings.HasPrefix(endpoint, "did:")
}

// ServiceQueryError denies the query based on validation constraints.
type ServiceQueryError struct {
    Err error // cause
}

// Error implements the error interface.
func (e ServiceQueryError) Error() string {
    return "DID service query invalid: " + e.Err.Error()
}

// Unwrap implements the errors.Unwrap convention.
func (e ServiceQueryError) Unwrap() error { return e.Err }

// ValidateServiceReference checks whether the given URI matches the format for a service reference.
func ValidateServiceReference(endpointURI ssi.URI) error {
    // Parse it as DID URL since DID URLs are rootless and thus opaque (RFC 3986), meaning the path will be part of the URI body, rather than the URI path.
    // For DID URLs the path is parsed properly.
    didEndpointURL, err := did.ParseDIDURL(endpointURI.String())
    if err != nil {
        return ServiceQueryError{err}
    }

    if "/"+didEndpointURL.Path != serviceEndpointPath {
        return ServiceQueryError{errors.New("endpoint URI path must be " + serviceEndpointPath)}
    }

    q := endpointURI.Query()
    switch len(q[serviceTypeQueryParameter]) {
    case 1:
        break // good
    case 0:
        return ServiceQueryError{errors.New("endpoint URI without " + serviceTypeQueryParameter + " query parameter")}
    default:
        return ServiceQueryError{errors.New("endpoint URI with multiple " + serviceTypeQueryParameter + " query parameters")}
    }

    // “Other query parameters, paths or fragments SHALL NOT be used.”
    // — RFC006, subsection 4.2
    if len(q) > 1 {
        return ServiceQueryError{errors.New("endpoint URI with query parameter other than " + serviceTypeQueryParameter)}
    }

    return nil
}