nuts-foundation/nuts-node

View on GitHub
crypto/storage/vault/vault.go

Summary

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

import (
    "context"
    "crypto"
    "errors"
    "fmt"
    vault "github.com/hashicorp/vault/api"
    "github.com/nuts-foundation/nuts-node/core"
    "github.com/nuts-foundation/nuts-node/crypto/log"
    "github.com/nuts-foundation/nuts-node/crypto/storage/spi"
    "github.com/nuts-foundation/nuts-node/crypto/util"
    "path/filepath"
    "time"
)

const privateKeyPathName = "nuts-private-keys"
const defaultPathPrefix = "kv"
const keyName = "key"

// StorageType is the name of this storage type, used in health check reports and configuration.
const StorageType = "vaultkv"

// Config contains the config options to configure the vaultKVStorage backend
type Config struct {
    // Token to authenticate to the Vault cluster.
    Token string `koanf:"token"`
    // Address of the Vault cluster
    Address string `koanf:"address"`
    // PathPrefix can be used to overwrite the default 'kv' path.
    PathPrefix string `koanf:"pathprefix"`
    // Timeout specifies the Vault client timeout.
    Timeout time.Duration
}

// DefaultConfig returns a Config with the PathPrefix containing the default value.
func DefaultConfig() Config {
    return Config{
        PathPrefix: defaultPathPrefix,
        Timeout:    5 * time.Second,
    }
}

// logicaler is an interface which has been implemented by the mockVaultClient and real vault.Logical to allow testing vault without the server.
type logicaler interface {
    ReadWithContext(ctx context.Context, path string) (*vault.Secret, error)
    WriteWithContext(ctx context.Context, path string, data map[string]interface{}) (*vault.Secret, error)
    ReadWithDataWithContext(ctx context.Context, path string, data map[string][]string) (*vault.Secret, error)
    DeleteWithContext(ctx context.Context, path string) (*vault.Secret, error)
}

type vaultKVStorage struct {
    config Config
    client logicaler
}

func (v vaultKVStorage) Name() string {
    return StorageType
}

func (v vaultKVStorage) CheckHealth() map[string]core.Health {
    health := make(map[string]core.Health)
    if err := v.checkConnection(); err != nil {
        health[v.Name()] = core.Health{Status: core.HealthStatusDown, Details: err.Error()}
    } else {
        health[v.Name()] = core.Health{Status: core.HealthStatusUp}
    }
    return health
}

// NewVaultKVStorage creates a new Vault backend using the kv version 1 secret engine: https://www.vaultproject.io/docs/secrets/kv
// It currently only supports token authentication which should be provided by the token param.
// If config.Address is empty, the VAULT_ADDR environment should be set.
// If config.Token is empty, the VAULT_TOKEN environment should be is set.
func NewVaultKVStorage(config Config) (spi.Storage, error) {
    client, err := configureVaultClient(config)
    if err != nil {
        return nil, err
    }

    vaultStorage := vaultKVStorage{client: client.Logical(), config: config}
    if err = vaultStorage.checkConnection(); err != nil {
        return nil, err
    }
    return vaultStorage, nil
}

func (v vaultKVStorage) NewPrivateKey(ctx context.Context, namingFunc func(crypto.PublicKey) (string, error)) (crypto.PublicKey, string, error) {
    return spi.GenerateAndStore(ctx, v, namingFunc)
}

func configureVaultClient(cfg Config) (*vault.Client, error) {
    vaultConfig := vault.DefaultConfig()
    vaultConfig.Timeout = cfg.Timeout
    client, err := vault.NewClient(vaultConfig)
    if err != nil {
        return nil, fmt.Errorf("unable to initialize Vault client: %w", err)
    }
    // The Vault client will automatically use the env var VAULT_TOKEN
    // the client.SetToken overrides this value, so only set when not empty
    if cfg.Token != "" {
        client.SetToken(cfg.Token)
    }
    if cfg.Address != "" {
        if err = client.SetAddress(cfg.Address); err != nil {
            return nil, fmt.Errorf("vault address invalid: %w", err)
        }
    }
    return client, nil
}

