vcr/vcr.go
/*
* 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 vcr
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/nuts-foundation/go-leia/v4"
"github.com/nuts-foundation/nuts-node/http/client"
"github.com/nuts-foundation/nuts-node/pki"
"github.com/nuts-foundation/nuts-node/vcr/credential"
"github.com/nuts-foundation/nuts-node/vcr/openid4vci"
"github.com/nuts-foundation/nuts-node/vcr/revocation"
"github.com/nuts-foundation/nuts-node/vdr"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
"io/fs"
"net/http"
"path"
"strings"
"time"
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/core"
"github.com/nuts-foundation/nuts-node/crypto"
"github.com/nuts-foundation/nuts-node/events"
"github.com/nuts-foundation/nuts-node/jsonld"
"github.com/nuts-foundation/nuts-node/network"
"github.com/nuts-foundation/nuts-node/storage"
"github.com/nuts-foundation/nuts-node/vcr/assets"
"github.com/nuts-foundation/nuts-node/vcr/holder"
"github.com/nuts-foundation/nuts-node/vcr/issuer"
"github.com/nuts-foundation/nuts-node/vcr/log"
"github.com/nuts-foundation/nuts-node/vcr/trust"
"github.com/nuts-foundation/nuts-node/vcr/types"
"github.com/nuts-foundation/nuts-node/vcr/verifier"
"gopkg.in/yaml.v3"
)
const credentialsBackupShelf = "credentials"
// NewVCRInstance creates a new vcr instance with default config and empty concept registry
func NewVCRInstance(keyStore crypto.KeyStore, vdrInstance vdr.VDR,
network network.Transactions, jsonldManager jsonld.JSONLD, eventManager events.Event, storageClient storage.Engine,
pkiProvider pki.Provider) VCR {
r := &vcr{
config: DefaultConfig(),
vdrInstance: vdrInstance,
keyStore: keyStore,
network: network,
jsonldManager: jsonldManager,
eventManager: eventManager,
storageClient: storageClient,
pkiProvider: pkiProvider,
}
return r
}
type vcr struct {
// datadir holds the location the VCR files are stored
datadir string
// strictmode holds a copy of the core.ServerConfig.Strictmode value
strictmode bool
config Config
store storage.KVBackedLeiaStore
keyStore crypto.KeyStore
keyResolver resolver.KeyResolver
serviceResolver resolver.ServiceResolver
ambassador Ambassador
network network.Transactions
trustConfig *trust.Config
issuer issuer.Issuer
verifier verifier.Verifier
wallet holder.Wallet
issuerStore issuer.Store
verifierStore verifier.Store
jsonldManager jsonld.JSONLD
eventManager events.Event
storageClient storage.Engine
openidSessionStore storage.SessionDatabase
localWalletResolver openid4vci.IdentifierResolver
issuerHttpClient core.HTTPRequestDoer
walletHttpClient core.HTTPRequestDoer
pkiProvider pki.Provider
vdrInstance vdr.VDR
}
func (c *vcr) GetOpenIDIssuer(ctx context.Context, id did.DID) (issuer.OpenIDHandler, error) {
identifier, err := c.resolveOpenID4VCIIdentifier(ctx, id)
if err != nil {
return nil, err
}
return issuer.NewOpenIDHandler(id, identifier, c.config.OpenID4VCI.DefinitionsDIR, c.issuerHttpClient, c.keyResolver, c.openidSessionStore)
}
func (c *vcr) GetOpenIDHolder(ctx context.Context, id did.DID) (holder.OpenIDHandler, error) {
identifier, err := c.resolveOpenID4VCIIdentifier(ctx, id)
if err != nil {
return nil, err
}
return holder.NewOpenIDHandler(id, identifier, c.walletHttpClient, c, c.keyStore, c.keyResolver), nil
}
func (c *vcr) resolveOpenID4VCIIdentifier(ctx context.Context, id did.DID) (string, error) {
identifier, err := c.localWalletResolver.Resolve(id)
if err != nil {
return "", openid4vci.Error{
Err: fmt.Errorf("error resolving OpenID4VCI identifier: %w", err),
Code: openid4vci.InvalidRequest,
StatusCode: http.StatusNotFound,
}
}
isOwner, err := c.vdrInstance.IsOwner(ctx, id)
if err != nil {
return "", err
}
if !isOwner {
return "", openid4vci.Error{
Err: errors.New("DID is not owned by this node"),
Code: openid4vci.InvalidRequest,
StatusCode: http.StatusNotFound,
}
}
return identifier, nil
}
func (c *vcr) Issuer() issuer.Issuer {
return c.issuer
}
func (c *vcr) Wallet() holder.Wallet {
return c.wallet
}
func (c *vcr) Verifier() verifier.Verifier {
return c.verifier
}
func (c *vcr) Configure(config core.ServerConfig) error {
var err error
// store config parameters for use in Start()
c.datadir = config.Datadir
// copy strictmode for openid4vci usage
c.strictmode = config.Strictmode
// create issuer store (to revoke)
issuerStorePath := path.Join(c.datadir, "vcr", "issued-credentials.db")
issuerBackupStore, err := c.storageClient.GetProvider(ModuleName).GetKVStore("backup-issued-credentials", storage.PersistentStorageClass)
if err != nil {
return err
}
c.issuerStore, err = issuer.NewStore(c.storageClient.GetSQLDatabase(), issuerStorePath, issuerBackupStore)
if err != nil {
return err
}
// create verifier store (for revocations)
verifierStorePath := path.Join(c.datadir, "vcr", "verifier-store.db")
verifierBackupStore, err := c.storageClient.GetProvider(ModuleName).GetKVStore("backup-revoked-credentials", storage.PersistentStorageClass)
if err != nil {
return err
}
c.verifierStore, err = verifier.NewLeiaVerifierStore(verifierStorePath, verifierBackupStore)
if err != nil {
return err
}
// create credentials store (for public credentials)
if err = c.createCredentialsStore(); err != nil {
return err
}
// create trust config
tcPath := path.Join(config.Datadir, "vcr", "trusted_issuers.yaml")
c.trustConfig = trust.NewConfig(tcPath)
// default to nil openidHandlerFn when OpenID4VCI.Enabled==false
var openidHandlerFn func(ctx context.Context, id did.DID) (issuer.OpenIDHandler, error)
didResolver := c.vdrInstance.Resolver()
c.keyResolver = resolver.DIDKeyResolver{Resolver: didResolver}
c.serviceResolver = resolver.DIDServiceResolver{Resolver: didResolver}
networkPublisher := issuer.NewNetworkPublisher(c.network, didResolver, c.keyStore)
tlsConfig, err := c.pkiProvider.CreateTLSConfig(config.TLS) // returns nil if TLS is disabled
if err != nil {
return err
}
if c.config.OpenID4VCI.Enabled {
c.localWalletResolver = openid4vci.NewTLSIdentifierResolver(
openid4vci.DIDIdentifierResolver{ServiceResolver: c.serviceResolver},
tlsConfig,
)
openidHandlerFn = c.GetOpenIDIssuer
// Issuer and wallet don't share the same http.Client and underlying transport,
// since that leads to (temporary) deadlocks under high load, when the http.Transport pool is exhausted.
// This is because the credential is requested by the wallet synchronously during the offer handling,
// meaning while the issuer allocated an HTTP connection the wallet will try to allocate one as well.
// This moved back to 1 http.Client when the credential is requested asynchronously.
// Should be fixed as part of https://github.com/nuts-foundation/nuts-node/issues/2039 (also fix core.NewStrictHTTPClient)
c.issuerHttpClient = client.NewWithTLSConfig(c.config.OpenID4VCI.Timeout, tlsConfig)
c.walletHttpClient = client.NewWithTLSConfig(c.config.OpenID4VCI.Timeout, tlsConfig)
c.openidSessionStore = c.storageClient.GetSessionDatabase()
}
status := revocation.NewStatusList2021(c.storageClient.GetSQLDatabase(), client.NewWithCache(config.HTTPClient.Timeout))
c.issuer = issuer.NewIssuer(c.issuerStore, c, networkPublisher, openidHandlerFn, didResolver, c.keyStore, c.jsonldManager, c.trustConfig, status)
c.verifier = verifier.NewVerifier(c.verifierStore, didResolver, c.keyResolver, c.jsonldManager, c.trustConfig, status)
c.ambassador = NewAmbassador(c.network, c, c.verifier, c.eventManager)
// Create holder/wallet
c.wallet = holder.NewSQLWallet(c.keyResolver, c.keyStore, c.verifier, c.jsonldManager, c.storageClient)
if err = c.store.HandleRestore(); err != nil {
return err
}
return c.trustConfig.Load()
}
func (c *vcr) createCredentialsStore() error {
credentialsStorePath := path.Join(c.datadir, "vcr", "credentials.db")
credentialsBackupStore, err := c.storageClient.GetProvider(ModuleName).GetKVStore("backup-credentials", storage.PersistentStorageClass)
if err != nil {
return err
}
credentialsStore, err := leia.NewStore(credentialsStorePath, leia.WithDocumentLoader(c.jsonldManager.DocumentLoader()))
if err != nil {
return err
}
c.store, err = storage.NewKVBackedLeiaStore(credentialsStore, credentialsBackupStore)
if err != nil {
return err
}
// set backup config
c.store.AddConfiguration(storage.LeiaBackupConfiguration{
CollectionName: "credentials",
CollectionType: leia.JSONLDCollection,
BackupShelf: credentialsBackupShelf,
SearchQuery: leia.NewIRIPath(),
})
// init indices
return c.initJSONLDIndices()
}
func (c *vcr) Start() error {
// start listening for new credentials
_ = c.ambassador.Configure()
return c.ambassador.Start()
}
func (c *vcr) Shutdown() error {
err := c.issuerStore.Close()
if err != nil {
log.Logger().
WithError(err).
Error("Unable to close issuer store")
}
err = c.verifierStore.Close()
if err != nil {
log.Logger().
WithError(err).
Error("Unable to close verifier store")
}
return c.store.Close()
}
func whitespaceOrExactTokenizer(text string) (tokens []string) {
tokens = leia.WhiteSpaceTokenizer(text)
tokens = append(tokens, text)
return
}
func (c *vcr) credentialCollection() leia.Collection {
return c.store.Collection(leia.JSONLDCollection, "credentials")
}
func (c *vcr) loadJSONLDConfig() ([]indexConfig, error) {
list, err := fs.Glob(assets.Assets, "**/*.index.yaml")
if err != nil {
return nil, err
}
configs := make([]indexConfig, 0)
for _, f := range list {
bytes, err := assets.Assets.ReadFile(f)
if err != nil {
return nil, err
}
config := indexConfig{}
err = yaml.Unmarshal(bytes, &config)
if err != nil {
return nil, err
}
configs = append(configs, config)
}
return configs, nil
}
func (c *vcr) initJSONLDIndices() error {
collection := c.credentialCollection()
configs, err := c.loadJSONLDConfig()
if err != nil {
return err
}
for _, config := range configs {
for _, index := range config.Indices {
var leiaParts []leia.FieldIndexer
for _, iParts := range index.Parts {
options := make([]leia.IndexOption, 0)
if iParts.Tokenizer != nil {
tokenizer := strings.ToLower(*iParts.Tokenizer)
switch tokenizer {
case "whitespaceorexact":
options = append(options, leia.TokenizerOption(whitespaceOrExactTokenizer))
case "whitespace":
options = append(options, leia.TokenizerOption(leia.WhiteSpaceTokenizer))
default:
return fmt.Errorf("unknown tokenizer %s for %s", *iParts.Tokenizer, index.Name)
}
}
if iParts.Transformer != nil {
transformer := strings.ToLower(*iParts.Transformer)
switch transformer {
case "cologne":
options = append(options, leia.TransformerOption(CologneTransformer))
case "lowercase":
options = append(options, leia.TransformerOption(leia.ToLower))
default:
return fmt.Errorf("unknown transformer %s for %s", *iParts.Transformer, index.Name)
}
}
leiaParts = append(leiaParts, leia.NewFieldIndexer(leia.NewIRIPath(iParts.IRIPath...), options...))
}
leiaIndex := collection.NewIndex(index.Name, leiaParts...)
log.Logger().Debugf("Adding index %s", index.Name)
if err := collection.AddIndex(leiaIndex); err != nil {
return err
}
}
}
return nil
}
func (c *vcr) Name() string {
return ModuleName
}
func (c *vcr) Config() interface{} {
return &c.config
}
func (c *vcr) OpenID4VCIEnabled() bool {
return c.config.OpenID4VCI.Enabled
}
func (c *vcr) Resolve(ID ssi.URI, resolveTime *time.Time) (*vc.VerifiableCredential, error) {
credential, err := c.find(ID)
if err != nil {
return nil, err
}
// we don't have to check the signature, it's coming from our own store.
if err = c.verifier.Verify(credential, false, false, resolveTime); err != nil {
switch err {
case types.ErrRevoked:
return &credential, types.ErrRevoked
case types.ErrUntrusted:
return &credential, types.ErrUntrusted
default:
return nil, err
}
}
return &credential, nil
}
// find only returns a VC from storage, it does not tell anything about validity
func (c *vcr) find(ID ssi.URI) (vc.VerifiableCredential, error) {
credential := vc.VerifiableCredential{}
qp := leia.Eq(leia.NewIRIPath(), leia.MustParseScalar(ID.String()))
q := leia.New(qp)
ctx, cancel := context.WithTimeout(context.Background(), maxFindExecutionTime)
defer cancel()
docs, err := c.credentialCollection().Find(ctx, q)
if err != nil {
return credential, err
}
if len(docs) > 0 {
// there can be only one
err = json.Unmarshal(docs[0], &credential)
if err != nil {
return credential, fmt.Errorf("unable to parse credential from db: %w", err)
}
return credential, nil
}
return credential, types.ErrNotFound
}
func (c *vcr) Trust(credentialType ssi.URI, issuer ssi.URI) error {
err := c.trustConfig.AddTrust(credentialType, issuer)
if err != nil {
log.Logger().
WithField(core.LogFieldCredentialType, credentialType).
WithField(core.LogFieldCredentialIssuer, issuer).
Info("Added trust for Verifiable Credential issuer")
}
return err
}
func (c *vcr) Untrust(credentialType ssi.URI, issuer ssi.URI) error {
err := c.trustConfig.RemoveTrust(credentialType, issuer)
if err != nil {
log.Logger().
WithField(core.LogFieldCredentialType, credentialType).
WithField(core.LogFieldCredentialIssuer, issuer).
Info("Untrusted for Verifiable Credential issuer")
}
return err
}
func (c *vcr) Trusted(credentialType ssi.URI) ([]ssi.URI, error) {
return c.trustConfig.List(credentialType), nil
}
func (c *vcr) Untrusted(credentialType ssi.URI) ([]ssi.URI, error) {
didResolver := c.vdrInstance.Resolver()
trustMap := make(map[string]bool)
untrusted := make([]ssi.URI, 0)
for _, trusted := range c.trustConfig.List(credentialType) {
trustMap[trusted.String()] = true
}
// check all issued VCs
query := leia.New(leia.NotNil(leia.NewIRIPath(jsonld.CredentialIssuerPath...)))
// use type specific collection
collection := c.credentialCollection()
// for each key: add to untrusted if not present in trusted
err := collection.IndexIterate(query, func(key []byte, value []byte) error {
// we iterate over all issuers->reference pairs
issuer := string(key)
if _, ok := trustMap[issuer]; !ok {
u, err := ssi.ParseURI(issuer)
if err != nil {
return err
}
trustMap[issuer] = true
// only add to untrusted if issuer is not deactivated or has active controllers
issuerDid, err := did.ParseDIDURL(issuer)
if err != nil {
return err
}
_, _, err = didResolver.Resolve(issuerDid.DID, nil)
if err != nil {
if !(errors.Is(err, did.DeactivatedErr) || errors.Is(err, resolver.ErrNoActiveController)) {
return err
}
} else {
untrusted = append(untrusted, *u)
}
}
return nil
})
if err != nil {
if errors.Is(err, leia.ErrNoIndex) {
log.Logger().
WithField(core.LogFieldCredentialType, credentialType).
Warn("No index with field 'issuer' found for credential")
return nil, types.ErrInvalidCredential
}
return nil, err
}
return untrusted, nil
}
func (c *vcr) Diagnostics() []core.DiagnosticResult {
var credentialCount int
var err error
credentialCount, err = c.credentialCollection().DocumentCount()
if err != nil {
credentialCount = -1
log.Logger().
WithError(err).
Warn("unable to retrieve credential document count")
}
return []core.DiagnosticResult{
core.DiagnosticResultMap{
Title: "issuer",
Items: c.issuerStore.Diagnostics(),
},
core.DiagnosticResultMap{
Title: "verifier",
Items: c.verifierStore.Diagnostics(),
},
core.GenericDiagnosticResult{
Title: "credential_count",
Outcome: credentialCount,
},
core.DiagnosticResultMap{
Title: "wallet_credential_count",
Items: c.wallet.Diagnostics(),
},
}
}
// writeCredentialToWallet writes a credential to the wallet if the subject is owned by this node.
// If it's not written to the wallet (because it's not owned by this node), it returns false.
// If it's written to the wallet, it returns true.
// If an error occurs, it returns false and the error.
func (c *vcr) writeCredentialToWallet(cred vc.VerifiableCredential) (bool, error) {
put, err := c.canWalletHoldCredential(cred)
if err != nil {
return false, err
}
if put {
return true, c.wallet.Put(context.TODO(), cred)
}
return false, nil
}
// canWalletHoldCredential returns true if the credential is subject to the wallet of the node, meaning:
// - It is of a type that can be stored in the wallet
// - The subject of the credential is owned by this node
// If these conditions are met, it returns true.
// If an error occurs, it returns false and the error.
func (c *vcr) canWalletHoldCredential(cred vc.VerifiableCredential) (bool, error) {
if cred.IsType(*credential.NutsAuthorizationCredentialTypeURI) {
return false, nil
}
subjectDID, _ := cred.SubjectDID()
if subjectDID == nil {
return false, nil
}
return c.vdrInstance.IsOwner(context.Background(), *subjectDID)
}