Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
692d7f8
fix(vdr): reflect key store backend capability in DID document key usage
reinkrul Sep 10, 2026
30cff92
refactor(crypto): keep DID key-usage semantics out of the storage bac…
reinkrul Sep 10, 2026
1045cb3
refactor(crypto): make KeyCapability an actual bitmask
reinkrul Sep 10, 2026
95aad29
fix(crypto): default key_usage to 0, not "everything", until it's known
reinkrul Sep 10, 2026
734822b
fix(crypto): make key_reference.key_usage NOT NULL with no default
reinkrul Sep 11, 2026
41ba628
fix(storage): cover mysql/sqlserver/azuresql in the key_usage migrati…
reinkrul Sep 11, 2026
da50419
refactor(storage): simplify key_usage migration statement table to a …
reinkrul Sep 11, 2026
e279b96
fix(crypto): use 0, not "everything", as the not-yet-migrated marker
reinkrul Sep 11, 2026
5743a4b
refactor(storage): drop the unneeded constant for the 0 marker
reinkrul Sep 11, 2026
f013735
refactor(vdr): rename actualUsage to allowedKeyUsage
reinkrul Sep 11, 2026
88a331b
refactor(vdr): rename actualKeyFlags; let did:web report the flags it…
reinkrul Sep 11, 2026
26cf5aa
refactor(vdr): simplify NewDocument/NewVerificationMethod for did:nuts
reinkrul Sep 11, 2026
7ea2745
fix(crypto): key usage should also require signing capability
reinkrul Sep 11, 2026
f51cab1
refactor(crypto,vdr): fail fast on unsupported key usage instead of p…
reinkrul Sep 11, 2026
6fbd4d3
fix(vdr): regenerate stale MockMethodManager, tidy migration formatting
reinkrul Sep 11, 2026
d9a5c4f
fix(vdr): stop forcing DefaultKeyFlags on did:nuts document creation
reinkrul Sep 11, 2026
8a5c58a
fix(vdr): restore KeyAgreement key on did:nuts documents created via …
reinkrul Sep 14, 2026
85e5ad8
fix(vdr): don't fail did:nuts creation because did:web can't back Key…
reinkrul Sep 15, 2026
16132f7
docs(vdr): note V1/V2 mixing and flag the did:web KeyAgreement check …
reinkrul Sep 15, 2026
b587fb1
fix(vdr): default V2 CreateSubject to requesting a KeyAgreement key too
reinkrul Sep 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions crypto/crypto.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,14 @@ import (
"crypto"
"errors"
"fmt"
"path"
"time"

"github.com/google/uuid"
"github.com/nuts-foundation/nuts-node/v6/crypto/storage/azure"
"github.com/nuts-foundation/nuts-node/v6/storage"
"github.com/nuts-foundation/nuts-node/v6/storage/orm"
"gorm.io/gorm"
"path"
"time"

"github.com/nuts-foundation/nuts-node/v6/audit"
"github.com/nuts-foundation/nuts-node/v6/core"
Expand Down Expand Up @@ -223,8 +224,15 @@ func (client *Crypto) Migrate() error {

// New generates a new key pair.
// Stores the private key, returns the public key and DB reference.
// It returns an error when a key with the resulting ID already exists.
func (client *Crypto) New(ctx context.Context, namingFunc KIDNamingFunc) (*orm.KeyReference, crypto.PublicKey, error) {
// requiredUsage is checked against supportedKeyUsage before any key is created: if the configured
// backend can't fully back it (e.g. Azure Key Vault can't back KeyAgreement, since it doesn't support
// decryption/ECDH), no key is created and ErrKeyUsageNotSupported is returned.
// It also returns an error when a key with the resulting ID already exists.
func (client *Crypto) New(ctx context.Context, namingFunc KIDNamingFunc, requiredUsage orm.DIDKeyFlags) (*orm.KeyReference, crypto.PublicKey, error) {
if supported := client.supportedKeyUsage(); requiredUsage&supported != requiredUsage {
return nil, nil, ErrKeyUsageNotSupported
}

var ref *orm.KeyReference
var publicKey crypto.PublicKey
err := client.continueTransaction(ctx, func(tx *gorm.DB) error {
Expand All @@ -251,6 +259,20 @@ func (client *Crypto) New(ctx context.Context, namingFunc KIDNamingFunc) (*orm.K
return ref, publicKey, err
}

// supportedKeyUsage returns the DIDKeyFlags a key generated by the configured key store backend can
// back. Every backend can back AssertionKeyUsage (signing); only Azure Key Vault can't also back
// EncryptionKeyUsage (KeyAgreement), since it doesn't support decryption/ECDH with its EC keys.
// This is a simple, static, per-backend-type fact today. If RSA key support is ever added (Azure Key
// Vault RSA keys can do decryption, unlike its EC keys), this would need to become a decision that
// also depends on key type, at which point it likely belongs in the backend adapter instead.
func (client *Crypto) supportedKeyUsage() orm.DIDKeyFlags {
usage := orm.AssertionKeyUsage()
if client.config.Storage != azure.StorageType {
usage |= orm.EncryptionKeyUsage()
}
return usage
}

// Delete removes the private key with the given KID from the KeyStore.
func (client *Crypto) Delete(ctx context.Context, kid string) error {
return client.continueTransaction(ctx, func(tx *gorm.DB) error {
Expand Down
19 changes: 16 additions & 3 deletions crypto/crypto_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package crypto
import (
"context"
"github.com/nuts-foundation/nuts-node/v6/audit"
"github.com/nuts-foundation/nuts-node/v6/crypto/storage/azure"
"github.com/nuts-foundation/nuts-node/v6/crypto/storage/fs"
"github.com/nuts-foundation/nuts-node/v6/crypto/storage/spi"
"github.com/nuts-foundation/nuts-node/v6/storage"
Expand Down Expand Up @@ -109,15 +110,15 @@ func TestCrypto_New(t *testing.T) {
t.Run("ok", func(t *testing.T) {
auditLogs := audit.CaptureAuditLogs(t)

ref, pubKey, err := client.New(ctx, StringNamingFunc("kid"))
ref, pubKey, err := client.New(ctx, StringNamingFunc("kid"), orm.AssertionKeyUsage()|orm.EncryptionKeyUsage())

assert.NoError(t, err)
assert.NotNil(t, ref)
assert.NotNil(t, pubKey)
auditLogs.AssertContains(t, ModuleName, "CreateNewKey", audit.TestActor, "Generated new key pair: "+ref.KID)
})
t.Run("error - invalid naming function", func(t *testing.T) {
_, _, err := client.New(ctx, ErrorNamingFunc(assert.AnError))
_, _, err := client.New(ctx, ErrorNamingFunc(assert.AnError), orm.AssertionKeyUsage())

require.Error(t, err)
assert.ErrorIs(t, err, assert.AnError)
Expand All @@ -129,11 +130,23 @@ func TestCrypto_New(t *testing.T) {
client := createCrypto(t)
client.backend = storageMock

_, _, err := client.New(ctx, StringNamingFunc("kid"))
_, _, err := client.New(ctx, StringNamingFunc("kid"), orm.AssertionKeyUsage())

require.Error(t, err)
assert.ErrorIs(t, err, assert.AnError)
})
t.Run("required usage not supported by backend: no key is created", func(t *testing.T) {
ctrl := gomock.NewController(t)
storageMock := spi.NewMockStorage(ctrl)
// NewPrivateKey is deliberately not stubbed: it must not be called.
client := createCrypto(t)
client.backend = storageMock
client.config = Config{Storage: azure.StorageType}

_, _, err := client.New(ctx, StringNamingFunc("kid"), orm.EncryptionKeyUsage())

assert.ErrorIs(t, err, ErrKeyUsageNotSupported)
})
}

func TestCrypto_Delete(t *testing.T) {
Expand Down
11 changes: 10 additions & 1 deletion crypto/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ import (
// ErrPrivateKeyNotFound is returned when the private key doesn't exist
var ErrPrivateKeyNotFound = errors.New("private key not found")

// ErrKeyUsageNotSupported is returned when the configured key store backend can't create a key that
// backs the requested DIDKeyFlags, e.g. Azure Key Vault can't create keys usable for
// decryption/KeyAgreement. No key is created when this is returned.
var ErrKeyUsageNotSupported = errors.New("the key store can't create a key that supports the requested key usage")

// ErrorInvalidNumberOfSignatures indicates that the number of signatures present in the JWT is invalid.
var ErrorInvalidNumberOfSignatures = errors.New("invalid number of signatures")

Expand All @@ -40,7 +45,11 @@ type KeyCreator interface {
// New generates a keypair and returns a reference. The context is used to pass audit information.
// It generates a key at the backend and stores its reference in the SQL DB.
// A DB transaction may be passed through the context using `orm.TransactionKey`.
New(ctx context.Context, namingFunc KIDNamingFunc) (*orm.KeyReference, crypto.PublicKey, error)
// requiredUsage is checked against what the configured key store backend can actually back (e.g.
// an Azure Key Vault EC key can't be used for KeyAgreement, since Azure Key Vault doesn't support
// decryption/ECDH with it) before any key is created. If the backend can't fully satisfy it, no
// key is created and ErrKeyUsageNotSupported is returned.
New(ctx context.Context, namingFunc KIDNamingFunc, requiredUsage orm.DIDKeyFlags) (*orm.KeyReference, crypto.PublicKey, error)
}

// KeyResolver is the interface for resolving keys.
Expand Down
16 changes: 8 additions & 8 deletions crypto/mock.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions crypto/storage/azure/keyvault.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ func (a Keyvault) CheckHealth() map[string]core.Health {
return nil
}

// NewPrivateKey creates a new EC key in Azure Key Vault. Azure Key Vault EC keys can only be used
// for signing, they can't be used for decryption/ECDH.
func (a Keyvault) NewPrivateKey(ctx context.Context, keyName string) (crypto.PublicKey, string, error) {
var keyType azkeys.KeyType
if a.useHSM {
Expand Down
3 changes: 2 additions & 1 deletion crypto/storage/spi/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ type Storage interface {
// NewPrivateKey creates a new private key. The backend will create the version and publicKey.
// It should be preferred over generating a key in the application and saving it to the storage,
// as it allows for unexportable (safer) keys.
NewPrivateKey(ctx context.Context, keyName string) (crypto.PublicKey, string, error)
NewPrivateKey(ctx context.Context, keyName string) (publicKey crypto.PublicKey, version string, err error)
// GetPrivateKey from the storage backend and return its handler as an implementation of crypto.Signer.
GetPrivateKey(ctx context.Context, keyName string, version string) (crypto.Signer, error)
// PrivateKeyExists checks if the private key indicated with the keyname/version is stored in the storage backend.
Expand Down Expand Up @@ -116,6 +116,7 @@ func (pke PublicKeyEntry) JWK() jwk.Key {
}

// GenerateAndStore generates a new key pair and stores it in the provided storage.
// It always generates a plain, exportable EC key, which can be used for both signing and decryption.
func GenerateAndStore(ctx context.Context, store Storage, keyName string) (crypto.PublicKey, string, error) {
keyPair, err := GenerateKeyPair()
if err != nil {
Expand Down
6 changes: 1 addition & 5 deletions crypto/storage/spi/wrapper.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,9 +90,5 @@ func (w wrapper) ListPrivateKeys(ctx context.Context) []KeyNameVersion {
}

func (w wrapper) NewPrivateKey(ctx context.Context, keyName string) (crypto.PublicKey, string, error) {
publicKey, version, err := w.wrappedBackend.NewPrivateKey(ctx, keyName)
if err != nil {
return nil, "", err
}
return publicKey, version, err
return w.wrappedBackend.NewPrivateKey(ctx, keyName)
}
12 changes: 11 additions & 1 deletion crypto/test.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"crypto"
"github.com/nuts-foundation/nuts-node/v6/audit"
"github.com/nuts-foundation/nuts-node/v6/core"
"github.com/nuts-foundation/nuts-node/v6/crypto/storage/azure"
"github.com/nuts-foundation/nuts-node/v6/crypto/storage/spi"
"github.com/nuts-foundation/nuts-node/v6/storage/orm"
"github.com/stretchr/testify/require"
Expand All @@ -48,6 +49,15 @@ func NewTestCryptoInstance(db *gorm.DB, storage spi.Storage) *Crypto {
return newInstance
}

// NewAzureKeyVaultLikeCryptoInstance returns a Crypto test instance configured as if it were using
// the Azure Key Vault backend, without needing a real Azure connection: it can back signing, but not
// KeyAgreement (decryption/ECDH).
func NewAzureKeyVaultLikeCryptoInstance(db *gorm.DB) *Crypto {
newInstance := NewTestCryptoInstance(db, NewMemoryStorage())
newInstance.config = Config{Storage: azure.StorageType}
return newInstance
}

func StringNamingFunc(name string) KIDNamingFunc {
return func(key crypto.PublicKey) (string, error) {
return name, nil
Expand Down Expand Up @@ -143,7 +153,7 @@ func (t TestKey) Private() crypto.PrivateKey {
// newKeyReference creates a new DID, DIDocument, VerificationMethod and KeyReference in the DB
// It does not create valid DID Document data
func newKeyReference(t *testing.T, client *Crypto, kid string) (*orm.KeyReference, crypto.PublicKey) {
ref, publicKey, err := client.New(audit.TestContext(), StringNamingFunc(kid))
ref, publicKey, err := client.New(audit.TestContext(), StringNamingFunc(kid), orm.AssertionKeyUsage())
require.NoError(t, err)
DID := orm.DID{ID: "did:test:" + t.Name(), Subject: "subject"}
DIDDoc := orm.DidDocument{
Expand Down
6 changes: 4 additions & 2 deletions docs/_static/vdr/v1.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,13 @@ paths:
description: |
Starting with v6.0.0, the entire body will be ignored and default values will be used.
The default values are: selfControl = true, assertionMethod = true, keyAgreement = true, capabilityInvocation = true, capabilityDelegation = true, authentication = true and controllers = [].

Only a single keypair will be generated. All enabled methods will reuse the same key pair.

keyAgreement = true can't be overridden through this endpoint. If the configured key store backend can't back a KeyAgreement key (e.g. Azure Key Vault, which doesn't support decryption/ECDH with its EC keys), this operation always fails with a 400. Use the V2 API instead (`POST /internal/vdr/v2/subject`), which lets a caller omit the encryption key request. Don't mix V1 and V2 for creating and managing DIDs/subjects on the same node: switch over to V2 entirely rather than using both.

error returns:
* 400 - Invalid (combination of) options
* 400 - Invalid (combination of) options, or the key store backend can't back a requested key usage (e.g. KeyAgreement on Azure Key Vault)
* 500 - An error occurred while processing the request
operationId: "createDID"
requestBody:
Expand Down
7 changes: 6 additions & 1 deletion docs/_static/vdr/v2.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,12 @@ components:
description: If true, an EC keypair is generated and added to the DID Documents as a assertion, authentication, capability invocation and capability delegation method.
encryptionKey:
type: boolean
description: If true, an RSA keypair is generated and added to the DID Documents as a key agreement method.
description: |
If true, a keypair is generated and added to the DID Documents as a key agreement method.
Defaults to true when the keys object (or the whole request body) is omitted; only an
explicit false opts out. did:web never supports this and is skipped without an error,
regardless of this setting. If the key store backend can't support it either (e.g. Azure
Key Vault), creation fails, whether this was left at its default or set explicitly.
CreateSubjectOptions:
type: object
description: Options for the subject creation.
Expand Down
7 changes: 4 additions & 3 deletions network/network_integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import (
v2 "github.com/nuts-foundation/nuts-node/v6/network/transport/v2"
"github.com/nuts-foundation/nuts-node/v6/pki"
"github.com/nuts-foundation/nuts-node/v6/storage"
"github.com/nuts-foundation/nuts-node/v6/storage/orm"
"github.com/nuts-foundation/nuts-node/v6/test"
"github.com/nuts-foundation/nuts-node/v6/test/io"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -201,7 +202,7 @@ func TestNetworkIntegration_Messages(t *testing.T) {
})

// set root
_, key, _ := bootstrap.network.keyStore.New(audit.TestContext(), nutsCrypto.StringNamingFunc("key1"))
_, key, _ := bootstrap.network.keyStore.New(audit.TestContext(), nutsCrypto.StringNamingFunc("key1"), orm.AssertionKeyUsage())
rootTx, err := bootstrap.network.CreateTransaction(audit.TestContext(), TransactionTemplate(payloadType, []byte("root_tx"), "key1").WithAttachKey(key))
require.NoError(t, err)
require.NoError(t, node1.network.state.Add(context.Background(), rootTx, []byte("root_tx")))
Expand Down Expand Up @@ -978,7 +979,7 @@ func resetIntegrationTest(t *testing.T) {
kid.Fragment = "key-1"
_, key, _ := keyStore.New(audit.TestContext(), func(_ crypto.PublicKey) (string, error) {
return kid.String(), nil
})
}, orm.AssertionKeyUsage())
verificationMethod, _ := did.NewVerificationMethod(kid, ssi.JsonWebKey2020, nodeDID, key)
document.VerificationMethod.Add(verificationMethod)
document.KeyAgreement.Add(verificationMethod)
Expand Down Expand Up @@ -1024,7 +1025,7 @@ func addBootstrapDIDDocument(t *testing.T, n node, subject string) hash.SHA256Ha
}

func addTransactionAndWaitForItToArrive(t *testing.T, payload string, sender node, receivers ...string) bool {
keyRef, key, _ := sender.network.keyStore.New(audit.TestContext(), nutsCrypto.StringNamingFunc(uuid.New().String()))
keyRef, key, _ := sender.network.keyStore.New(audit.TestContext(), nutsCrypto.StringNamingFunc(uuid.New().String()), orm.AssertionKeyUsage())
expectedTransaction, err := sender.network.CreateTransaction(audit.TestContext(), TransactionTemplate(payloadType, []byte(payload), keyRef.KID).WithAttachKey(key))
if !assert.NoError(t, err) {
return false
Expand Down
Loading
Loading