vcr/issuer/issuer.go
/*
* Copyright (C) 2022 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 issuer
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/nuts-foundation/go-stoabs"
"github.com/nuts-foundation/nuts-node/vcr/openid4vci"
"github.com/nuts-foundation/nuts-node/vcr/revocation"
"github.com/nuts-foundation/nuts-node/vdr/didnuts"
"github.com/nuts-foundation/nuts-node/vdr/resolver"
"gorm.io/gorm"
"strings"
"time"
"github.com/google/uuid"
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/jsonld"
"github.com/nuts-foundation/nuts-node/vcr/credential"
"github.com/nuts-foundation/nuts-node/vcr/log"
"github.com/nuts-foundation/nuts-node/vcr/signature"
"github.com/nuts-foundation/nuts-node/vcr/signature/proof"
"github.com/nuts-foundation/nuts-node/vcr/trust"
"github.com/nuts-foundation/nuts-node/vcr/types"
)
// TimeFunc is a function that returns the time used, for e.g. signing time. It can be set for testing purposes.
var TimeFunc = time.Now
// NewIssuer creates a new issuer which implements the Issuer interface.
// If openidIssuerFn is nil, it won't try to issue over OpenID4VCI.
// It needs types.Writer since issued credentials need to be in the general VCR store,
// since that normally happens through receiving the just-issued credential over the network,
// but that doesn't happen when issuing over OpenID4VCI. Thus, it needs to explicitly save it to the VCR store when issuing over OpenID4VCI.
// See https://github.com/nuts-foundation/nuts-node/issues/2063
func NewIssuer(store Store, vcrStore types.Writer, networkPublisher Publisher,
openidHandlerFn func(ctx context.Context, id did.DID) (OpenIDHandler, error),
didResolver resolver.DIDResolver, keyStore crypto.KeyStore, jsonldManager jsonld.JSONLD, trustConfig *trust.Config,
statusList *revocation.StatusList2021) Issuer {
keyResolver := vdrKeyResolver{
publicKeyResolver: resolver.DIDKeyResolver{Resolver: didResolver},
privateKeyResolver: keyStore,
}
i := &issuer{
store: store,
networkPublisher: networkPublisher,
openidHandlerFn: openidHandlerFn,
walletResolver: openid4vci.DIDIdentifierResolver{
ServiceResolver: resolver.DIDServiceResolver{Resolver: didResolver},
},
keyResolver: keyResolver,
keyStore: keyStore,
jsonldManager: jsonldManager,
trustConfig: trustConfig,
vcrStore: vcrStore,
statusList: statusList,
}
statusList.Sign = i.buildJSONLDCredential
statusList.ResolveKey = i.keyResolver.ResolveAssertionKey
return i
}
type issuer struct {
store Store
networkPublisher Publisher
openidHandlerFn func(ctx context.Context, id did.DID) (OpenIDHandler, error)
keyResolver keyResolver
keyStore crypto.KeyStore
trustConfig *trust.Config
jsonldManager jsonld.JSONLD
vcrStore types.Writer
walletResolver openid4vci.IdentifierResolver
statusList revocation.StatusList2021Issuer
}
// Issue creates a new credential, signs, stores it.
// If publish is true, it publishes the credential to the network using the configured Publisher
// Use the public flag to pass the visibility settings to the Publisher.
func (i issuer) Issue(ctx context.Context, template vc.VerifiableCredential, options CredentialOptions) (*vc.VerifiableCredential, error) {
// Until further notice we don't support publishing JWT VCs, since they're not officially supported by Nuts yet.
if options.Publish && options.Format == vc.JWTCredentialProofFormat {
return nil, errors.New("publishing VC JWTs is not supported")
}
createdVC, err := i.buildAndSignVC(ctx, template, options)
if err != nil {
return nil, err
}
// Sanity check: all provided fields must be defined by the context: otherwise they're not protected by the signature
createdVCJSON, _ := json.Marshal(createdVC) // can't use createdVC.Raw() it does not return json for JWT-VCs
err = jsonld.AllFieldsDefined(i.jsonldManager.DocumentLoader(), createdVCJSON)
if err != nil {
return nil, err
}
// Validate the VC using the type-specific validator
validator := credential.FindValidator(*createdVC)
if err := validator.Validate(*createdVC); err != nil {
return nil, err
}
// Trust credential before storing/publishing, otherwise it might self-issued credentials might not be trusted,
// if AddTrust() fails for whatever reason.
// Only 1 allowed for now, but looping over all types (VerifiableCredential is excluded by ExtractTypes()) is future-proof.
for _, credentialType := range credential.ExtractTypes(*createdVC) {
// MustParseURI is safe since it came from vc.Type, which contains URIs
if err := i.trustConfig.AddTrust(ssi.MustParseURI(credentialType), createdVC.Issuer); err != nil {
return nil, fmt.Errorf("failed to trust issuer when issuing VC (did=%s,type=%s): %w", createdVC.Issuer, credentialType, err)
}
}
if err = i.store.StoreCredential(*createdVC); err != nil {
return nil, fmt.Errorf("unable to store the issued credential: %w", err)
}
if options.Publish {
// Try to issue over OpenID4VCI if it's enabled and if the credential is not public
// (public credentials are always published on the network).
if i.openidHandlerFn != nil && !options.Public {
success, err := i.issueUsingOpenID4VCI(ctx, *createdVC)
if err != nil {
// An error occurred, but it's not because the wallet/issuer doesn't support OpenID4VCI.
log.Logger().
WithField(core.LogFieldCredentialID, createdVC.ID.String()).
WithError(err).
Warnf("Couldn't publish credential over OpenID4VCI, fallback to publish over Nuts network")
} else if success {
log.Logger().
WithField(core.LogFieldCredentialID, createdVC.ID.String()).
Info("Published credential over OpenID4VCI")
return createdVC, nil
} else {
log.Logger().
WithField(core.LogFieldCredentialID, createdVC.ID.String()).
Info("Wallet or issuer does not support OpenID4VCI, fallback to publish over Nuts network")
}
}
if err := i.networkPublisher.PublishCredential(ctx, *createdVC, options.Public); err != nil {
return nil, fmt.Errorf("unable to publish the issued credential: %w", err)
}
}
return createdVC, nil
}
// issueUsingOpenID4VCI tries to issue the credential over OpenID4VCI. It returns whether the credential was offered successfully.
// If no error is returned and bool is false, it means the wallet does not support OpenID4VCI.
func (i issuer) issueUsingOpenID4VCI(ctx context.Context, credential vc.VerifiableCredential) (bool, error) {
subjectID, err := credential.SubjectDID()
if err != nil {
return false, err
}
walletIdentifier, err := i.walletResolver.Resolve(*subjectID)
if err != nil {
return false, fmt.Errorf("unable to discover wallet identifier: %w", err)
}
if walletIdentifier == "" {
// Wallet not configured for OpenID4VCI
return false, nil
}
issuerDID, _ := did.ParseDID(credential.Issuer.String()) // can't fail, already created
openidIssuer, err := i.openidHandlerFn(ctx, *issuerDID)
if err != nil {
return false, fmt.Errorf("unable to discover issuer identifier: %w", err)
}
err = openidIssuer.OfferCredential(ctx, credential, walletIdentifier)
if err != nil {
return false, fmt.Errorf("unable to offer the credential over OpenID4VCI to (wallet: %s): %w", walletIdentifier, err)
}
return true, i.vcrStore.StoreCredential(credential, nil)
}
func (i issuer) buildAndSignVC(ctx context.Context, template vc.VerifiableCredential, options CredentialOptions) (*vc.VerifiableCredential, error) {
issuerDID, err := did.ParseDID(template.Issuer.String())
if err != nil {
return nil, fmt.Errorf("failed to parse issuer: %w", err)
}
// immediately fail if we do not have the private key
key, err := i.keyResolver.ResolveAssertionKey(ctx, *issuerDID)
if err != nil {
const errString = "failed to sign credential: could not resolve an assertionKey for issuer: %w"
// Differentiate between a DID document not found and some other error:
if resolver.IsFunctionalResolveError(err) {
return nil, core.InvalidInputError(errString, err)
}
return nil, fmt.Errorf(errString, err)
}
credentialID := ssi.MustParseURI(fmt.Sprintf("%s#%s", issuerDID.String(), uuid.New().String()))
unsignedCredential := vc.VerifiableCredential{
Context: template.Context,
ID: &credentialID,
Type: template.Type,
CredentialSubject: template.CredentialSubject,
Issuer: template.Issuer,
IssuanceDate: TimeFunc(),
ExpirationDate: template.ExpirationDate,
//CredentialStatus: template.CredentialStatus, // not allowed for now since it requires API changes to be able to determine what status to revoke.
}
// statuslist
if options.WithStatusListRevocation {
// add credential status
credentialStatusEntry, err := i.statusList.Entry(ctx, *issuerDID, revocation.StatusPurposeRevocation)
if err != nil {
return nil, err
}
unsignedCredential.CredentialStatus = append(unsignedCredential.CredentialStatus, credentialStatusEntry)
// add status list context
if !unsignedCredential.ContainsContext(revocation.StatusList2021ContextURI) {
unsignedCredential.Context = append(unsignedCredential.Context, revocation.StatusList2021ContextURI)
}
}
// context
if !unsignedCredential.ContainsContext(vc.VCContextV1URI()) {
// @context is an ordered list that MUST start with the base VC context: https://www.w3.org/TR/vc-data-model/#contexts
unsignedCredential.Context = append([]ssi.URI{vc.VCContextV1URI()}, unsignedCredential.Context...)
}
// type
if len(template.Type) == 0 || // programming error
len(template.Type) > 2 || // too many types
(len(template.Type) == 2 && !template.IsType(vc.VerifiableCredentialTypeV1URI())) { // too many types
return nil, core.InvalidInputError("can only issue VerifiableCredential with at most 1 extra type")
}
if !unsignedCredential.IsType(vc.VerifiableCredentialTypeV1URI()) {
unsignedCredential.Type = append(unsignedCredential.Type, vc.VerifiableCredentialTypeV1URI())
}
// sign
switch options.Format {
case vc.JWTCredentialProofFormat:
return vc.CreateJWTVerifiableCredential(ctx, unsignedCredential, func(ctx context.Context, claims map[string]interface{}, headers map[string]interface{}) (string, error) {
return i.keyStore.SignJWT(ctx, claims, headers, key)
})
case "":
fallthrough
case vc.JSONLDCredentialProofFormat:
return i.buildJSONLDCredential(ctx, unsignedCredential, key)
default:
return nil, errors.New("unsupported credential proof format")
}
}
func (i issuer) buildJSONLDCredential(ctx context.Context, unsignedCredential vc.VerifiableCredential, key crypto.Key) (*vc.VerifiableCredential, error) {
credentialAsMap := map[string]interface{}{}
b, _ := json.Marshal(unsignedCredential)
_ = json.Unmarshal(b, &credentialAsMap)
proofOptions := proof.ProofOptions{Created: unsignedCredential.IssuanceDate}
webSig := signature.JSONWebSignature2020{ContextLoader: i.jsonldManager.DocumentLoader(), Signer: i.keyStore}
signingResult, err := proof.NewLDProof(proofOptions).Sign(ctx, credentialAsMap, webSig, key.KID())
if err != nil {
return nil, err
}
credentialJSON, _ := json.Marshal(signingResult)
return vc.ParseVerifiableCredential(string(credentialJSON))
}
func (i issuer) Revoke(ctx context.Context, credentialID ssi.URI) (*credential.Revocation, error) {
credentialDIDURL, err := did.ParseDIDURL(credentialID.String())
// revoke did:nuts credential. activating on err maintains existing behavior
if err != nil || credentialDIDURL.Method == didnuts.MethodName {
return i.revokeDIDNuts(ctx, credentialID)
}
// revoke a credential that has a revocable credentialStatus
return nil, i.revokeStatusList(ctx, credentialID)
}
// revokeDIDNuts revokes did:nuts credentials and publishes the revocation to the network
func (i issuer) revokeDIDNuts(ctx context.Context, credentialID ssi.URI) (*credential.Revocation, error) {
// Previously we first tried to resolve the credential, but that's not necessary:
// if the credential doesn't actually exist the revocation doesn't apply to anything, no harm done.
// Although it is a bit ugly, it helps issuers to revoke credentials that they don't have anymore,
// for whatever reason (e.g. incorrect database backup/restore).
isRevoked, err := i.isRevoked(credentialID)
if err != nil {
return nil, fmt.Errorf("error while checking revocation status: %w", err)
}
if isRevoked {
return nil, types.ErrRevoked
}
revocation, err := i.buildRevocation(ctx, credentialID)
if err != nil {
return nil, err
}
err = i.networkPublisher.PublishRevocation(ctx, *revocation)
if err != nil {
return nil, fmt.Errorf("failed to publish revocation: %w", err)
}
// Store the revocation after it has been published
if err := i.store.StoreRevocation(*revocation); err != nil {
return nil, fmt.Errorf("unable to store revocation: %w", err)
}
log.Logger().
WithField(core.LogFieldCredentialID, credentialID).
Info("Verifiable Credential revoked")
return revocation, nil
}
// revokeStatusList revokes a credential through its credential status
func (i issuer) revokeStatusList(ctx context.Context, credentialID ssi.URI) error {
cred, err := i.store.GetCredential(credentialID)
if err != nil {
return err
}
statuses, err := cred.CredentialStatuses()
if err != nil {
return err
}
// find the correct credentialStatus and revoke it on the relevant statuslist
for _, status := range statuses {
if status.Type == revocation.StatusList2021EntryType {
var slEntry revocation.StatusList2021Entry
err = json.Unmarshal(status.Raw(), &slEntry)
if err != nil {
return err
}
// TODO: make sure it is the correct entry when we allow other purposes, or VC issuance that include other credential statuses
if slEntry.StatusPurpose != revocation.StatusPurposeRevocation {
continue
}
return i.statusList.Revoke(ctx, credentialID, slEntry)
}
}
return types.ErrStatusNotFound
}
func (i issuer) buildRevocation(ctx context.Context, credentialID ssi.URI) (*credential.Revocation, error) {
// Sanity check: since we don't check existence of the VC, at least somewhat guard against mistyped credential IDs
// (although nobody should be typing those in).
_, err := uuid.Parse(credentialID.Fragment)
if err != nil {
return nil, core.InvalidInputError("invalid credential ID")
}
// find issuer from credential ID
issuer := credentialID
issuer.Path = ""
issuer.Fragment = ""
issuerDID, err := did.ParseDID(issuer.String())
if err != nil {
return nil, fmt.Errorf("failed to extract issuer: %w", err)
}
assertionKey, err := i.keyResolver.ResolveAssertionKey(ctx, *issuerDID)
if err != nil {
const errString = "failed to revoke credential (%s): could not resolve an assertionKey for issuer: %w"
// Differentiate between a DID document not found and some other error:
if resolver.IsFunctionalResolveError(err) {
return nil, core.InvalidInputError(errString, credentialID, err)
}
return nil, fmt.Errorf(errString, credentialID, err)
}
// set defaults
revocation := credential.BuildRevocation(issuerDID.URI(), credentialID)
revocationAsMap := map[string]interface{}{}
b, _ := json.Marshal(revocation)
_ = json.Unmarshal(b, &revocationAsMap)
ldProof := proof.NewLDProof(proof.ProofOptions{Created: TimeFunc()})
webSig := signature.JSONWebSignature2020{ContextLoader: i.jsonldManager.DocumentLoader(), Signer: i.keyStore}
signingResult, err := ldProof.Sign(ctx, revocationAsMap, webSig, assertionKey.KID())
if err != nil {
return nil, err
}
signingResultAsMap := signingResult.(proof.SignedDocument)
b, _ = json.Marshal(signingResultAsMap)
signedRevocation := credential.Revocation{}
_ = json.Unmarshal(b, &signedRevocation)
return &signedRevocation, nil
}
// isRevoked returns false if no credential.Revocation can be found, all other cases default to true.
// Only applies to did:nuts revocations.
func (i issuer) isRevoked(credentialID ssi.URI) (bool, error) {
_, err := i.store.GetRevocation(credentialID)
switch err {
case nil: // revocation found
return true, nil
case types.ErrMultipleFound:
return true, nil
case types.ErrNotFound:
return false, nil
default:
return true, err
}
}
func (i issuer) SearchCredential(credentialType ssi.URI, issuer did.DID, subject *ssi.URI) ([]vc.VerifiableCredential, error) {
return i.store.SearchCredential(credentialType, issuer, subject)
}
func (i issuer) StatusList(ctx context.Context, issuerDID did.DID, page int) (*vc.VerifiableCredential, error) {
return i.statusList.Credential(ctx, issuerDID, page)
}
func NewStore(db *gorm.DB, leiaIssuerStorePath string, leiaIssuerBackupStore stoabs.KVStore) (Store, error) {
didNutsStore, err := NewLeiaIssuerStore(leiaIssuerStorePath, leiaIssuerBackupStore)
if err != nil {
return nil, err
}
return &combinedStore{
didNutsStore: didNutsStore,
otherDIDsStore: sqlStore{db: db},
}, nil
}
var _ Store = &combinedStore{}
type combinedStore struct {
didNutsStore Store
otherDIDsStore Store
}
func (c combinedStore) Diagnostics() []core.DiagnosticResult {
var result []core.DiagnosticResult
issuedCredentialCount := 0
// both stores return issued_credentials_count, merge them
// Not the nicest way, but the did:nuts store will be removed in the future.
for _, store := range []Store{c.didNutsStore, c.otherDIDsStore} {
currResult := store.Diagnostics()
for _, diagnosticResult := range currResult {
if diagnosticResult.Name() == "issued_credentials_count" {
issuedCredentialCount += diagnosticResult.Result().(int)
} else {
result = append(result, diagnosticResult)
}
}
}
result = append(result, core.GenericDiagnosticResult{
Title: "issued_credentials_count",
Outcome: issuedCredentialCount,
})
return result
}
func (c combinedStore) GetCredential(id ssi.URI) (*vc.VerifiableCredential, error) {
if strings.HasPrefix(id.String(), "did:nuts:") {
return c.didNutsStore.GetCredential(id)
}
return c.otherDIDsStore.GetCredential(id)
}
func (c combinedStore) StoreCredential(vc vc.VerifiableCredential) error {
if strings.HasPrefix(vc.Issuer.String(), "did:nuts:") {
return c.didNutsStore.StoreCredential(vc)
}
return c.otherDIDsStore.StoreCredential(vc)
}
func (c combinedStore) GetRevocation(id ssi.URI) (*credential.Revocation, error) {
if strings.HasPrefix(id.String(), "did:nuts:") {
return c.didNutsStore.GetRevocation(id)
}
return c.otherDIDsStore.GetRevocation(id)
}
func (c combinedStore) StoreRevocation(r credential.Revocation) error {
return c.didNutsStore.StoreRevocation(r)
}
func (c combinedStore) SearchCredential(credentialType ssi.URI, issuer did.DID, subject *ssi.URI) ([]vc.VerifiableCredential, error) {
if strings.HasPrefix(issuer.String(), "did:nuts:") {
return c.didNutsStore.SearchCredential(credentialType, issuer, subject)
}
return c.otherDIDsStore.SearchCredential(credentialType, issuer, subject)
}
func (c combinedStore) Close() error {
// did:web SQL store does not need closing
return c.didNutsStore.Close()
}