Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
97 changes: 97 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ initialization, including seed and password generation.
- [Example usage](#example-usage)
- [Basic setup](#example-use-case-1-basic-setup)
- [Kubernetes](#example-use-case-2-kubernetes)
- [HashiCorp Vault](#example-use-case-3-hashicorp-vault)
- [Logging and idempotent operations](#logging-and-idempotent-operations)

## Requirements
Expand Down Expand Up @@ -379,6 +380,102 @@ $ lnd \
--wallet-unlock-password-file=/safe/location/walletpassword.txt
```

### Example use case 3: HashiCorp Vault

This example shows how a [HashiCorp Vault](https://www.vaultproject.io/) KV v2
secrets engine can be used to store the wallet seed and password instead of a
Kubernetes Secret. Unlike the k8s variant, the seed and password never
materialize as a Kubernetes Secret in `etcd`; `lndinit` reads and writes them
directly against Vault.

The `vault` source/target authenticates against Vault using the [Kubernetes auth
method](https://developer.hashicorp.com/vault/docs/auth/kubernetes): the pod's
projected ServiceAccount token is exchanged for a Vault token bound to a role
and policy. The Vault server address is taken from the `VAULT_ADDR` environment
variable (or the `--vault.addr` flag).

The following flags are shared by the `init-wallet`, `store-secret` and
`load-secret` commands under the `vault` namespace:

- `--vault.addr`: The Vault server address (defaults to `VAULT_ADDR`).
- `--vault.auth-mount`: The mount path of the Kubernetes auth method (default
`kubernetes`).
- `--vault.auth-role`: The Vault role to assume (required).
- `--vault.auth-token-path`: The ServiceAccount token file used to authenticate
(defaults to the standard projected token path).
- `--vault.kv-mount`: The mount path of the KV v2 secrets engine (default
`secret`).
- `--vault.secret-path`: The path of the secret within the KV v2 engine,
excluding the engine mount and the `data/` segment (e.g. `lnd/mynode/wallet`).

#### 1. Store the wallet password and seed in Vault

Each value is stored as a separate entry (key) within the same KV v2 secret. If
an entry already exists, it is not overwritten and the operation is a no-op.

```shell
$ lndinit gen-password \
| lndinit -v store-secret \
--target=vault \
--vault.auth-role=lnd \
--vault.secret-path=lnd/mynode/wallet \
--vault.secret-key-name=walletpassword

$ lndinit gen-seed \
| lndinit -v store-secret \
--target=vault \
--vault.auth-role=lnd \
--vault.secret-path=lnd/mynode/wallet \
--vault.secret-key-name=walletseed
```

#### 2. Initialize the wallet from Vault

```shell
$ lndinit -v init-wallet \
--secret-source=vault \
--vault.auth-role=lnd \
--vault.secret-path=lnd/mynode/wallet \
--vault.seed-key-name=walletseed \
--vault.wallet-password-key-name=walletpassword \
--init-file.output-wallet-dir=$HOME/.lnd/data/chain/bitcoin/mainnet \
--init-file.validate-password
```

#### 3. Auto unlock lnd from Vault

As with the Kubernetes example, a named pipe can be used so the password is only
read once during `lnd`'s startup.

```shell
$ mkfifo /tmp/wallet-password
$ lndinit load-secret \
--source=vault \
--vault.auth-role=lnd \
--vault.secret-path=lnd/mynode/wallet \
--vault.secret-key-name=walletpassword > /tmp/wallet-password &

$ lnd \
--bitcoin.active \
...
--wallet-unlock-password-file=/tmp/wallet-password
```

A complete startup script that wires these steps together is available in
[`example-init-wallet-vault.sh`](example-init-wallet-vault.sh).

The Vault role referenced above must be bound to a policy that grants KV v2
access to the wallet path, for example:

```hcl
path "secret/data/lnd/*" {
capabilities = ["create", "read", "update"]
}
path "secret/metadata/lnd/*" {
capabilities = ["read"]
}
```

---

## Logging and idempotent operations
Expand Down
78 changes: 71 additions & 7 deletions cmd_init_wallet.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,30 @@ type secretSourceK8s struct {
Base64 bool `long:"base64" description:"Encode as base64 when storing and decode as base64 when reading"`
}

type secretSourceVault struct {
Addr string `long:"addr" description:"The address of the Vault server; if unset, the VAULT_ADDR environment variable is used"`
AuthMount string `long:"auth-mount" description:"The mount path of the Vault Kubernetes auth method"`
AuthRole string `long:"auth-role" description:"The role to assume when logging into Vault via the Kubernetes auth method"`
AuthTokenPath string `long:"auth-token-path" description:"The full path to the Kubernetes ServiceAccount token file that is used to authenticate against Vault"`
KVMount string `long:"kv-mount" description:"The mount path of the KV v2 secrets engine the secret lives in"`
SecretPath string `long:"secret-path" description:"The path of the secret within the KV v2 engine, excluding the engine mount and the 'data/' segment (e.g. 'lnd/mynode/wallet')"`
SeedKeyName string `long:"seed-key-name" description:"The name of the entry within the secret that contains the seed"`
SeedPassphraseKeyName string `long:"seed-passphrase-key-name" description:"The name of the entry within the secret that contains the seed passphrase"`
WalletPasswordKeyName string `long:"wallet-password-key-name" description:"The name of the entry within the secret that contains the wallet password"`
}

func (s *secretSourceVault) options(keyName string) *vaultSecretOptions {
return &vaultSecretOptions{
Addr: s.Addr,
AuthMount: s.AuthMount,
AuthRole: s.AuthRole,
AuthTokenPath: s.AuthTokenPath,
KVMount: s.KVMount,
SecretPath: s.SecretPath,
SecretKeyName: keyName,
}
}

type initTypeFile struct {
OutputWalletDir string `long:"output-wallet-dir" description:"The directory in which the wallet.db file should be initialized"`
ValidatePassword bool `long:"validate-password" description:"If a wallet file already exists in the output wallet directory, validate that it can be unlocked with the given password; this will try to decrypt the wallet and will take several seconds to complete"`
Expand All @@ -65,13 +89,14 @@ type initTypeRpc struct {
}

type initWalletCommand struct {
Network string `long:"network" description:"The Bitcoin network to initialize the wallet for, required for wallet internals" choice:"mainnet" choice:"testnet" choice:"testnet3" choice:"regtest" choice:"simnet"`
SecretSource string `long:"secret-source" description:"Where to read the secrets from to initialize the wallet with" choice:"file" choice:"k8s"`
File *secretSourceFile `group:"Flags for reading the secrets from files (use when --secret-source=file)" namespace:"file"`
K8s *secretSourceK8s `group:"Flags for reading the secrets from Kubernetes (use when --secret-source=k8s)" namespace:"k8s"`
InitType string `long:"init-type" description:"How to initialize the wallet" choice:"file" choice:"rpc"`
InitFile *initTypeFile `group:"Flags for initializing the wallet as a file (use when --init-type=file)" namespace:"init-file"`
InitRpc *initTypeRpc `group:"Flags for initializing the wallet through RPC (use when --init-type=rpc)" namespace:"init-rpc"`
Network string `long:"network" description:"The Bitcoin network to initialize the wallet for, required for wallet internals" choice:"mainnet" choice:"testnet" choice:"testnet3" choice:"regtest" choice:"simnet"`
SecretSource string `long:"secret-source" description:"Where to read the secrets from to initialize the wallet with" choice:"file" choice:"k8s" choice:"vault"`
File *secretSourceFile `group:"Flags for reading the secrets from files (use when --secret-source=file)" namespace:"file"`
K8s *secretSourceK8s `group:"Flags for reading the secrets from Kubernetes (use when --secret-source=k8s)" namespace:"k8s"`
Vault *secretSourceVault `group:"Flags for reading the secrets from HashiCorp Vault (use when --secret-source=vault)" namespace:"vault"`
InitType string `long:"init-type" description:"How to initialize the wallet" choice:"file" choice:"rpc"`
InitFile *initTypeFile `group:"Flags for initializing the wallet as a file (use when --init-type=file)" namespace:"init-file"`
InitRpc *initTypeRpc `group:"Flags for initializing the wallet through RPC (use when --init-type=rpc)" namespace:"init-rpc"`
}

func newInitWalletCommand() *initWalletCommand {
Expand All @@ -82,6 +107,11 @@ func newInitWalletCommand() *initWalletCommand {
K8s: &secretSourceK8s{
Namespace: defaultK8sNamespace,
},
Vault: &secretSourceVault{
AuthMount: defaultVaultAuthMount,
AuthTokenPath: defaultK8sServiceAccountTokenPath,
KVMount: defaultVaultKVMount,
},
InitType: typeFile,
InitFile: &initTypeFile{},
InitRpc: &initTypeRpc{
Expand Down Expand Up @@ -275,6 +305,40 @@ func (x *initWalletCommand) readInput(requireSeed bool) (string, string, string,
if err != nil {
return "", "", "", err
}

// Read the secrets from a HashiCorp Vault KV v2 secret.
case storageVault:
if requireSeed {
logger.Infof("Reading seed from vault secret %s",
x.Vault.SecretPath)
seed, _, err = readVault(
x.Vault.options(x.Vault.SeedKeyName),
)
if err != nil {
return "", "", "", err
}
}

// The seed passphrase is optional.
if x.Vault.SeedPassphraseKeyName != "" {
logger.Infof("Reading seed passphrase from vault "+
"secret %s", x.Vault.SecretPath)
seedPassPhrase, _, err = readVault(
x.Vault.options(x.Vault.SeedPassphraseKeyName),
)
if err != nil {
return "", "", "", err
}
}

logger.Infof("Reading wallet password from vault secret %s",
x.Vault.SecretPath)
walletPassword, _, err = readVault(
x.Vault.options(x.Vault.WalletPasswordKeyName),
)
if err != nil {
return "", "", "", err
}
}

// The seed, its passphrase and the wallet password should all never
Expand Down
37 changes: 34 additions & 3 deletions cmd_load_secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import (
)

type loadSecretCommand struct {
Source string `long:"source" short:"s" description:"Secret storage source" choice:"k8s"`
K8s *k8sSecretOptions `group:"Flags for looking up the secret as a value inside a Kubernetes Secret (use when --source=k8s)" namespace:"k8s"`
Output string `long:"output" short:"o" description:"Output format" choice:"raw" choice:"json"`
Source string `long:"source" short:"s" description:"Secret storage source" choice:"k8s" choice:"vault"`
K8s *k8sSecretOptions `group:"Flags for looking up the secret as a value inside a Kubernetes Secret (use when --source=k8s)" namespace:"k8s"`
Vault *vaultSecretOptions `group:"Flags for looking up the secret as an entry inside a HashiCorp Vault KV v2 secret (use when --source=vault)" namespace:"vault"`
Output string `long:"output" short:"o" description:"Output format" choice:"raw" choice:"json"`
}

func newLoadSecretCommand() *loadSecretCommand {
Expand All @@ -18,6 +19,11 @@ func newLoadSecretCommand() *loadSecretCommand {
K8s: &k8sSecretOptions{
Namespace: defaultK8sNamespace,
},
Vault: &vaultSecretOptions{
AuthMount: defaultVaultAuthMount,
AuthTokenPath: defaultK8sServiceAccountTokenPath,
KVMount: defaultVaultKVMount,
},
Output: outputFormatRaw,
}
}
Expand Down Expand Up @@ -70,6 +76,31 @@ func (x *loadSecretCommand) Execute(_ []string) error {

return nil

case storageVault:
content, secret, err := readVault(x.Vault)
if err != nil {
return fmt.Errorf("error reading secret %s from "+
"vault: %v", x.Vault.SecretPath, err)
}

if x.Output == outputFormatJSON {
content, err = asJSON(&struct {
*jsonVaultObject `json:",inline"`
Value string `json:"value"`
}{
jsonVaultObject: secret,
Value: content,
})
if err != nil {
return fmt.Errorf("error encoding as JSON: %v",
err)
}
}

fmt.Printf("%s\n", content)

return nil

default:
return fmt.Errorf("invalid secret storage source %s", x.Source)
}
Expand Down
52 changes: 48 additions & 4 deletions cmd_store_secret.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ type entry struct {
}

type storeSecretCommand struct {
Batch bool `long:"batch" description:"Instead of reading one secret from stdin, read all files of the argument list and store them as entries in the secret"`
Overwrite bool `long:"overwrite" description:"Overwrite existing secret entries instead of aborting"`
Target string `long:"target" short:"t" description:"Secret storage target" choice:"k8s"`
K8s *targetK8sSecret `group:"Flags for storing the secret as a value inside a Kubernetes Secret (use when --target=k8s)" namespace:"k8s"`
Batch bool `long:"batch" description:"Instead of reading one secret from stdin, read all files of the argument list and store them as entries in the secret"`
Overwrite bool `long:"overwrite" description:"Overwrite existing secret entries instead of aborting"`
Target string `long:"target" short:"t" description:"Secret storage target" choice:"k8s" choice:"vault"`
K8s *targetK8sSecret `group:"Flags for storing the secret as a value inside a Kubernetes Secret (use when --target=k8s)" namespace:"k8s"`
Vault *vaultSecretOptions `group:"Flags for storing the secret as an entry inside a HashiCorp Vault KV v2 secret (use when --target=vault)" namespace:"vault"`
}

func newStoreSecretCommand() *storeSecretCommand {
Expand All @@ -39,6 +40,11 @@ func newStoreSecretCommand() *storeSecretCommand {
ResourcePolicy: defaultK8sResourcePolicy,
},
},
Vault: &vaultSecretOptions{
AuthMount: defaultVaultAuthMount,
AuthTokenPath: defaultK8sServiceAccountTokenPath,
KVMount: defaultVaultKVMount,
},
}
}

Expand Down Expand Up @@ -101,6 +107,15 @@ func (x *storeSecretCommand) Execute(args []string) error {

return storeSecretsK8s(entries, x.K8s, x.Overwrite)

case storageVault:
// Take the actual entry key from the options if we aren't in
// batch mode.
if len(entries) == 1 && entries[0].key == "" {
entries[0].key = x.Vault.SecretKeyName
}

return storeSecretsVault(entries, x.Vault, x.Overwrite)

default:
return fmt.Errorf("invalid secret storage target %s", x.Target)
}
Expand Down Expand Up @@ -138,3 +153,32 @@ func storeSecretsK8s(entries []*entry, opts *targetK8sSecret,

return nil
}

func storeSecretsVault(entries []*entry, opts *vaultSecretOptions,
overwrite bool) error {

if opts.SecretPath == "" {
return fmt.Errorf("secret path is required")
}

for _, entry := range entries {
if entry.key == "" {
return fmt.Errorf("secret key name is required")
}

// Each entry is stored under its own key within the same KV v2
// secret, so we only vary the key name between iterations.
entryOpts := *opts
entryOpts.SecretKeyName = entry.key

logger.Infof("Storing entry with name %s to secret %s in vault",
entryOpts.SecretKeyName, entryOpts.SecretPath)
err := saveVault(entry.value, &entryOpts, overwrite)
if err != nil {
return fmt.Errorf("error storing secret %s entry %s: "+
"%v", opts.SecretPath, entry.key, err)
}
}

return nil
}
Loading