diff --git a/README.md b/README.md index b095941..dd8de9e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 diff --git a/cmd_init_wallet.go b/cmd_init_wallet.go index b3c56c8..23e582e 100644 --- a/cmd_init_wallet.go +++ b/cmd_init_wallet.go @@ -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"` @@ -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 { @@ -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{ @@ -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 diff --git a/cmd_load_secret.go b/cmd_load_secret.go index db018c9..9839689 100644 --- a/cmd_load_secret.go +++ b/cmd_load_secret.go @@ -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 { @@ -18,6 +19,11 @@ func newLoadSecretCommand() *loadSecretCommand { K8s: &k8sSecretOptions{ Namespace: defaultK8sNamespace, }, + Vault: &vaultSecretOptions{ + AuthMount: defaultVaultAuthMount, + AuthTokenPath: defaultK8sServiceAccountTokenPath, + KVMount: defaultVaultKVMount, + }, Output: outputFormatRaw, } } @@ -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) } diff --git a/cmd_store_secret.go b/cmd_store_secret.go index d20ded5..fc75436 100644 --- a/cmd_store_secret.go +++ b/cmd_store_secret.go @@ -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 { @@ -39,6 +40,11 @@ func newStoreSecretCommand() *storeSecretCommand { ResourcePolicy: defaultK8sResourcePolicy, }, }, + Vault: &vaultSecretOptions{ + AuthMount: defaultVaultAuthMount, + AuthTokenPath: defaultK8sServiceAccountTokenPath, + KVMount: defaultVaultKVMount, + }, } } @@ -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) } @@ -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 +} diff --git a/docs/vault-integration-design.md b/docs/vault-integration-design.md new file mode 100644 index 0000000..faed3c1 --- /dev/null +++ b/docs/vault-integration-design.md @@ -0,0 +1,275 @@ +# lndinit Vault Integration — Design & Plan (KV v2 variant) + +Status: reviewed — decisions locked, ready to execute +Author: (design pass) +Related: [lightninglabs/lndinit#62](https://github.com/lightninglabs/lndinit/pull/62) (to be superseded) + +### Locked decisions + +1. **Scope:** `lndinit` Go change **and** the `lightning-infra` wiring (policy, + k8s auth role, lnd chart init-script) — two PRs, one per repo. +2. **PR strategy:** open a **fresh PR** that supersedes #62; close #62. +3. **KV version:** **v2 only.** Drop PR #62's v1 behavior; no version toggle. +4. **Auth:** use the official `vault/api/auth/kubernetes` helper (mount + + audience configurable). +5. **Architecture:** Option A — direct-to-Vault for both read and write; the + seed/password never become a k8s Secret. + +Still open (rollout detail, decide before step 5 of §11): whether to migrate +existing k8s-Secret seeds into Vault or adopt Vault only for new nodes. + +## 1. Purpose + +PR #62 adds a `vault` secret source/target to `lndinit` (init-wallet, +store-secret, load-secret). It is a straight port of an old branch from +Oliver's `lnd` fork and was written against a **KV v1** Vault with a +hand-rolled Kubernetes login. Our production Vault in `lightning-infra` looks +different today. This doc specifies a **new variant** of the Vault integration +that targets the Vault we actually run now, so that `lnd` pods can keep their +wallet seed and password in Vault (never in an etcd-backed k8s Secret). + +## 2. What PR #62 does today + +- New `vault.go` with `getClientVault` / `readVault` / `saveVault` / + `storeSecretVault`, mirroring the existing `k8s.go` shape. +- Wires a third choice into three commands: `init-wallet --secret-source=vault`, + `store-secret --target=vault`, `load-secret --source=vault`. +- Auth: hand-rolled `POST /v1/auth/kubernetes/login` with the pod's + ServiceAccount JWT + a role, then sets the returned client token. +- Secret access: `client.Logical().Read(name)` and + `client.Logical().Write(name, flatMap)`, reading `secret.Data[key]` directly. +- Config: `api.DefaultConfig()` (reads `VAULT_ADDR` and friends from env). +- Adds `example-init-wallet-vault.sh` mirroring the k8s example. + +## 3. What our Vault actually is (from `lightning-infra`) + +Concrete facts that drive the design (evidence in `lightning-infra`): + +- **Engine: KV v2**, mount `secret/`. Values live at `secret/data/`, + metadata at `secret/metadata/`. The infra provisioner uses + `client.KVv2("secret")` (`containers/vault-github-provisioner/main.go`), and + VSO manifests declare `type: kv-v2`, `mount: secret`. +- **Auth: Kubernetes auth method** at mount `auth/kubernetes`; roles bind a + ServiceAccount + namespace to a policy, with token `audiences: ["vault"]` + (see `charts/*/templates/static-auth.yaml`, + `charts/vault-secrets-operator/values-*.yaml`). GitHub auth exists too, but + that is for humans, not workloads. +- **Server address (in-cluster):** `http://vault.vault.svc.cluster.local:8200` + (`defaultVaultConnection` in the VSO values). Ingress URLs per env exist for + humans. +- **VSO is the standard read path** for most workloads: `VaultAuth` + + `VaultStaticSecret` sync a Vault path into a k8s Secret. VSO is read-only and + cannot generate/write secrets. +- **Current lnd flow does NOT use Vault.** `lnd` charts run `lndinit + gen-password` / `gen-seed` and `store-secret --target=k8s`, then auto-unlock + by reading the password back from the k8s Secret through a FIFO. The seed and + password therefore sit in etcd as a k8s Secret. +- **Deploy model:** GitOps via ArgoCD; changes land through + `charts//values-.yaml` and app-info files. + +## 4. Gap analysis: PR #62 vs. our Vault + +| Concern | PR #62 | Our Vault | Consequence | +|---|---|---|---| +| KV version | v1 (`Logical().Read/Write`, flat `Data`) | **v2** (nested `data`, `/data/` path) | PR #62 reads/writes the wrong shape; a v2 read returns `{data:{...}, metadata:{...}}`, so `Data[key]` is nil. **Must fix.** | +| Auth | hand-rolled HTTP login | `auth/kubernetes`, mount configurable, audiences `["vault"]` | Works, but brittle; no mount/audience config. Prefer official helper. | +| Path model | single `--secret-name` used as literal path | mount + KV path + key | Need explicit mount + path + key fields. | +| Write semantics | read-modify-`Write` whole map | KV v2 `Patch`/`Put` | Use KVv2 helper to avoid clobbering sibling keys. | +| Server addr | env only | in-cluster DNS default | Keep env-driven; optionally allow `--vault.addr`. | + +## 5. Scope & architecture decision + +The store/generate path (gen-seed → persist) **must** talk to Vault directly — +VSO cannot write. So this remains a direct-to-Vault feature in `lndinit`. The +only real fork is the *consume* (auto-unlock) path: + +- **Option A — direct Vault for read and write (recommended).** `lndinit` + authenticates via k8s auth and reads the seed/password directly at init and + unlock time. The seed **never** materializes as a k8s Secret. This is the + security win and the natural modernization of PR #62. +- **Option B — VSO for the read path.** VSO syncs `secret/data/lnd/` into + a k8s Secret; `lndinit` keeps `--secret-source=k8s` for unlock. Less code, but + the seed lands in etcd, defeating much of the point. Store path still needs + direct Vault. + +This plan assumes **Option A**. + +## 6. Proposed CLI surface + +Rework `vaultSecretOptions` (shared by all three commands) to KV v2 + explicit +auth config. Field/flag names mirror the k8s source where possible. + +``` +# Connection & auth (shared) +--vault.addr (optional; else VAULT_ADDR / api.DefaultConfig) +--vault.auth-mount=kubernetes +--vault.auth-role= (required) +--vault.auth-token-path=/var/run/secrets/kubernetes.io/serviceaccount/token + +# KV v2 location (shared) +--vault.kv-mount=secret +--vault.secret-path=lnd//wallet (KV path, WITHOUT mount or /data/) +--vault.secret-key-name= (store-secret / load-secret) + +# init-wallet only (multiple keys within one secret) +--vault.seed-key-name=walletseed +--vault.seed-passphrase-key-name= +--vault.wallet-password-key-name=walletpassword +``` + +Notes: +- Rename PR #62's `--vault.secret-name` → `--vault.secret-path` to reflect KV v2 + path semantics, and add `--vault.kv-mount` / `--vault.auth-mount`. +- **Audience** is intentionally *not* a flag: the token audience is a property + of the projected ServiceAccount token the pod mounts (set in the pod spec's + `serviceAccountToken` projection and matched by the Vault role), so + `--vault.auth-token-path` just points at that token. The official k8s auth + helper exposes no client-side audience option. +- `main.go`: keep `storageVault = "vault"`; add `vault` to the `choice:` lists + on `--secret-source`, `--source`, `--target`. + +## 7. Go implementation plan (`vault.go` rewrite) + +Dependencies (add to `go.mod`): +- `github.com/hashicorp/vault/api` (already pulled by PR #62). +- `github.com/hashicorp/vault/api/auth/kubernetes` (small helper module) — for + `NewKubernetesAuth(role, WithMountPath(...), WithServiceAccountTokenPath(...))`. + +Client construction: +```go +cfg := api.DefaultConfig() // VAULT_ADDR, TLS, etc. from env +if opts.Addr != "" { cfg.Address = opts.Addr } +client, _ := api.NewClient(cfg) + +k8sAuth, _ := auth.NewKubernetesAuth( + opts.AuthRole, + auth.WithMountPath(opts.AuthMount), // default "kubernetes" + auth.WithServiceAccountTokenPath(opts.AuthTokenPath), +) +if _, err := client.Auth().Login(ctx, k8sAuth); err != nil { ... } +``` +(If we prefer zero extra modules, keep PR #62's hand-rolled login but make the +mount path configurable. Recommendation: use the official helper.) + +Read (`readVault`) — KV v2: +```go +kv := client.KVv2(opts.KVMount) // default "secret" +sec, err := kv.Get(ctx, opts.SecretPath) // handles /data/ automatically +raw, ok := sec.Data[opts.SecretKeyName].(string) +// not-found and empty-entry errors as today; TrimRight "\r\n" as PR #62 does. +``` + +Write (`storeSecretVault` / `saveVault`) — KV v2, no clobber: +```go +kv := client.KVv2(opts.KVMount) +existing, err := kv.Get(ctx, opts.SecretPath) // errors.Is(err, ErrSecretNotFound) -> empty +data := existing.Data (or {}) // read-modify-write merge +if data[key] != nil && !overwrite { return errTargetExists } +data[key] = content +kv.Put(ctx, opts.SecretPath, data) // full write of merged map +``` +- Read-modify-`Put` (rather than `Patch`) so the lnd policy only needs + `create/read/update` on `secret/data/lnd/*` — `Patch` would require the + separate `patch` capability. +- `errTargetExists` sentinel is preserved so `-e/--error-on-existing` and the + existing exit-code contract keep working (see `main.go`). +- `jsonVaultObject` for `load-secret --output=json` can be sourced from + `sec.Raw` (request_id, lease_*, renewable) to keep parity with PR #62. + +`init-wallet` `readInput` switch: the `case storageVault:` block from PR #62 +stays structurally the same, just pointing at the new options +(seed/passphrase/password keys under one `--vault.secret-path`). + +## 8. `lightning-infra` wiring (companion changes) + +These are the infra-side pieces that make Option A run (separate PR in +`lightning-infra`, GitOps): + +1. **Vault policy** (e.g. `lnd`), KV v2 capabilities scoped to lnd paths: + ```hcl + path "secret/data/lnd/*" { capabilities = ["create", "read", "update"] } + path "secret/metadata/lnd/*" { capabilities = ["read"] } + ``` +2. **k8s auth role** binding the lnd ServiceAccount(s)/namespace(s) to the + policy: + ``` + vault write auth/kubernetes/role/lnd \ + bound_service_account_names= \ + bound_service_account_namespaces= \ + token_policies=lnd token_ttl=1h + ``` + Fold this into the vault-provisioner setup job pattern rather than by hand. +3. **lnd chart**: switch `configmap-init-script.yaml` from the `--target=k8s` + calls to the `vault` variant (`store-secret --target=vault`, + `init-wallet --secret-source=vault`, `load-secret --source=vault`), and set + env: `VAULT_ADDR=http://vault.vault.svc.cluster.local:8200`, + `VAULT_ROLE=lnd`, `VAULT_SECRET_PATH=lnd//wallet`. Project the SA token + with `audience: vault` (or rely on the default projected token if the role + accepts it). +4. **Image**: bump the `lndinit` image tag once the new binary is released + (the lnd chart already uses an `lndinit`-bundled image). + +## 9. Security considerations + +- Seed/password never become a k8s Secret under Option A — they live only in + Vault and transit the pod as an in-memory value / FIFO, matching the existing + auto-unlock FIFO trick. +- Least-privilege policy: lnd role gets `create/read/update` on `secret/data/lnd/*` + only; no `delete`, no cross-namespace paths. +- Token audience pinned to `vault`; SA-token path configurable for non-default + projections. +- Idempotency: `errTargetExists` + `-e` preserves "assert exists, don't + overwrite" semantics so a restart won't rotate the seed. **Critical**: seed + overwrite must never happen silently on an existing wallet. + +## 10. Testing plan + +- **Unit**: table tests for KV v2 read/merge-write shaping against a stubbed + `Logical()`/httptest Vault (mirror `k8s_test.go` style). Cover: missing + secret, missing key, empty value, overwrite vs. no-overwrite (`errTargetExists`), + trailing-newline trim. +- **Integration (manual/dev)**: dev-mode Vault (`vault server -dev`) with KV v2 + + k8s auth stubbed via token; run the full `example-init-wallet-vault.sh` + against it. +- **regtest e2e**: in a kind/dev cluster with the real Vault chart, run the lnd + init script end-to-end (gen → store → init → unlock). +- Keep the k8s path tests green; the vault path is additive. + +## 11. Rollout / migration + +1. Land `lndinit` Go change (KV v2 + k8s auth helper), cut a release + image. +2. Land `lightning-infra` policy + role (provisioner) — no workload impact yet. +3. Flip one **staging** lnd node's init script to the vault variant; verify + clean init and unlock across a pod restart. +4. Promote to testnet, then prod, per the usual ArgoCD env progression. +5. For existing nodes with a seed already in a k8s Secret: one-time migrate by + `store-secret --target=vault` from the current values, verify, then remove + the k8s Secret. (Or leave existing nodes as-is and only use Vault for new + nodes — TBD, see open questions.) + +## 12. Execution order + +Two PRs, `lndinit` first (it produces the binary/image the infra depends on): + +**PR 1 — `lndinit` (this repo), fresh branch superseding #62:** +1. Add deps: `vault/api`, `vault/api/auth/kubernetes`. +2. Rewrite `vault.go`: KV v2 (`client.KVv2`) read + merge-write, official k8s + auth login, new `vaultSecretOptions` (mount/path/auth-mount/role/audiences). +3. Update `main.go` `choice:` tags to include `vault` on the three commands. +4. Update `cmd_init_wallet.go` / `cmd_load_secret.go` / `cmd_store_secret.go` + `vault` cases to the new options. +5. Rewrite `example-init-wallet-vault.sh` for KV v2 flags + `VAULT_ADDR`. +6. Unit tests (`vault_test.go`) mirroring `k8s_test.go`. +7. README: document the `vault` source/target. +8. Open fresh PR; close #62 with a pointer. + +**PR 2 — `lightning-infra` (companion, after image is cut):** +1. Vault policy `lnd` (`secret/data/lnd/*` create/read/update) via the + provisioner setup-job pattern. +2. k8s auth role `lnd` binding lnd SA + namespace(s) → policy. +3. lnd chart: init-script → vault variant; env `VAULT_ADDR`, `VAULT_ROLE`, + `VAULT_SECRET_PATH`; SA token `audience: vault`; bump `lndinit` image. +4. Roll staging → testnet → prod per §11. + +Still open (decide before §11 step 5): existing-node seed migration vs. +Vault-only-for-new-nodes. diff --git a/example-init-wallet-vault.sh b/example-init-wallet-vault.sh new file mode 100755 index 0000000..1ec79be --- /dev/null +++ b/example-init-wallet-vault.sh @@ -0,0 +1,93 @@ +#!/bin/bash + +set -e + +# The address of the Vault server. Inside a Kubernetes cluster this is usually +# the in-cluster service DNS name. It can also be provided to lndinit via the +# VAULT_ADDR environment variable instead of the --vault.addr flag. +VAULT_ADDR=${VAULT_ADDR:-http://vault.vault.svc.cluster.local:8200} +export VAULT_ADDR + +# The Vault Kubernetes auth role that binds this pod's ServiceAccount to a +# policy allowing access to the wallet secret path. +VAULT_AUTH_ROLE=${VAULT_AUTH_ROLE:-lnd} + +# The mount path of the KV v2 secrets engine and the path of the wallet secret +# within it (excluding the engine mount and the "data/" segment). +VAULT_KV_MOUNT=${VAULT_KV_MOUNT:-secret} +VAULT_SECRET_PATH=${VAULT_SECRET_PATH:-lnd/mynode/wallet} + +WALLET_DIR=${WALLET_DIR:-~/.lnd/data/chain/bitcoin/mainnet} +WALLET_PASSWORD_FILE=${WALLET_PASSWORD_FILE:-/tmp/wallet-password} +CERT_DIR=${CERT_DIR:-~/.lnd} +UPLOAD_RPC_SECRETS=${UPLOAD_RPC_SECRETS:-0} +RPC_SECRETS_PATH=${RPC_SECRETS_PATH:-lnd/mynode/rpc} + +echo "[STARTUP] Asserting wallet password exists in secret ${VAULT_SECRET_PATH}" +lndinit gen-password \ + | lndinit -v store-secret \ + --target=vault \ + --vault.auth-role="${VAULT_AUTH_ROLE}" \ + --vault.kv-mount="${VAULT_KV_MOUNT}" \ + --vault.secret-path="${VAULT_SECRET_PATH}" \ + --vault.secret-key-name=walletpassword + +echo "" +echo "[STARTUP] Asserting seed exists in secret ${VAULT_SECRET_PATH}" +lndinit gen-seed \ + | lndinit -v store-secret \ + --target=vault \ + --vault.auth-role="${VAULT_AUTH_ROLE}" \ + --vault.kv-mount="${VAULT_KV_MOUNT}" \ + --vault.secret-path="${VAULT_SECRET_PATH}" \ + --vault.secret-key-name=walletseed + +echo "" +echo "[STARTUP] Asserting wallet is created with values from secret ${VAULT_SECRET_PATH}" +lndinit -v init-wallet \ + --secret-source=vault \ + --vault.auth-role="${VAULT_AUTH_ROLE}" \ + --vault.kv-mount="${VAULT_KV_MOUNT}" \ + --vault.secret-path="${VAULT_SECRET_PATH}" \ + --vault.seed-key-name=walletseed \ + --vault.wallet-password-key-name=walletpassword \ + --init-file.output-wallet-dir="${WALLET_DIR}" \ + --init-file.validate-password + +echo "" +echo "[STARTUP] Preparing lnd auto unlock file" + +# To make sure the password can be read exactly once (by lnd itself), we create +# a named pipe. Because we can only write to such a pipe if there's a reader on +# the other end, we need to run this in a sub process in the background. +mkfifo "${WALLET_PASSWORD_FILE}" +lndinit -v load-secret \ + --source=vault \ + --vault.auth-role="${VAULT_AUTH_ROLE}" \ + --vault.kv-mount="${VAULT_KV_MOUNT}" \ + --vault.secret-path="${VAULT_SECRET_PATH}" \ + --vault.secret-key-name=walletpassword > "${WALLET_PASSWORD_FILE}" & + +# In case we want to upload the TLS certificate and macaroons to Vault once lnd +# is ready, we can use the wait-ready and store-secret commands in combination +# to wait until lnd is ready and then batch upload the files to Vault. +if [[ "${UPLOAD_RPC_SECRETS}" == "1" ]]; then + echo "" + echo "[STARTUP] Starting RPC secret uploader process in background" + lndinit -v wait-ready \ + && lndinit -v store-secret \ + --batch \ + --overwrite \ + --target=vault \ + --vault.auth-role="${VAULT_AUTH_ROLE}" \ + --vault.kv-mount="${VAULT_KV_MOUNT}" \ + --vault.secret-path="${RPC_SECRETS_PATH}" \ + "${CERT_DIR}/tls.cert" \ + "${WALLET_DIR}"/*.macaroon & +fi + +# And finally start lnd. We need to use "exec" here to make sure all signals are +# forwarded correctly. +echo "" +echo "[STARTUP] Starting lnd with flags: $@" +exec lnd "$@" diff --git a/go.mod b/go.mod index a72c527..b868772 100644 --- a/go.mod +++ b/go.mod @@ -20,7 +20,11 @@ require ( k8s.io/client-go v0.18.3 ) -require github.com/lightningnetwork/lnd/healthcheck v1.2.6 +require ( + github.com/hashicorp/vault/api v1.15.0 + github.com/hashicorp/vault/api/auth/kubernetes v0.8.0 + github.com/lightningnetwork/lnd/healthcheck v1.2.6 +) require ( dario.cat/mergo v1.0.1 // indirect @@ -43,7 +47,7 @@ require ( github.com/btcsuite/go-socks v0.0.0-20170105172521-4720035b7bfd // indirect github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 // indirect github.com/btcsuite/winsvc v1.0.0 // indirect - github.com/cenkalti/backoff/v4 v4.2.1 // indirect + github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/continuity v0.3.0 // indirect github.com/coreos/go-semver v0.3.0 // indirect @@ -58,6 +62,7 @@ require ( github.com/docker/go-connections v0.4.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect @@ -78,8 +83,15 @@ require ( github.com/grpc-ecosystem/grpc-gateway v1.16.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/hashicorp/go-rootcerts v1.0.2 // indirect + github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 // indirect + github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect + github.com/hashicorp/go-sockaddr v1.0.2 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/hashicorp/hcl v1.0.0 // indirect github.com/jackc/chunkreader/v2 v2.0.1 // indirect github.com/jackc/pgconn v1.14.3 // indirect github.com/jackc/pgerrcode v0.0.0-20240316143900-6e2875d9b438 // indirect @@ -120,6 +132,8 @@ require ( github.com/mattn/go-isatty v0.0.20 // indirect github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect github.com/miekg/dns v1.1.43 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/term v0.5.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect @@ -137,6 +151,7 @@ require ( github.com/prometheus/procfs v0.6.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/fastuuid v1.2.0 // indirect + github.com/ryanuber/go-glob v1.0.0 // indirect github.com/sirupsen/logrus v1.9.2 // indirect github.com/soheilhy/cmux v0.1.5 // indirect github.com/spf13/pflag v1.0.6 // indirect diff --git a/go.sum b/go.sum index 68ba9ca..6888531 100644 --- a/go.sum +++ b/go.sum @@ -39,10 +39,12 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/alecthomas/units v0.0.0-20190717042225-c3de453c63f4/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alecthomas/units v0.0.0-20190924025748-f65c72e2690d/go.mod h1:rBZYJk541a8SKzHPHnH3zbiI+7dagKZ0cgpgrD7Fyho= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.1.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= @@ -95,8 +97,8 @@ github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 h1:R8vQdOQdZ9Y3 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792/go.mod h1:ghJtEyQwv5/p4Mg4C0fgbePVuGr935/5ddU9Z3TmDRY= github.com/btcsuite/winsvc v1.0.0 h1:J9B4L7e3oqhXOcm+2IuNApwzQec85lE+QaikUcCs+dk= github.com/btcsuite/winsvc v1.0.0/go.mod h1:jsenWakMcC0zFBFurPLEAyrnc/teJEM1O46fmI40EZs= -github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM= -github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -159,6 +161,9 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7 github.com/envoyproxy/protoc-gen-validate v1.3.0 h1:TvGH1wof4H33rezVKWSpqKz5NXWg5VPuZ0uONDT6eb4= github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= github.com/evanphx/json-patch v4.2.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= +github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= +github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/fergusstrange/embedded-postgres v1.25.0 h1:sa+k2Ycrtz40eCRPOzI7Ry7TtkWXXJ+YRsxpKMDhxK0= @@ -171,6 +176,8 @@ github.com/fsnotify/fsnotify v1.5.4 h1:jRbGcIw6P2Meqdwuo0H1p6JVLbL5DHKAKlYndzMwV github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= +github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/kit v0.9.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= github.com/go-kit/log v0.1.0/go.mod h1:zbhenjAZHb184qTLMA9ZjW7ThYL0H2mk7Q6pNt4vbaY= @@ -190,6 +197,8 @@ github.com/go-openapi/swag v0.0.0-20160704191624-1d0bd113de87/go.mod h1:DXUve3Dp github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= +github.com/go-test/deep v1.0.2 h1:onZX1rnHT3Wv6cqNgYyFOOlgVKJrksuCMCRvJStbMYw= +github.com/go-test/deep v1.0.2/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= @@ -268,12 +277,34 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.16.0/go.mod h1:YN5jB8ie0yfIUg6VvR9K github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= +github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6 h1:om4Al8Oy7kCm/B86rLCLah4Dt5Aa0Fr5rYBG60OzwHQ= +github.com/hashicorp/go-secure-stdlib/parseutil v0.1.6/go.mod h1:QmrqtbKuxxSWTN3ETMPuB+VtEiBJ/A9XhoYGv8E1uD8= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.1/go.mod h1:gKOamz3EwoIoJq7mlMIRBpVTAUn8qPCrEclOKKWhD3U= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.2 h1:ztczhD1jLxIRjVejw8gFomI1BQZOe2WoVOu0SyteCQc= +github.com/hashicorp/go-sockaddr v1.0.2/go.mod h1:rB4wwRAUzs07qva3c5SdrY/NEtAUjGlgmH/UkBUC97A= github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= +github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= +github.com/hashicorp/vault/api v1.15.0 h1:O24FYQCWwhwKnF7CuSqP30S51rTV7vz1iACXE/pj5DA= +github.com/hashicorp/vault/api v1.15.0/go.mod h1:+5YTO09JGn0u+b6ySD/LLVf8WkJCPLAL2Vkmrn2+CM8= +github.com/hashicorp/vault/api/auth/kubernetes v0.8.0 h1:6jPcORq7OHwf+MCbaaUmiBvMhETAaZ7+i97WfZtF5kc= +github.com/hashicorp/vault/api/auth/kubernetes v0.8.0/go.mod h1:nfl5sRUUork0ZSfV3xf+pgAFQSD5kSkL0k9axg523DM= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= github.com/imdario/mergo v0.3.5/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJh5FfA= github.com/jackc/chunkreader v1.0.0/go.mod h1:RT6O25fNZIuasFJRyZ4R/Y2BbhasbmZXF9QQ7T3kePo= @@ -437,9 +468,13 @@ github.com/ltcsuite/ltcutil v0.0.0-20181217130922-17f3b04680b6/go.mod h1:8Vg/LTO github.com/lunixbochs/vtclean v0.0.0-20160125035106-4fbf7632a2c6/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI= github.com/mailru/easyjson v0.0.0-20160728113105-d5b7844b561a/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mattn/go-colorable v0.0.6/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= +github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.0-20160806122752-66b8e73f3f5c/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= +github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= @@ -449,6 +484,13 @@ github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0j github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/miekg/dns v1.1.43 h1:JKfpVSCB84vrAmHzyrsxB5NAr5kLoMXZArPSw7Qlgyg= github.com/miekg/dns v1.1.43/go.mod h1:+evo5L0630/F6ca/Z9+GAqzhjGyn8/c+TBaOyfEl0V4= +github.com/mitchellh/cli v1.0.0/go.mod h1:hNIlj7HEI86fIcpObd7a0FcrxTWetlwJDGcceTlRvqc= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-wordwrap v1.0.0/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo= +github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= @@ -502,6 +544,7 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI= github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw= github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo= github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M= @@ -531,6 +574,9 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= +github.com/ryanuber/columnize v2.1.0+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0= github.com/shopspring/decimal v0.0.0-20180709203117-cd690d0c9e24/go.mod h1:M+9NzErvs504Cn4c5DxATwIqPbtswREoFCre64PpcG4= github.com/shopspring/decimal v1.2.0 h1:abSATXmQEYyShuxI4/vyW3tV1MrKAJzCZ/0zLUXYbsQ= @@ -729,6 +775,7 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20170830134202-bb24a47a89ea/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20180823144017-11551d06cbcc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= diff --git a/main.go b/main.go index c4c2cf3..09ef985 100644 --- a/main.go +++ b/main.go @@ -21,8 +21,9 @@ const ( outputFormatRaw = "raw" outputFormatJSON = "json" - storageFile = "file" - storageK8s = "k8s" + storageFile = "file" + storageK8s = "k8s" + storageVault = "vault" errTargetExists = "target exists error" errInputMissing = "input missing error" diff --git a/vault.go b/vault.go new file mode 100644 index 0000000..9a350bc --- /dev/null +++ b/vault.go @@ -0,0 +1,240 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/hashicorp/vault/api" + auth "github.com/hashicorp/vault/api/auth/kubernetes" +) + +const ( + // defaultK8sServiceAccountTokenPath is the default location of the + // projected Kubernetes ServiceAccount token that is used to + // authenticate against Vault's Kubernetes auth method. + defaultK8sServiceAccountTokenPath = "/var/run/secrets/kubernetes.io/" + + "serviceaccount/token" + + // defaultVaultKVMount is the default mount path of the KV v2 secrets + // engine. + defaultVaultKVMount = "secret" + + // defaultVaultAuthMount is the default mount path of the Vault + // Kubernetes auth method. + defaultVaultAuthMount = "kubernetes" +) + +// vaultSecretOptions holds all the flags that are needed to connect to Vault, +// authenticate against it via the Kubernetes auth method and address a single +// entry within a KV v2 secret. It is shared by the store-secret and +// load-secret commands. +type vaultSecretOptions 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')"` + SecretKeyName string `long:"secret-key-name" description:"The name of the key/entry within the secret"` +} + +// jsonVaultObject is the subset of Vault response metadata that we surface when +// the load-secret command is asked for JSON output. +type jsonVaultObject struct { + RequestID string `json:"request_id"` + LeaseID string `json:"lease_id"` + LeaseDuration int `json:"lease_duration"` + Renewable bool `json:"renewable"` +} + +// newJSONVaultObject extracts the response metadata from a KV v2 secret so it +// can be marshalled alongside the secret's value. +func newJSONVaultObject(secret *api.KVSecret) *jsonVaultObject { + if secret == nil || secret.Raw == nil { + return &jsonVaultObject{} + } + + return &jsonVaultObject{ + RequestID: secret.Raw.RequestID, + LeaseID: secret.Raw.LeaseID, + LeaseDuration: secret.Raw.LeaseDuration, + Renewable: secret.Raw.Renewable, + } +} + +// kvMount returns the configured KV v2 mount path, falling back to the default +// if none was set. +func kvMount(opts *vaultSecretOptions) string { + if opts.KVMount == "" { + return defaultVaultKVMount + } + + return opts.KVMount +} + +// saveVault stores a single entry within a KV v2 secret. To avoid clobbering +// sibling entries in the same secret, the existing secret (if any) is read +// first and only the addressed key is updated before the merged data is written +// back. +func saveVault(content string, opts *vaultSecretOptions, overwrite bool) error { + client, err := getClientVault(opts) + if err != nil { + return err + } + + ctx := context.Background() + kv := client.KVv2(kvMount(opts)) + + // Read the current state of the secret so we can merge our entry into + // it. A not-found error just means we'll be creating the secret fresh. + data := make(map[string]interface{}) + existing, err := kv.Get(ctx, opts.SecretPath) + switch { + case err == nil && existing != nil && existing.Data != nil: + data = existing.Data + + case err == nil: + // Secret exists but has no data, we'll initialize it below. + + case errors.Is(err, api.ErrSecretNotFound): + // Secret doesn't exist yet, we'll create it below. + + default: + return fmt.Errorf("error querying secret %s in vault: %v", + opts.SecretPath, err) + } + + if data[opts.SecretKeyName] != nil && !overwrite { + return fmt.Errorf("entry %s in secret %s already exists: %v", + opts.SecretKeyName, opts.SecretPath, errTargetExists) + } + + data[opts.SecretKeyName] = content + + logger.Infof("Attempting to write entry %s of secret %s in vault", + opts.SecretKeyName, opts.SecretPath) + if _, err := kv.Put(ctx, opts.SecretPath, data); err != nil { + return fmt.Errorf("error writing secret %s in vault: %v", + opts.SecretPath, err) + } + + logger.Infof("Stored entry %s of secret %s in vault", + opts.SecretKeyName, opts.SecretPath) + + return nil +} + +// readVault reads a single entry from a KV v2 secret and returns its value +// along with the Vault response metadata. +func readVault(opts *vaultSecretOptions) (string, *jsonVaultObject, error) { + client, err := getClientVault(opts) + if err != nil { + return "", nil, err + } + + kv := client.KVv2(kvMount(opts)) + secret, err := kv.Get(context.Background(), opts.SecretPath) + switch { + case errors.Is(err, api.ErrSecretNotFound): + return "", nil, fmt.Errorf("secret %s does not exist in vault", + opts.SecretPath) + + case err != nil: + return "", nil, fmt.Errorf("error reading secret %s from "+ + "vault: %v", opts.SecretPath, err) + } + + if secret == nil || len(secret.Data) == 0 { + return "", nil, fmt.Errorf("secret %s exists but contains no "+ + "data", opts.SecretPath) + } + + entry, ok := secret.Data[opts.SecretKeyName] + if !ok || entry == nil { + return "", nil, fmt.Errorf("secret %s exists but does not "+ + "contain the entry %s", opts.SecretPath, + opts.SecretKeyName) + } + + stringEntry, isString := entry.(string) + if !isString { + return "", nil, fmt.Errorf("entry %s of secret %s is not a "+ + "string value", opts.SecretKeyName, opts.SecretPath) + } + if len(stringEntry) == 0 { + return "", nil, fmt.Errorf("secret %s exists but the entry %s "+ + "is empty", opts.SecretPath, opts.SecretKeyName) + } + + // Remove any newlines at the end of the value. We never write a newline + // ourselves, but the entry may have been provisioned by another process + // or user. + content := strings.TrimRight(stringEntry, "\r\n") + + return content, newJSONVaultObject(secret), nil +} + +// getClientVault creates a Vault client from the environment and authenticates +// it against the configured Kubernetes auth method. +func getClientVault(opts *vaultSecretOptions) (*api.Client, error) { + logger.Infof("Creating vault config from environment") + cfg := api.DefaultConfig() + if cfg.Error != nil { + return nil, fmt.Errorf("error reading vault config from env: "+ + "%v", cfg.Error) + } + + // An explicit address flag overrides whatever the environment provided. + if opts.Addr != "" { + cfg.Address = opts.Addr + } + + logger.Infof("Creating vault client for server %s", cfg.Address) + client, err := api.NewClient(cfg) + if err != nil { + return nil, fmt.Errorf("error creating vault client: %v", err) + } + + if opts.AuthRole == "" { + return nil, fmt.Errorf("the --vault.auth-role flag must be set") + } + + authMount := opts.AuthMount + if authMount == "" { + authMount = defaultVaultAuthMount + } + + tokenPath := opts.AuthTokenPath + if tokenPath == "" { + tokenPath = defaultK8sServiceAccountTokenPath + } + + logger.Infof("Authenticating against vault kubernetes auth method %s "+ + "with role %s", authMount, opts.AuthRole) + k8sAuth, err := auth.NewKubernetesAuth( + opts.AuthRole, + auth.WithMountPath(authMount), + auth.WithServiceAccountTokenPath(tokenPath), + ) + if err != nil { + return nil, fmt.Errorf("error setting up kubernetes auth: %v", + err) + } + + authInfo, err := client.Auth().Login(context.Background(), k8sAuth) + if err != nil { + return nil, fmt.Errorf("error logging into vault: %v", err) + } + if authInfo == nil || authInfo.Auth == nil { + return nil, fmt.Errorf("no authentication info returned after " + + "vault login") + } + + logger.Infof("Authenticated successfully with vault, acquired "+ + "policies %v", authInfo.Auth.Policies) + + logger.Infof("Vault client created successfully") + return client, nil +} diff --git a/vault_test.go b/vault_test.go new file mode 100644 index 0000000..55e873f --- /dev/null +++ b/vault_test.go @@ -0,0 +1,235 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +// newTestVault spins up an httptest server that emulates the subset of the +// Vault HTTP API that lndinit uses: the Kubernetes auth login endpoint and the +// KV v2 data endpoints. It returns the server URL and the backing in-memory +// store keyed by KV path. +func newTestVault(t *testing.T) (string, map[string]map[string]interface{}) { + t.Helper() + + store := make(map[string]map[string]interface{}) + + const dataPrefix = "/v1/secret/data/" + + handler := func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + // Kubernetes auth login: hand back a usable client token. + if strings.HasSuffix(r.URL.Path, "/auth/kubernetes/login") { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "auth": map[string]interface{}{ + "client_token": "test-token", + "policies": []string{"lnd"}, + "lease_duration": 3600, + "renewable": true, + }, + }) + return + } + + if !strings.HasPrefix(r.URL.Path, dataPrefix) { + w.WriteHeader(http.StatusNotFound) + return + } + + path := strings.TrimPrefix(r.URL.Path, dataPrefix) + switch r.Method { + // KV v2 read: nest the values under data.data, as Vault does. + case http.MethodGet: + data, ok := store[path] + if !ok { + w.WriteHeader(http.StatusNotFound) + _ = json.NewEncoder(w).Encode( + map[string]interface{}{ + "errors": []string{}, + }, + ) + return + } + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "request_id": "req-123", + "data": map[string]interface{}{ + "data": data, + "metadata": map[string]interface{}{ + "version": 1, + "created_time": "2020-01-01T00:00:00Z", + }, + }, + }) + + // KV v2 write: the payload wraps the values under a data key. + case http.MethodPost, http.MethodPut: + var body struct { + Data map[string]interface{} `json:"data"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + store[path] = body.Data + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "data": map[string]interface{}{ + "version": 1, + "created_time": "2020-01-01T00:00:00Z", + }, + }) + + default: + w.WriteHeader(http.StatusMethodNotAllowed) + } + } + + srv := httptest.NewServer(http.HandlerFunc(handler)) + t.Cleanup(srv.Close) + + return srv.URL, store +} + +// testVaultOptions returns a vaultSecretOptions pointed at the test server with +// a temporary ServiceAccount token file. +func testVaultOptions(t *testing.T, addr, path, key string) *vaultSecretOptions { + t.Helper() + + tokenFile := filepath.Join(t.TempDir(), "token") + require.NoError(t, os.WriteFile(tokenFile, []byte("jwt-token"), 0600)) + + return &vaultSecretOptions{ + Addr: addr, + AuthMount: defaultVaultAuthMount, + AuthRole: "lnd", + AuthTokenPath: tokenFile, + KVMount: defaultVaultKVMount, + SecretPath: path, + SecretKeyName: key, + } +} + +// TestVaultSaveAndRead verifies the round trip of storing and reading a single +// entry, and that adding a second entry does not clobber the first. +func TestVaultSaveAndRead(t *testing.T) { + addr, _ := newTestVault(t) + path := "lnd/test/wallet" + + pwOpts := testVaultOptions(t, addr, path, "walletpassword") + require.NoError(t, saveVault("supersecret", pwOpts, false)) + + content, meta, err := readVault(pwOpts) + require.NoError(t, err) + require.Equal(t, "supersecret", content) + require.NotNil(t, meta) + + // Store a second entry in the same secret and make sure the first one + // survives the merge. + seedOpts := testVaultOptions(t, addr, path, "walletseed") + require.NoError(t, saveVault("my sixteen words", seedOpts, false)) + + seed, _, err := readVault(seedOpts) + require.NoError(t, err) + require.Equal(t, "my sixteen words", seed) + + pw, _, err := readVault(pwOpts) + require.NoError(t, err) + require.Equal(t, "supersecret", pw) +} + +// TestVaultOverwriteGuard verifies that an existing entry is only replaced when +// the overwrite flag is set, and that the non-overwrite path surfaces the +// errTargetExists sentinel used for lndinit's idempotency contract. +func TestVaultOverwriteGuard(t *testing.T) { + addr, _ := newTestVault(t) + opts := testVaultOptions(t, addr, "lnd/test/wallet", "walletpassword") + + require.NoError(t, saveVault("first", opts, false)) + + // Storing again without overwrite must fail with the sentinel. + err := saveVault("second", opts, false) + require.Error(t, err) + require.Contains(t, err.Error(), errTargetExists) + + // The original value must be untouched. + content, _, err := readVault(opts) + require.NoError(t, err) + require.Equal(t, "first", content) + + // With overwrite, the value is replaced. + require.NoError(t, saveVault("second", opts, true)) + content, _, err = readVault(opts) + require.NoError(t, err) + require.Equal(t, "second", content) +} + +// TestVaultReadTrimsNewline makes sure trailing newlines on a stored value are +// stripped on read. +func TestVaultReadTrimsNewline(t *testing.T) { + addr, _ := newTestVault(t) + opts := testVaultOptions(t, addr, "lnd/test/wallet", "walletpassword") + + require.NoError(t, saveVault("value-with-newline\r\n", opts, false)) + + content, _, err := readVault(opts) + require.NoError(t, err) + require.Equal(t, "value-with-newline", content) +} + +// TestVaultReadErrors covers the not-found, missing-entry and empty-entry +// error paths. +func TestVaultReadErrors(t *testing.T) { + addr, _ := newTestVault(t) + + // Secret does not exist at all. + missing := testVaultOptions(t, addr, "lnd/missing/wallet", "walletpassword") + _, _, err := readVault(missing) + require.Error(t, err) + require.Contains(t, err.Error(), "does not exist") + + // Secret exists but the requested entry does not. + present := testVaultOptions(t, addr, "lnd/test/wallet", "walletpassword") + require.NoError(t, saveVault("supersecret", present, false)) + + wrongKey := testVaultOptions(t, addr, "lnd/test/wallet", "does-not-exist") + _, _, err = readVault(wrongKey) + require.Error(t, err) + require.Contains(t, err.Error(), "does not contain the entry") + + // Entry exists but is empty. + empty := testVaultOptions(t, addr, "lnd/test/wallet", "empty") + require.NoError(t, saveVault("", empty, false)) + _, _, err = readVault(empty) + require.Error(t, err) + require.Contains(t, err.Error(), "empty") +} + +// TestStoreSecretsVault exercises the batch store helper used by the +// store-secret command. +func TestStoreSecretsVault(t *testing.T) { + addr, _ := newTestVault(t) + opts := testVaultOptions(t, addr, "lnd/test/rpc", "") + + entries := []*entry{ + {key: "tls.cert", value: "cert-bytes"}, + {key: "admin.macaroon", value: "macaroon-bytes"}, + } + require.NoError(t, storeSecretsVault(entries, opts, false)) + + for _, e := range entries { + readOpts := testVaultOptions(t, addr, "lnd/test/rpc", e.key) + content, _, err := readVault(readOpts) + require.NoError(t, err) + require.Equal(t, e.value, content) + } + + // A missing secret path must be rejected. + badOpts := testVaultOptions(t, addr, "", "tls.cert") + require.Error(t, storeSecretsVault(entries, badOpts, false)) +}