func (v vaultKVStorage) checkConnection() error {
    // Perform a token introspection to test the connection. This should be allowed by the default vault token policy.
    log.Logger().Debug("Verifying Vault connection...")
    secret, err := v.client.ReadWithContext(context.Background(), "auth/token/lookup-self")
    if err != nil {
        return fmt.Errorf("unable to connect to Vault: unable to retrieve token status: %w", err)
    }
    if secret == nil || len(secret.Data) == 0 {
        return fmt.Errorf("could not read token information on auth/token/lookup-self")
    }
    log.Logger().Info("Connected to Vault.")
    return nil
}

func (v vaultKVStorage) GetPrivateKey(ctx context.Context, kid string) (crypto.Signer, error) {
    path := privateKeyPath(v.config.PathPrefix, kid)
    value, err := v.getValue(ctx, path, keyName)
    if err != nil {
        return nil, err
    }
    privateKey, err := util.PemToPrivateKey(value)
    if err != nil {
        return nil, err
    }
    return privateKey, nil
}

func (v vaultKVStorage) getValue(ctx context.Context, path, key string) ([]byte, error) {
    result, err := v.client.ReadWithContext(ctx, path)
    if err != nil {
        return nil, fmt.Errorf("unable to read key from vault: %w", err)
    }
    if result == nil || result.Data == nil {
        return nil, spi.ErrNotFound
    }
    rawValue, ok := result.Data[key]
    if !ok {
        return nil, spi.ErrNotFound
    }
    value, ok := rawValue.(string)
    if !ok {
        return nil, fmt.Errorf("unable to convert key result to string")
    }
    return []byte(value), nil
}
func (v vaultKVStorage) storeValue(ctx context.Context, path, key string, value string) error {
    _, err := v.client.WriteWithContext(ctx, path, map[string]interface{}{key: value})
    if err != nil {
        return fmt.Errorf("unable to write private key to vault: %w", err)
    }
    return nil
}

func (v vaultKVStorage) PrivateKeyExists(ctx context.Context, kid string) (bool, error) {
    path := privateKeyPath(v.config.PathPrefix, kid)
    _, err := v.getValue(ctx, path, keyName)
    if errors.Is(err, spi.ErrNotFound) {
        return false, nil
    }
    return err == nil, err
}

func (v vaultKVStorage) ListPrivateKeys(ctx context.Context) []string {
    path := privateKeyListPath(v.config.PathPrefix)
    response, err := v.client.ReadWithDataWithContext(ctx, path, map[string][]string{"list": {"true"}})
    if err != nil {
        log.Logger().
            WithError(err).
            Error("Could not list private keys in Vault")
        return nil
    }
    if response == nil {
        log.Logger().Warnf("Vault returned nothing while fetching private keys, maybe the path prefix ('%s') is incorrect or the engine doesn't exist?", v.config.PathPrefix)
        return nil
    }
    keys, _ := response.Data["keys"].([]interface{})
    var result []string
    for _, key := range keys {
        keyStr, ok := key.(string)
        if ok {
            result = append(result, keyStr)
        }
    }
    return result
}

// privateKeyPath cleans the kid by removing optional slashes and dots and constructs the key path
// This prevents “dot-dot-slash” aka “directory traversal” attacks.
func privateKeyPath(prefix, kid string) string {
    path := fmt.Sprintf("%s/%s/%s", prefix, privateKeyPathName, filepath.Base(kid))
    return filepath.Clean(path)
}

func privateKeyListPath(prefix string) string {
    path := fmt.Sprintf("%s/%s", prefix, privateKeyPathName)
    return filepath.Clean(path)
}

func (v vaultKVStorage) SavePrivateKey(ctx context.Context, kid string, key crypto.PrivateKey) error {
    path := privateKeyPath(v.config.PathPrefix, kid)
    pem, err := util.PrivateKeyToPem(key)
    if err != nil {
        return fmt.Errorf("unable to convert private key to pem format: %w", err)
    }

    return v.storeValue(ctx, path, keyName, pem)
}

func (v vaultKVStorage) DeletePrivateKey(ctx context.Context, kid string) error {
    path := privateKeyPath(v.config.PathPrefix, kid)
    _, err := v.client.DeleteWithContext(ctx, path)
    if err != nil {
        return fmt.Errorf("unable to delete private key from vault: %w", err)
    }
    return nil
}