Skip to content

Commit 53c41c2

Browse files
vault: add base64 option for binary secret values
KV v2 stores entries as JSON strings, so a value that is not valid UTF-8, such as a raw macaroon, has its invalid bytes replaced with the Unicode replacement character on write and cannot be recovered. Text values like a seed, a wallet password, or a PEM certificate are unaffected, but binary values are silently corrupted. The Kubernetes backend already avoids this with its base64 option; the Vault path had no equivalent. Add a base64 option to the Vault secret flags that encodes the value on write and decodes it on read, wired into the store-secret, load-secret and init-wallet commands so a caller can store binary values safely. It is off by default, so text values are still stored verbatim.
1 parent 0874eef commit 53c41c2

3 files changed

Lines changed: 47 additions & 0 deletions

File tree

cmd_init_wallet.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ type secretSourceVault struct {
6262
SeedKeyName string `long:"seed-key-name" description:"The name of the entry within the secret that contains the seed"`
6363
SeedPassphraseKeyName string `long:"seed-passphrase-key-name" description:"The name of the entry within the secret that contains the seed passphrase"`
6464
WalletPasswordKeyName string `long:"wallet-password-key-name" description:"The name of the entry within the secret that contains the wallet password"`
65+
Base64 bool `long:"base64" description:"Decode entries as base64 when reading; must match how the secrets were stored"`
6566
}
6667

6768
func (s *secretSourceVault) options(keyName string) *vaultSecretOptions {
@@ -73,6 +74,7 @@ func (s *secretSourceVault) options(keyName string) *vaultSecretOptions {
7374
KVMount: s.KVMount,
7475
SecretPath: s.SecretPath,
7576
SecretKeyName: keyName,
77+
Base64: s.Base64,
7678
}
7779
}
7880

vault.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"context"
5+
"encoding/base64"
56
"errors"
67
"fmt"
78
"strings"
@@ -38,6 +39,7 @@ type vaultSecretOptions struct {
3839
KVMount string `long:"kv-mount" description:"The mount path of the KV v2 secrets engine the secret lives in"`
3940
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')"`
4041
SecretKeyName string `long:"secret-key-name" description:"The name of the key/entry within the secret"`
42+
Base64 bool `long:"base64" description:"Encode as base64 when storing and decode as base64 when reading; required for binary values since KV v2 stores entries as JSON strings"`
4143
}
4244

4345
// jsonVaultObject is the subset of Vault response metadata that we surface when
@@ -84,6 +86,13 @@ func saveVault(content string, opts *vaultSecretOptions, overwrite bool) error {
8486
return err
8587
}
8688

89+
// Optionally base64-encode the value so binary content survives KV v2's
90+
// JSON string storage, which would otherwise replace invalid UTF-8 bytes
91+
// with the Unicode replacement character.
92+
if opts.Base64 {
93+
content = base64.StdEncoding.EncodeToString([]byte(content))
94+
}
95+
8796
ctx := context.Background()
8897
kv := client.KVv2(kvMount(opts))
8998

@@ -196,6 +205,18 @@ func readVault(opts *vaultSecretOptions) (string, *jsonVaultObject, error) {
196205
"is empty", opts.SecretPath, opts.SecretKeyName)
197206
}
198207

208+
// Undo the optional base64 encoding applied on write to recover the
209+
// original (possibly binary) value.
210+
if opts.Base64 {
211+
decoded, err := base64.StdEncoding.DecodeString(content)
212+
if err != nil {
213+
return "", nil, fmt.Errorf("failed to base64 decode "+
214+
"entry %s of secret %s: %v", opts.SecretKeyName,
215+
opts.SecretPath, err)
216+
}
217+
content = string(decoded)
218+
}
219+
199220
return content, newJSONVaultObject(secret), nil
200221
}
201222

vault_test.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,3 +292,27 @@ func TestVaultCheckAndSet(t *testing.T) {
292292
require.NoError(t, err)
293293
require.Equal(t, "seed-a", content)
294294
}
295+
296+
// TestVaultBase64 verifies that a binary value is corrupted by KV v2's JSON
297+
// string storage when stored as-is, but round-trips intact when the base64
298+
// option is set.
299+
func TestVaultBase64(t *testing.T) {
300+
addr, _ := newTestVault(t)
301+
binary := string([]byte{0x01, 0xff, 0xfe, 0x80, 0x02})
302+
303+
// Stored as-is, the invalid UTF-8 bytes are replaced during JSON
304+
// encoding, so the value does not survive the round trip.
305+
plain := testVaultOptions(t, addr, "lnd/test/plain", "admin.macaroon")
306+
require.NoError(t, saveVault(binary, plain, false))
307+
got, _, err := readVault(plain)
308+
require.NoError(t, err)
309+
require.NotEqual(t, binary, got)
310+
311+
// With base64 the same value round-trips intact.
312+
encoded := testVaultOptions(t, addr, "lnd/test/encoded", "admin.macaroon")
313+
encoded.Base64 = true
314+
require.NoError(t, saveVault(binary, encoded, false))
315+
got, _, err = readVault(encoded)
316+
require.NoError(t, err)
317+
require.Equal(t, binary, got)
318+
}

0 commit comments

Comments
 (0)