Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94dca4e80f |
No files matched your search
@@ -129,17 +129,6 @@ one.
|
|||||||
|
|
||||||
### Secret Management
|
### Secret Management
|
||||||
|
|
||||||
#### Secret Size
|
|
||||||
|
|
||||||
While `secret` works on a secret's value it keeps the value in locked memory, so
|
|
||||||
the value counts against the locked-memory limit (`ulimit -l`). `secret` raises
|
|
||||||
that limit to the hard limit when it starts. `secret add`, `secret import` and
|
|
||||||
`secret generate secret` refuse a value that does not fit in the limit with 8
|
|
||||||
memory pages to spare (32 KiB with 4 KiB pages), with an error that names the
|
|
||||||
limit. Linux's usual limit of 8 MiB allows a value of nearly 8 MiB; a larger one
|
|
||||||
needs a higher hard limit, such as `memlock` in `/etc/security/limits.conf`. No
|
|
||||||
value may be over 100 MiB, whatever the limit.
|
|
||||||
|
|
||||||
#### `secret add <secret-name> [--force]`
|
#### `secret add <secret-name> [--force]`
|
||||||
|
|
||||||
Adds a secret to the current vault. Reads the secret value from stdin.
|
Adds a secret to the current vault. Reads the secret value from stdin.
|
||||||
@@ -302,6 +291,10 @@ Selects an unlocker as the current default for operations.
|
|||||||
Imports a secret from a file and stores it in the current vault under the given
|
Imports a secret from a file and stores it in the current vault under the given
|
||||||
name.
|
name.
|
||||||
|
|
||||||
|
#### `secret vault import [vault-name]`
|
||||||
|
|
||||||
|
Imports a mnemonic phrase into the specified vault (defaults to "default").
|
||||||
|
|
||||||
### Encryption Operations
|
### Encryption Operations
|
||||||
|
|
||||||
#### `secret encrypt <secret-name> [--input=file] [--output=file]`
|
#### `secret encrypt <secret-name> [--input=file] [--output=file]`
|
||||||
@@ -407,7 +400,9 @@ line or in a CI job, they end up in shell history and CI logs. `secret` unsets
|
|||||||
each one as soon as it has read it, so that the programs it runs itself, such as
|
each one as soon as it has read it, so that the programs it runs itself, such as
|
||||||
`gpg`, do not inherit it, but that erases nothing: the environment the process
|
`gpg`, do not inherit it, but that erases nothing: the environment the process
|
||||||
started with, and its memory, still hold the value. The interactive prompt,
|
started with, and its memory, still hold the value. The interactive prompt,
|
||||||
which every command offers when the variable is not set, is the safer default.
|
which every command except `secret vault import` offers when the variable is not
|
||||||
|
set, is the safer default; `secret vault import` has no prompt and needs both
|
||||||
|
variables.
|
||||||
|
|
||||||
The prompt asks on the terminal the command runs in, not on stdin, so it also
|
The prompt asks on the terminal the command runs in, not on stdin, so it also
|
||||||
asks when stdin is a pipe, as in `echo value | secret add name --force` or
|
asks when stdin is a pipe, as in `echo value | secret add name --force` or
|
||||||
|
|||||||
@@ -141,30 +141,6 @@ func TestVaultCreationLeavesNoSecretInEnvironment(t *testing.T) {
|
|||||||
run("vault", "create", "work")
|
run("vault", "create", "work")
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestNoVaultImportCommand is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/139: `secret vault import`
|
|
||||||
// refused every vault that `secret vault create` makes, because each has a
|
|
||||||
// long-term key. The command is gone, so cobra answers it with the help of
|
|
||||||
// `secret vault`, as it does any subcommand of `secret vault` it does not
|
|
||||||
// know.
|
|
||||||
func TestNoVaultImportCommand(t *testing.T) {
|
|
||||||
t.Setenv(secret.EnvStateDir, t.TempDir())
|
|
||||||
|
|
||||||
run := func(args ...string) string {
|
|
||||||
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
|
||||||
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
|
||||||
|
|
||||||
output, err := cli.ExecuteCommandInProcess(args, "", nil)
|
|
||||||
require.NoError(t, err, "%v", args)
|
|
||||||
|
|
||||||
return output
|
|
||||||
}
|
|
||||||
|
|
||||||
run("vault", "create", "work")
|
|
||||||
|
|
||||||
require.Equal(t, run("vault"), run("vault", "import", "work"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStopAtPassphrasePromptLeavesNothing is a regression test for the
|
// TestStopAtPassphrasePromptLeavesNothing is a regression test for the
|
||||||
// review of https://git.eeqj.de/sneak/secret/pulls/82: `secret init` or
|
// review of https://git.eeqj.de/sneak/secret/pulls/82: `secret init` or
|
||||||
// `secret vault create` stopped at the passphrase prompt left a vault with
|
// `secret vault create` stopped at the passphrase prompt left a vault with
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func newGenerateMnemonicCmd() *cobra.Command {
|
|||||||
Short: "Generate a random BIP39 mnemonic phrase",
|
Short: "Generate a random BIP39 mnemonic phrase",
|
||||||
Long: `Generate a cryptographically secure random BIP39 ` +
|
Long: `Generate a cryptographically secure random BIP39 ` +
|
||||||
`mnemonic phrase that can be used with 'secret init' ` +
|
`mnemonic phrase that can be used with 'secret init' ` +
|
||||||
`or 'secret vault create'.`,
|
`or 'secret import'.`,
|
||||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||||
cli, err := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -72,12 +72,6 @@ func newGenerateSecretCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Before readSecretEnv, which makes the first locked allocation
|
|
||||||
err = checkLockedMemoryLimit(0)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
destroySecrets := cli.readSecretEnv()
|
destroySecrets := cli.readSecretEnv()
|
||||||
defer destroySecrets()
|
defer destroySecrets()
|
||||||
|
|
||||||
@@ -121,10 +115,8 @@ func (cli *Instance) GenerateMnemonic(cmd *cobra.Command) error {
|
|||||||
" • If you lose this phrase, your secrets cannot be recovered")
|
" • If you lose this phrase, your secrets cannot be recovered")
|
||||||
fmt.Fprintln(os.Stderr, "")
|
fmt.Fprintln(os.Stderr, "")
|
||||||
fmt.Fprintln(os.Stderr, "Use this mnemonic with:")
|
fmt.Fprintln(os.Stderr, "Use this mnemonic with:")
|
||||||
fmt.Fprintln(os.Stderr,
|
fmt.Fprintln(os.Stderr, " secret init (to initialize a new secret manager)")
|
||||||
" secret init (to initialize a new secret manager)")
|
fmt.Fprintln(os.Stderr, " secret import (to import into an existing vault)")
|
||||||
fmt.Fprintln(os.Stderr,
|
|
||||||
" secret vault create <name> (to create another vault)")
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -141,16 +133,10 @@ func (cli *Instance) GenerateSecret(
|
|||||||
return errLengthTooSmall
|
return errLengthTooSmall
|
||||||
}
|
}
|
||||||
|
|
||||||
if length > maxSecretSize {
|
var (
|
||||||
return errSecretTooLarge
|
secretValue string
|
||||||
}
|
err error
|
||||||
|
)
|
||||||
err := checkLockedMemoryLimit(length)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var secretValue string
|
|
||||||
|
|
||||||
switch secretType {
|
switch secretType {
|
||||||
case "base58":
|
case "base58":
|
||||||
|
|||||||
+13
-12
@@ -18,23 +18,24 @@ type vaultStats struct {
|
|||||||
|
|
||||||
// addVersion accumulates size and timestamp info for one version directory
|
// addVersion accumulates size and timestamp info for one version directory
|
||||||
func (s *vaultStats) addVersion(fs afero.Fs, versionPath string) {
|
func (s *vaultStats) addVersion(fs afero.Fs, versionPath string) {
|
||||||
// Add the size of every file in the version directory: its value,
|
// Add size of encrypted data
|
||||||
// metadata and keys. A directory that cannot be read adds nothing and
|
dataPath := filepath.Join(versionPath, "data.age")
|
||||||
// gets a warning; its timestamp is still tracked below.
|
|
||||||
files, err := afero.ReadDir(fs, versionPath)
|
stat, err := fs.Stat(dataPath)
|
||||||
if err != nil {
|
if err == nil {
|
||||||
secret.Warn("Could not read version directory",
|
s.totalSize += stat.Size()
|
||||||
"directory", versionPath, "error", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, file := range files {
|
// Add size of metadata
|
||||||
if !file.IsDir() {
|
metaPath := filepath.Join(versionPath, "metadata.age")
|
||||||
s.totalSize += file.Size()
|
|
||||||
}
|
stat, err = fs.Stat(metaPath)
|
||||||
|
if err == nil {
|
||||||
|
s.totalSize += stat.Size()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Track timestamps
|
// Track timestamps
|
||||||
stat, err := fs.Stat(versionPath)
|
stat, err = fs.Stat(versionPath)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
modTime := stat.ModTime()
|
modTime := stat.ModTime()
|
||||||
if s.oldestTime.IsZero() || modTime.Before(s.oldestTime) {
|
if s.oldestTime.IsZero() || modTime.Before(s.oldestTime) {
|
||||||
|
|||||||
@@ -1,51 +0,0 @@
|
|||||||
//nolint:testpackage // white-box test of unexported internals
|
|
||||||
package cli
|
|
||||||
|
|
||||||
import (
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestAddVersionWarnsWhenVersionDirUnreadable asserts that a version
|
|
||||||
// directory that cannot be listed, so that its files are left out of the
|
|
||||||
// total size, gets a warning naming it and the error, and that its
|
|
||||||
// timestamp is still tracked.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // replaces os.Stderr, where secret.Warn writes
|
|
||||||
func TestAddVersionWarnsWhenVersionDirUnreadable(t *testing.T) {
|
|
||||||
versionDir := filepath.Join(testVaultDir(listTestVaultName),
|
|
||||||
unreadableTestSecretsDirName, unreadableTestSecretName,
|
|
||||||
"versions", "20260809.001")
|
|
||||||
|
|
||||||
base := afero.NewMemMapFs()
|
|
||||||
require.NoError(t, base.MkdirAll(versionDir, listTestDirPerm))
|
|
||||||
|
|
||||||
stderrReader, stderrWriter, err := os.Pipe()
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
defer func() { _ = stderrReader.Close() }()
|
|
||||||
|
|
||||||
realStderr := os.Stderr
|
|
||||||
os.Stderr = stderrWriter
|
|
||||||
|
|
||||||
var stats vaultStats
|
|
||||||
stats.addVersion(&openFailFs{Fs: base, path: versionDir}, versionDir)
|
|
||||||
|
|
||||||
os.Stderr = realStderr
|
|
||||||
|
|
||||||
require.NoError(t, stderrWriter.Close())
|
|
||||||
|
|
||||||
warning, err := io.ReadAll(stderrReader)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Contains(t, string(warning), versionDir)
|
|
||||||
assert.Contains(t, string(warning), errOpenFailed.Error())
|
|
||||||
assert.False(t, stats.latestTime.IsZero(),
|
|
||||||
"the timestamp must still be tracked")
|
|
||||||
}
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
package cli_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"crypto/rand"
|
|
||||||
"encoding/json"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
|
||||||
"github.com/spf13/cobra"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/secret/internal/cli"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestInfoTotalSizeCountsSecretValues is a regression test for
|
|
||||||
// https://git.eeqj.de/sneak/secret/issues/147, where the total size shown by
|
|
||||||
// `secret info` left out every secret's value. Adding a secret must make it
|
|
||||||
// grow by at least the size of the value.
|
|
||||||
func TestInfoTotalSizeCountsSecretValues(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const valueSize = 10000
|
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, "default",
|
|
||||||
testMnemonicBuffer(t), nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
before := infoTotalSize(t, fs)
|
|
||||||
|
|
||||||
valueBytes := make([]byte, valueSize)
|
|
||||||
_, err = rand.Read(valueBytes)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
value := memguard.NewBufferFromBytes(valueBytes)
|
|
||||||
defer value.Destroy()
|
|
||||||
|
|
||||||
require.NoError(t, vlt.AddSecret("s1", value, false))
|
|
||||||
|
|
||||||
after := infoTotalSize(t, fs)
|
|
||||||
require.GreaterOrEqual(t, after-before, int64(valueSize))
|
|
||||||
}
|
|
||||||
|
|
||||||
// infoTotalSize returns the total size that `secret info --json` reports for
|
|
||||||
// the state directory on fs.
|
|
||||||
func infoTotalSize(t *testing.T, fs afero.Fs) int64 {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var out bytes.Buffer
|
|
||||||
|
|
||||||
cmd := &cobra.Command{}
|
|
||||||
cmd.SetOut(&out)
|
|
||||||
|
|
||||||
err := cli.NewCLIInstanceWithStateDir(fs, testStateDir).Info(cmd, true)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
var info cli.InfoOutput
|
|
||||||
require.NoError(t, json.Unmarshal(out.Bytes(), &info))
|
|
||||||
|
|
||||||
return info.TotalSize
|
|
||||||
}
|
|
||||||
@@ -7,10 +7,12 @@ import (
|
|||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/secret/internal/vault"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestInvalidMnemonicError checks that every command that takes a mnemonic
|
// TestInvalidMnemonicError checks that every command that takes a mnemonic
|
||||||
// returns errInvalidMnemonicPhrase for one that is not valid BIP39.
|
// returns errInvalidMnemonicPhrase for one that is not valid BIP39. The vault
|
||||||
|
// "other" has no long-term key, as vault import needs.
|
||||||
func TestInvalidMnemonicError(t *testing.T) {
|
func TestInvalidMnemonicError(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -22,13 +24,20 @@ func TestInvalidMnemonicError(t *testing.T) {
|
|||||||
{"secret vault create work", func(c *Instance) error {
|
{"secret vault create work", func(c *Instance) error {
|
||||||
return c.CreateVault(c.cmd, "work")
|
return c.CreateVault(c.cmd, "work")
|
||||||
}},
|
}},
|
||||||
|
{"secret vault import other", func(c *Instance) error {
|
||||||
|
return c.VaultImport(c.cmd, "other")
|
||||||
|
}},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.command, func(t *testing.T) {
|
t.Run(tt.command, func(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
instance, _ := newTestInstance(afero.NewMemMapFs())
|
fs := afero.NewMemMapFs()
|
||||||
|
_, err := vault.CreateVault(fs, listTestStateDir, "other", nil, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
instance, _ := newTestInstance(fs)
|
||||||
instance.Mnemonic = memguard.NewBufferFromBytes([]byte("not a mnemonic"))
|
instance.Mnemonic = memguard.NewBufferFromBytes([]byte("not a mnemonic"))
|
||||||
t.Cleanup(instance.Mnemonic.Destroy)
|
t.Cleanup(instance.Mnemonic.Destroy)
|
||||||
|
|
||||||
|
|||||||
@@ -140,6 +140,16 @@ func TestSecretManagerIntegration(t *testing.T) {
|
|||||||
// - currentvault symlink updated to point to work
|
// - currentvault symlink updated to point to work
|
||||||
test03CreateVault(t, tempDir, runSecret)
|
test03CreateVault(t, tempDir, runSecret)
|
||||||
|
|
||||||
|
// Test 4: Import mnemonic into work vault
|
||||||
|
// Command: secret vault import work
|
||||||
|
// Purpose: Import mnemonic with passphrase unlocker
|
||||||
|
// Expected filesystem:
|
||||||
|
// - work vault has pub.age file
|
||||||
|
// - work vault has unlockers.d/passphrase directory
|
||||||
|
// - Unlocker metadata and encrypted keys present
|
||||||
|
// NOTE: Skipped because vault creation now includes mnemonic import
|
||||||
|
// test04ImportMnemonic(t, tempDir, testMnemonic, testPassphrase, runSecretWithEnv)
|
||||||
|
|
||||||
// Test 5: Add secrets with versioning
|
// Test 5: Add secrets with versioning
|
||||||
// Command: echo "password123" | secret add database/password
|
// Command: echo "password123" | secret add database/password
|
||||||
// Purpose: Add a secret and verify versioned storage
|
// Purpose: Add a secret and verify versioned storage
|
||||||
@@ -525,6 +535,65 @@ func test03CreateVault(t *testing.T, tempDir string, runSecret func(...string) (
|
|||||||
assert.Contains(t, output, "work", "should list work vault")
|
assert.Contains(t, output, "work", "should list work vault")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
//nolint:unused // TODO: re-enable when vault import is implemented
|
||||||
|
func test04ImportMnemonic(t *testing.T, tempDir, testMnemonic, testPassphrase string, runSecretWithEnv func(map[string]string, ...string) (string, error)) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Import mnemonic into work vault
|
||||||
|
output, err := runSecretWithEnv(map[string]string{
|
||||||
|
secret.EnvMnemonic: testMnemonic,
|
||||||
|
secret.EnvUnlockPassphrase: testPassphrase,
|
||||||
|
}, "vault", "import", "work")
|
||||||
|
|
||||||
|
require.NoError(t, err, "vault import should succeed")
|
||||||
|
assert.Contains(t, output, "Successfully imported mnemonic into vault 'work'", "should confirm import")
|
||||||
|
|
||||||
|
// Verify work vault now has long-term key
|
||||||
|
workVaultDir := filepath.Join(tempDir, "vaults.d", "work")
|
||||||
|
pubKeyFile := filepath.Join(workVaultDir, "pub.age")
|
||||||
|
verifyFileExists(t, pubKeyFile)
|
||||||
|
|
||||||
|
// Verify passphrase unlocker was created
|
||||||
|
currentUnlocker := readFile(t, filepath.Join(workVaultDir, "current-unlocker"))
|
||||||
|
passphraseUnlockerDir := filepath.Join(workVaultDir, "unlockers.d", string(currentUnlocker))
|
||||||
|
verifyFileExists(t, passphraseUnlockerDir)
|
||||||
|
|
||||||
|
// Check unlocker files
|
||||||
|
unlockerMetadata := filepath.Join(passphraseUnlockerDir, "unlocker-metadata.json")
|
||||||
|
verifyFileExists(t, unlockerMetadata)
|
||||||
|
|
||||||
|
encryptedLTPrivKey := filepath.Join(passphraseUnlockerDir, "priv.age")
|
||||||
|
verifyFileExists(t, encryptedLTPrivKey)
|
||||||
|
|
||||||
|
encryptedLTPubKey := filepath.Join(passphraseUnlockerDir, "pub.age")
|
||||||
|
verifyFileExists(t, encryptedLTPubKey)
|
||||||
|
|
||||||
|
longtermKeyFile := filepath.Join(passphraseUnlockerDir, "longterm.age")
|
||||||
|
verifyFileExists(t, longtermKeyFile)
|
||||||
|
|
||||||
|
// Verify vault metadata was created with derivation index
|
||||||
|
vaultMetadata := filepath.Join(workVaultDir, "vault-metadata.json")
|
||||||
|
verifyFileExists(t, vaultMetadata)
|
||||||
|
|
||||||
|
metadataBytes := readFile(t, vaultMetadata)
|
||||||
|
|
||||||
|
var metadata map[string]any
|
||||||
|
|
||||||
|
err = json.Unmarshal(metadataBytes, &metadata)
|
||||||
|
require.NoError(t, err, "vault metadata should be valid JSON")
|
||||||
|
|
||||||
|
// Work vault should have a different derivation index than default (0)
|
||||||
|
derivIndex, ok := metadata["derivationIndex"].(float64)
|
||||||
|
require.True(t, ok, "derivationIndex should be a number")
|
||||||
|
assert.NotEqual(t, float64(0), derivIndex, "work vault should have non-zero derivation index")
|
||||||
|
|
||||||
|
// Verify public key hash is stored
|
||||||
|
assert.Contains(t, metadata, "publicKeyHash", "should contain public key hash")
|
||||||
|
pubKeyHash, ok := metadata["publicKeyHash"].(string)
|
||||||
|
require.True(t, ok, "publicKeyHash should be a string")
|
||||||
|
assert.NotEmpty(t, pubKeyHash, "public key hash should not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
func test05AddSecret(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
|
func test05AddSecret(t *testing.T, tempDir, testMnemonic string, runSecret func(...string) (string, error), runSecretWithEnv func(map[string]string, ...string) (string, error), runSecretWithStdin func(string, map[string]string, ...string) (string, error)) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -1870,6 +1939,14 @@ func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string,
|
|||||||
_, err = runSecret("vault", "select", "nonexistent")
|
_, err = runSecret("vault", "select", "nonexistent")
|
||||||
require.ErrorIs(t, err, vault.ErrVaultNotFound, "select non-existent vault should fail")
|
require.ErrorIs(t, err, vault.ErrVaultNotFound, "select non-existent vault should fail")
|
||||||
|
|
||||||
|
// Import to non-existent vault with test passphrase
|
||||||
|
testPassphrase := "test-passphrase-123" // Define testPassphrase locally
|
||||||
|
_, err = runSecretWithEnv(map[string]string{
|
||||||
|
secret.EnvMnemonic: testMnemonic,
|
||||||
|
secret.EnvUnlockPassphrase: testPassphrase,
|
||||||
|
}, "vault", "import", "nonexistent")
|
||||||
|
require.ErrorIs(t, err, vault.ErrVaultNotFound, "import to non-existent vault should fail")
|
||||||
|
|
||||||
// Get specific version that doesn't exist
|
// Get specific version that doesn't exist
|
||||||
_, err = runSecretWithEnv(map[string]string{
|
_, err = runSecretWithEnv(map[string]string{
|
||||||
secret.EnvMnemonic: testMnemonic,
|
secret.EnvMnemonic: testMnemonic,
|
||||||
|
|||||||
@@ -282,10 +282,11 @@ func stateDirModTimes(t *testing.T, fs afero.Fs) map[string]int64 {
|
|||||||
|
|
||||||
// setupEveryCommand makes what each command in
|
// setupEveryCommand makes what each command in
|
||||||
// TestChangingCommandsWaitForLock needs: the current vault "work" with two
|
// TestChangingCommandsWaitForLock needs: the current vault "work" with two
|
||||||
// versions of "test/secret", the vault "other", and the file testInput.
|
// versions of "test/secret", the vault "other" without a long-term key, for
|
||||||
// There is no vault "default", which init creates. If withUnlocker is set,
|
// vault import, and the file testInput. There is no vault "default", which
|
||||||
// it also gives "work" a passphrase unlocker, which is slow. It returns the
|
// init creates. If withUnlocker is set, it also gives "work" a passphrase
|
||||||
// older version and the unlocker's ID.
|
// unlocker, which is slow. It returns the older version and the unlocker's
|
||||||
|
// ID.
|
||||||
func setupEveryCommand(
|
func setupEveryCommand(
|
||||||
t *testing.T, fs afero.Fs, withUnlocker bool,
|
t *testing.T, fs afero.Fs, withUnlocker bool,
|
||||||
) (string, string) {
|
) (string, string) {
|
||||||
@@ -293,9 +294,13 @@ func setupEveryCommand(
|
|||||||
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
|
||||||
_, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
|
other, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
otherDir, err := other.GetDirectory()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, fs.Remove(filepath.Join(otherDir, "pub.age")))
|
||||||
|
|
||||||
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic, nil)
|
vlt, err := vault.CreateVault(fs, testStateDir, "work", mnemonic, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
@@ -450,6 +455,9 @@ func TestChangingCommandsWaitForLock(t *testing.T) {
|
|||||||
{"vault select", false, func(cli *Instance, _, _ string) error {
|
{"vault select", false, func(cli *Instance, _, _ string) error {
|
||||||
return cli.SelectVault(cli.cmd, "other")
|
return cli.SelectVault(cli.cmd, "other")
|
||||||
}},
|
}},
|
||||||
|
{"vault import", false, func(cli *Instance, _, _ string) error {
|
||||||
|
return cli.VaultImport(cli.cmd, "other")
|
||||||
|
}},
|
||||||
{"vault rm --force", false, func(cli *Instance, _, _ string) error {
|
{"vault rm --force", false, func(cli *Instance, _, _ string) error {
|
||||||
return cli.RemoveVault(cli.cmd, "other", true)
|
return cli.RemoveVault(cli.cmd, "other", true)
|
||||||
}},
|
}},
|
||||||
|
|||||||
@@ -306,7 +306,8 @@ func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
|
|||||||
|
|
||||||
// TestInvalidVaultNameLeavesStateUnchanged is a regression test for
|
// TestInvalidVaultNameLeavesStateUnchanged is a regression test for
|
||||||
// https://git.eeqj.de/sneak/secret/issues/68, where
|
// https://git.eeqj.de/sneak/secret/issues/68, where
|
||||||
// `secret vault select ..` made the state directory itself the current
|
// `secret vault import ..` wrote a long-term key and an unlocker into the
|
||||||
|
// state directory itself, and `secret vault select ..` made it the current
|
||||||
// vault. Each command that takes a vault name must reject an invalid one
|
// vault. Each command that takes a vault name must reject an invalid one
|
||||||
// before building a path from it. The instance is given the mnemonic and
|
// before building a path from it. The instance is given the mnemonic and
|
||||||
// the passphrase, and moves and removals use --force, so that only the name
|
// the passphrase, and moves and removals use --force, so that only the name
|
||||||
@@ -330,6 +331,9 @@ func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) {
|
|||||||
{"vault create %q", func(c *cli.Instance, name string) error {
|
{"vault create %q", func(c *cli.Instance, name string) error {
|
||||||
return c.CreateVault(cmd, name)
|
return c.CreateVault(cmd, name)
|
||||||
}},
|
}},
|
||||||
|
{"vault import %q", func(c *cli.Instance, name string) error {
|
||||||
|
return c.VaultImport(cmd, name)
|
||||||
|
}},
|
||||||
{"vault select %q", func(c *cli.Instance, name string) error {
|
{"vault select %q", func(c *cli.Instance, name string) error {
|
||||||
return c.SelectVault(cmd, name)
|
return c.SelectVault(cmd, name)
|
||||||
}},
|
}},
|
||||||
|
|||||||
@@ -14,8 +14,6 @@ import (
|
|||||||
// every memguard buffer before it returns, so the caller must do nothing
|
// every memguard buffer before it returns, so the caller must do nothing
|
||||||
// but exit with the code.
|
// but exit with the code.
|
||||||
func Entry() int {
|
func Entry() int {
|
||||||
raiseLockedMemoryLimit()
|
|
||||||
|
|
||||||
// On SIGINT or SIGTERM memguard runs this function, wipes every buffer
|
// On SIGINT or SIGTERM memguard runs this function, wipes every buffer
|
||||||
// and exits with status 1. The passphrase prompt turns echo off on the
|
// and exits with status 1. The passphrase prompt turns echo off on the
|
||||||
// controlling terminal until the read finishes, so a signal there would
|
// controlling terminal until the read finishes, so a signal there would
|
||||||
@@ -53,19 +51,6 @@ func Entry() int {
|
|||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// raiseLockedMemoryLimit raises this process's limit on locked memory, which
|
|
||||||
// holds every secret value, to the hard limit, the most a process may set
|
|
||||||
// without privileges. When that fails the limit stays as it was, and
|
|
||||||
// checkLockedMemoryLimit names it in its error.
|
|
||||||
func raiseLockedMemoryLimit() {
|
|
||||||
var limit unix.Rlimit
|
|
||||||
|
|
||||||
if unix.Getrlimit(unix.RLIMIT_MEMLOCK, &limit) == nil {
|
|
||||||
limit.Cur = limit.Max
|
|
||||||
_ = unix.Setrlimit(unix.RLIMIT_MEMLOCK, &limit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func newRootCmd() *cobra.Command {
|
func newRootCmd() *cobra.Command {
|
||||||
secret.Debug("newRootCmd starting")
|
secret.Debug("newRootCmd starting")
|
||||||
|
|
||||||
|
|||||||
+95
-111
@@ -14,7 +14,6 @@ import (
|
|||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"golang.org/x/sys/unix"
|
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
"sneak.berlin/go/secret/internal/secret"
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
"sneak.berlin/go/secret/internal/vault"
|
||||||
)
|
)
|
||||||
@@ -25,30 +24,26 @@ const (
|
|||||||
// vaultSecretParts is the number of parts when splitting vault:secret
|
// vaultSecretParts is the number of parts when splitting vault:secret
|
||||||
vaultSecretParts = 2
|
vaultSecretParts = 2
|
||||||
|
|
||||||
|
// initialBufferSize is the starting size for secret read buffers (4KB)
|
||||||
|
initialBufferSize = 4 * 1024
|
||||||
// maxSecretSize is the maximum allowed size of a secret (100MB)
|
// maxSecretSize is the maximum allowed size of a secret (100MB)
|
||||||
maxSecretSize = 100 * 1024 * 1024
|
maxSecretSize = 100 * 1024 * 1024
|
||||||
|
|
||||||
// lockedMemoryReservePages is the locked memory, in pages, that `secret
|
|
||||||
// add`, `secret import` and `secret generate secret` may need besides
|
|
||||||
// the value they store, for keys and the read buffer: memguard locks
|
|
||||||
// whole pages, at least one for each buffer. `secret add` was measured
|
|
||||||
// to need 7 with both SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE set,
|
|
||||||
// and 6 with one; the eighth is to spare.
|
|
||||||
lockedMemoryReservePages = 8
|
|
||||||
// kibibyte is the unit of the locked-memory limit in `ulimit -l`.
|
|
||||||
kibibyte = 1024
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sentinel errors for secret operations
|
// Sentinel errors for secret operations
|
||||||
var (
|
var (
|
||||||
errSecretTooLarge = errors.New("secret too large: exceeds 100MB limit")
|
errSecretTooLarge = errors.New("secret too large: exceeds 100MB limit")
|
||||||
errLockedMemoryLimit = errors.New(
|
|
||||||
"secret too large for the locked-memory limit")
|
|
||||||
errCrossVaultSourceUnqualified = errors.New(
|
errCrossVaultSourceUnqualified = errors.New(
|
||||||
"source must specify vault (e.g., vault:secret) for cross-vault move")
|
"source must specify vault (e.g., vault:secret) for cross-vault move")
|
||||||
errMoveOntoItself = errors.New("cannot be moved onto itself")
|
errMoveOntoItself = errors.New("cannot be moved onto itself")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// bufferInfo tracks a protected buffer and the number of bytes used in it
|
||||||
|
type bufferInfo struct {
|
||||||
|
buffer *memguard.LockedBuffer
|
||||||
|
used int
|
||||||
|
}
|
||||||
|
|
||||||
// ParseVaultSecretRef parses a "vault:secret" or just "secret" reference
|
// ParseVaultSecretRef parses a "vault:secret" or just "secret" reference
|
||||||
// Returns (vaultName, secretName, isQualified)
|
// Returns (vaultName, secretName, isQualified)
|
||||||
// If no vault is specified, returns empty vaultName and isQualified=false
|
// If no vault is specified, returns empty vaultName and isQualified=false
|
||||||
@@ -80,12 +75,6 @@ func newAddCmd() *cobra.Command {
|
|||||||
|
|
||||||
cli.cmd = cmd // Set the command for stdin access
|
cli.cmd = cmd // Set the command for stdin access
|
||||||
|
|
||||||
// Before readSecretEnv, which makes the first locked allocation
|
|
||||||
err = checkLockedMemoryLimit(0)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
destroySecrets := cli.readSecretEnv()
|
destroySecrets := cli.readSecretEnv()
|
||||||
defer destroySecrets()
|
defer destroySecrets()
|
||||||
|
|
||||||
@@ -185,12 +174,6 @@ func newImportCmd() *cobra.Command {
|
|||||||
return fmt.Errorf("failed to initialize CLI: %w", err)
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Before readSecretEnv, which makes the first locked allocation
|
|
||||||
err = checkLockedMemoryLimit(0)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
destroySecrets := cli.readSecretEnv()
|
destroySecrets := cli.readSecretEnv()
|
||||||
defer destroySecrets()
|
defer destroySecrets()
|
||||||
|
|
||||||
@@ -287,105 +270,88 @@ The source secret is deleted after successful copy.`,
|
|||||||
return cmd
|
return cmd
|
||||||
}
|
}
|
||||||
|
|
||||||
// checkLockedMemoryLimit returns an error naming the locked-memory limit
|
// updateBufferSize updates the buffer size based on usage pattern
|
||||||
// (`ulimit -l`) when a value of size bytes does not fit in it with
|
func updateBufferSize(currentSize int, sameSize *int) int {
|
||||||
// lockedMemoryReservePages. memguard, which holds secret values in locked
|
const (
|
||||||
// memory, crashes or hangs the process when it cannot lock more, so a
|
doubleAfterBuffers = 2
|
||||||
// command calls this with size 0 before its first locked allocation, and
|
growthFactor = 2
|
||||||
// again with the value's size before a buffer for the value is allocated.
|
)
|
||||||
func checkLockedMemoryLimit(size int) error {
|
|
||||||
var limit unix.Rlimit
|
|
||||||
|
|
||||||
err := unix.Getrlimit(unix.RLIMIT_MEMLOCK, &limit)
|
*sameSize++
|
||||||
if err != nil {
|
if *sameSize >= doubleAfterBuffers {
|
||||||
return fmt.Errorf("failed to read the locked-memory limit: %w", err)
|
*sameSize = 0
|
||||||
|
|
||||||
|
return currentSize * growthFactor
|
||||||
}
|
}
|
||||||
|
|
||||||
// memguard locks whole pages, so the value is counted in whole pages.
|
return currentSize
|
||||||
pageSize := os.Getpagesize()
|
|
||||||
valuePages := (size + pageSize - 1) / pageSize
|
|
||||||
|
|
||||||
//nolint:gosec // G115: a page count and a page size are never negative
|
|
||||||
needed := uint64((valuePages + lockedMemoryReservePages) * pageSize)
|
|
||||||
if needed > limit.Cur {
|
|
||||||
return fmt.Errorf("%w of %d KiB (ulimit -l)",
|
|
||||||
errLockedMemoryLimit, limit.Cur/kibibyte)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// readSecretFromReader reads all of reader into a new locked buffer, which
|
// destroyBuffers destroys every buffer in the list
|
||||||
// the caller must destroy, leaving out one trailing newline when trimNewline
|
func destroyBuffers(buffers []bufferInfo) {
|
||||||
// is set. It reads a page at a time into a memguard.Stream, which keeps what
|
for _, b := range buffers {
|
||||||
// it is given encrypted, so that the value is in locked memory only once, in
|
b.buffer.Destroy()
|
||||||
// the buffer returned, and both size checks come before that buffer is
|
}
|
||||||
// allocated. It takes the value back out with Stream.Next, not Stream.Read:
|
}
|
||||||
// Read puts back the part of a piece that does not fit, and memguard hangs
|
|
||||||
// when it runs out of locked memory doing that.
|
|
||||||
func readSecretFromReader(
|
|
||||||
reader io.Reader, trimNewline bool,
|
|
||||||
) (*memguard.LockedBuffer, error) {
|
|
||||||
stream := memguard.NewStream()
|
|
||||||
|
|
||||||
chunk := memguard.NewBuffer(os.Getpagesize())
|
// readSecretFromReader reads all data from reader into protected buffers,
|
||||||
defer chunk.Destroy()
|
// enforcing the maximum secret size. On failure the accumulated buffers
|
||||||
|
// are destroyed; on success the caller must destroy them.
|
||||||
|
func readSecretFromReader(reader io.Reader) ([]bufferInfo, int, error) {
|
||||||
|
var buffers []bufferInfo
|
||||||
|
|
||||||
size := 0
|
totalSize := 0
|
||||||
endsWithNewline := false
|
currentBufferSize := initialBufferSize
|
||||||
|
sameSize := 0
|
||||||
|
|
||||||
for {
|
for {
|
||||||
n, err := io.ReadFull(reader, chunk.Bytes())
|
// Create a new buffer
|
||||||
if n > 0 {
|
buffer := memguard.NewBuffer(currentBufferSize)
|
||||||
size += n
|
n, err := io.ReadFull(reader, buffer.Bytes())
|
||||||
if size > maxSecretSize {
|
|
||||||
return nil, errSecretTooLarge
|
if n == 0 {
|
||||||
|
// No data read, destroy the unused buffer
|
||||||
|
buffer.Destroy()
|
||||||
|
} else {
|
||||||
|
buffers = append(buffers, bufferInfo{buffer: buffer, used: n})
|
||||||
|
totalSize += n
|
||||||
|
|
||||||
|
if totalSize > maxSecretSize {
|
||||||
|
destroyBuffers(buffers)
|
||||||
|
|
||||||
|
return nil, 0, errSecretTooLarge
|
||||||
}
|
}
|
||||||
|
|
||||||
endsWithNewline = chunk.Bytes()[n-1] == '\n'
|
// If we filled the buffer, consider growing for next iteration
|
||||||
|
if n == currentBufferSize {
|
||||||
// Write encrypts the bytes and wipes them from chunk.
|
currentBufferSize = updateBufferSize(currentBufferSize, &sameSize)
|
||||||
_, _ = stream.Write(chunk.Bytes()[:n])
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if errors.Is(err, io.EOF) || errors.Is(err, io.ErrUnexpectedEOF) {
|
if err == io.EOF || errors.Is(err, io.ErrUnexpectedEOF) {
|
||||||
break
|
break
|
||||||
}
|
} else if err != nil {
|
||||||
|
destroyBuffers(buffers)
|
||||||
|
|
||||||
if err != nil {
|
return nil, 0, err
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Freed before the value's buffer is allocated, so that the two are
|
return buffers, totalSize, nil
|
||||||
// never locked at once.
|
}
|
||||||
chunk.Destroy()
|
|
||||||
|
|
||||||
if trimNewline && endsWithNewline {
|
// combineBuffers copies the used portions of buffers into a single
|
||||||
size--
|
// protected buffer of totalSize bytes
|
||||||
|
func combineBuffers(buffers []bufferInfo, totalSize int) *memguard.LockedBuffer {
|
||||||
|
valueBuffer := memguard.NewBuffer(totalSize)
|
||||||
|
|
||||||
|
offset := 0
|
||||||
|
for _, b := range buffers {
|
||||||
|
copy(valueBuffer.Bytes()[offset:], b.buffer.Bytes()[:b.used])
|
||||||
|
offset += b.used
|
||||||
}
|
}
|
||||||
|
|
||||||
err := checkLockedMemoryLimit(size)
|
return valueBuffer
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
value := memguard.NewBuffer(size)
|
|
||||||
|
|
||||||
// copy stops at the value's size, so a trimmed newline is not copied.
|
|
||||||
for offset := 0; offset < size; {
|
|
||||||
decrypted, err := stream.Next()
|
|
||||||
if err != nil {
|
|
||||||
value.Destroy()
|
|
||||||
|
|
||||||
return nil, fmt.Errorf("failed to decrypt the value read: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
offset += copy(value.Bytes()[offset:], decrypted.Bytes())
|
|
||||||
decrypted.Destroy()
|
|
||||||
}
|
|
||||||
|
|
||||||
return value, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddSecret adds a secret to the current vault
|
// AddSecret adds a secret to the current vault
|
||||||
@@ -404,20 +370,34 @@ func (cli *Instance) AddSecret(secretName string, force bool) error {
|
|||||||
|
|
||||||
secret.Debug("Got current vault", "vault_name", vlt.GetName())
|
secret.Debug("Got current vault", "vault_name", vlt.GetName())
|
||||||
|
|
||||||
// Read the secret value, less a trailing newline, into locked memory
|
// Read secret value directly into protected buffers
|
||||||
secret.Debug("Reading secret value from stdin into a protected buffer")
|
secret.Debug("Reading secret value from stdin into protected buffers")
|
||||||
|
|
||||||
valueBuffer, err := readSecretFromReader(cli.cmd.InOrStdin(), true)
|
buffers, totalSize, err := readSecretFromReader(cli.cmd.InOrStdin())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, errSecretTooLarge) || errors.Is(err, errLockedMemoryLimit) {
|
if errors.Is(err, errSecretTooLarge) {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return fmt.Errorf("failed to read secret value: %w", err)
|
return fmt.Errorf("failed to read secret value: %w", err)
|
||||||
}
|
}
|
||||||
defer valueBuffer.Destroy()
|
defer destroyBuffers(buffers)
|
||||||
|
|
||||||
secret.Debug("Read secret value from stdin", "value_length", valueBuffer.Size())
|
// Check for trailing newline in the last buffer
|
||||||
|
if len(buffers) > 0 && totalSize > 0 {
|
||||||
|
lastBuffer := &buffers[len(buffers)-1]
|
||||||
|
if lastBuffer.buffer.Bytes()[lastBuffer.used-1] == '\n' {
|
||||||
|
lastBuffer.used--
|
||||||
|
totalSize--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.Debug("Read secret value from stdin",
|
||||||
|
"value_length", totalSize, "buffers", len(buffers))
|
||||||
|
|
||||||
|
// Combine all buffers into a single protected buffer
|
||||||
|
valueBuffer := combineBuffers(buffers, totalSize)
|
||||||
|
defer valueBuffer.Destroy()
|
||||||
|
|
||||||
// Locked only now that stdin has been read: in `secret encrypt key |
|
// Locked only now that stdin has been read: in `secret encrypt key |
|
||||||
// secret add name`, holding the lock while reading would leave each
|
// secret add name`, holding the lock while reading would leave each
|
||||||
@@ -687,7 +667,7 @@ func (cli *Instance) ImportSecret(
|
|||||||
|
|
||||||
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
vlt.Mnemonic, vlt.UnlockPassphrase = cli.Mnemonic, cli.UnlockPassphrase
|
||||||
|
|
||||||
// Read secret value from the source file into a protected buffer
|
// Read secret value from the source file into protected buffers
|
||||||
file, err := cli.fs.Open(sourceFile)
|
file, err := cli.fs.Open(sourceFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to open file %s: %w", sourceFile, err)
|
return fmt.Errorf("failed to open file %s: %w", sourceFile, err)
|
||||||
@@ -699,10 +679,14 @@ func (cli *Instance) ImportSecret(
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
valueBuffer, err := readSecretFromReader(file, false)
|
buffers, totalSize, err := readSecretFromReader(file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err)
|
return fmt.Errorf("failed to read secret from file %s: %w", sourceFile, err)
|
||||||
}
|
}
|
||||||
|
defer destroyBuffers(buffers)
|
||||||
|
|
||||||
|
// Combine all buffers into a single protected buffer
|
||||||
|
valueBuffer := combineBuffers(buffers, totalSize)
|
||||||
defer valueBuffer.Destroy()
|
defer valueBuffer.Destroy()
|
||||||
|
|
||||||
// Locked only now that the file has been read, as in AddSecret: the
|
// Locked only now that the file has been read, as in AddSecret: the
|
||||||
|
|||||||
@@ -3,23 +3,17 @@ package cli
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
|
||||||
"os/exec"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
"sneak.berlin/go/secret/internal/vault"
|
||||||
"sneak.berlin/go/secret/pkg/agehd"
|
"sneak.berlin/go/secret/pkg/agehd"
|
||||||
)
|
)
|
||||||
@@ -214,25 +208,23 @@ func TestImportSecretVariousSizes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAddSecretAroundPageSize tests that secret add, which reads its input
|
// TestAddSecretBufferGrowth tests that our buffer growth strategy works correctly
|
||||||
// one memory page (usually 4 KiB) at a time, stores values just under, at and
|
|
||||||
// just over one and two pages unchanged.
|
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // in parallel, size tests could exceed the memlock limit
|
//nolint:paralleltest // in parallel, size tests could exceed the memlock limit
|
||||||
func TestAddSecretAroundPageSize(t *testing.T) {
|
func TestAddSecretBufferGrowth(t *testing.T) {
|
||||||
// Sizes around one and two pages of 4 KiB, then larger ones up to 1 MiB
|
// Test various sizes that should trigger buffer growth
|
||||||
sizes := []int{
|
sizes := []int{
|
||||||
1, // Single byte
|
1, // Single byte
|
||||||
100, // Small
|
100, // Small
|
||||||
4095, // Just under 4KB
|
4095, // Just under initial 4KB
|
||||||
4096, // Exactly 4KB
|
4096, // Exactly 4KB
|
||||||
4097, // Just over 4KB
|
4097, // Just over 4KB
|
||||||
8191, // Just under 8KB
|
8191, // Just under 8KB (first double)
|
||||||
8192, // Exactly 8KB
|
8192, // Exactly 8KB
|
||||||
8193, // Just over 8KB
|
8193, // Just over 8KB
|
||||||
12288, // 12KB
|
12288, // 12KB (should trigger second double)
|
||||||
16384, // 16KB
|
16384, // 16KB
|
||||||
32768, // 32KB
|
32768, // 32KB (after more doublings)
|
||||||
65536, // 64KB
|
65536, // 64KB
|
||||||
131072, // 128KB
|
131072, // 128KB
|
||||||
524288, // 512KB
|
524288, // 512KB
|
||||||
@@ -323,144 +315,6 @@ func TestAddSecretStreamingBehavior(t *testing.T) {
|
|||||||
"Retrieved secret should match original")
|
"Retrieved secret should match original")
|
||||||
}
|
}
|
||||||
|
|
||||||
// newBinaryTestStateDir returns a new state directory holding a vault made
|
|
||||||
// from the test mnemonic, for the secret binary to use.
|
|
||||||
func newBinaryTestStateDir(t *testing.T) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
stateDir := t.TempDir()
|
|
||||||
_, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default",
|
|
||||||
testMnemonicBuffer(t), nil)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
return stateDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// runAtLockedMemoryLimit runs the secret binary with args, stdin, the state
|
|
||||||
// directory stateDir and the test mnemonic, its locked-memory limit (ulimit
|
|
||||||
// -l) lowered to limitKiB, and returns what it printed.
|
|
||||||
func runAtLockedMemoryLimit(
|
|
||||||
t *testing.T, stateDir string, limitKiB int, stdin io.Reader,
|
|
||||||
args ...string,
|
|
||||||
) (string, error) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
const commandWait = 10 * time.Second
|
|
||||||
|
|
||||||
secretPath, err := filepath.Abs("../../secret")
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// The shell lowers the limit for itself and the binary only.
|
|
||||||
script := fmt.Sprintf(`ulimit -l %d && exec "$0" "$@"`, limitKiB)
|
|
||||||
|
|
||||||
//nolint:gosec // G204: runs the freshly built secret binary
|
|
||||||
cmd := exec.CommandContext(ctx, "sh",
|
|
||||||
append([]string{"-c", script, secretPath}, args...)...)
|
|
||||||
cmd.Env = []string{
|
|
||||||
secret.EnvStateDir + "=" + stateDir,
|
|
||||||
secret.EnvMnemonic + "=" + testMnemonic,
|
|
||||||
}
|
|
||||||
cmd.Stdin = stdin
|
|
||||||
|
|
||||||
output, err := cmd.CombinedOutput()
|
|
||||||
|
|
||||||
return string(output), err
|
|
||||||
}
|
|
||||||
|
|
||||||
// requireRefusedAtLockedMemoryLimit runs the secret binary as
|
|
||||||
// runAtLockedMemoryLimit does, and requires it to exit with status 1 and
|
|
||||||
// print wantError, with no panic.
|
|
||||||
func requireRefusedAtLockedMemoryLimit(
|
|
||||||
t *testing.T, stateDir string, limitKiB int, wantError string,
|
|
||||||
stdin io.Reader, args ...string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
output, err := runAtLockedMemoryLimit(t, stateDir, limitKiB, stdin, args...)
|
|
||||||
|
|
||||||
var exitErr *exec.ExitError
|
|
||||||
|
|
||||||
require.ErrorAs(t, err, &exitErr, "%v: %s", args, output)
|
|
||||||
assert.Equal(t, 1, exitErr.ExitCode(), "%v: %s", args, output)
|
|
||||||
assert.Contains(t, output, wantError, args)
|
|
||||||
assert.NotContains(t, output, "panic", args)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSecretsAtLockedMemoryLimit runs the secret binary with the
|
|
||||||
// locked-memory limit (ulimit -l) lowered to 1 MiB, where memguard would crash
|
|
||||||
// the process with a panic instead of returning an error. add must store the
|
|
||||||
// largest value that fits, which takes holding it in locked memory only once.
|
|
||||||
// add, import and generate secret must refuse a value one byte larger with
|
|
||||||
// exit status 1 and an error saying why.
|
|
||||||
func TestSecretsAtLockedMemoryLimit(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const limitKiB = 1024
|
|
||||||
|
|
||||||
fits := limitKiB*kibibyte - lockedMemoryReservePages*os.Getpagesize()
|
|
||||||
limitError := fmt.Sprintf("locked-memory limit of %d KiB", limitKiB)
|
|
||||||
stateDir := newBinaryTestStateDir(t)
|
|
||||||
|
|
||||||
tooBigFile := filepath.Join(t.TempDir(), "too-big")
|
|
||||||
require.NoError(t, os.WriteFile(tooBigFile, make([]byte, fits+1), 0o600))
|
|
||||||
|
|
||||||
output, err := runAtLockedMemoryLimit(t, stateDir, limitKiB,
|
|
||||||
bytes.NewReader(make([]byte, fits)), "add", "fits")
|
|
||||||
require.NoError(t, err, "the largest value that fits: %s", output)
|
|
||||||
|
|
||||||
requireRefusedAtLockedMemoryLimit(t, stateDir, limitKiB, limitError,
|
|
||||||
bytes.NewReader(make([]byte, fits+1)), "add", "too-big")
|
|
||||||
requireRefusedAtLockedMemoryLimit(t, stateDir, limitKiB, limitError,
|
|
||||||
nil, "import", "too-big", "--source", tooBigFile)
|
|
||||||
requireRefusedAtLockedMemoryLimit(t, stateDir, limitKiB, limitError,
|
|
||||||
nil, "generate", "secret", "too-big", "--length", strconv.Itoa(fits+1))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSecretsUnderSmallLockedMemoryLimits runs add, import and generate
|
|
||||||
// secret with a short value under two small locked-memory limits (ulimit
|
|
||||||
// -l). Under 20 KiB, too little for the buffers they need besides the value,
|
|
||||||
// each must refuse it with exit status 1 and an error naming the limit, where
|
|
||||||
// memguard would panic or hang. Under 64 KiB, the default of older Linux
|
|
||||||
// kernels, each must store it.
|
|
||||||
func TestSecretsUnderSmallLockedMemoryLimits(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
const (
|
|
||||||
tooSmallLimitKiB = 20
|
|
||||||
oldDefaultLimitKiB = 64
|
|
||||||
shortValue = "hunter2"
|
|
||||||
)
|
|
||||||
|
|
||||||
tooSmallLimitError := fmt.Sprintf("locked-memory limit of %d KiB",
|
|
||||||
tooSmallLimitKiB)
|
|
||||||
stateDir := newBinaryTestStateDir(t)
|
|
||||||
|
|
||||||
shortFile := filepath.Join(t.TempDir(), "short")
|
|
||||||
require.NoError(t, os.WriteFile(shortFile, []byte(shortValue), 0o600))
|
|
||||||
|
|
||||||
requireRefusedAtLockedMemoryLimit(t, stateDir, tooSmallLimitKiB,
|
|
||||||
tooSmallLimitError, strings.NewReader(shortValue), "add", "added")
|
|
||||||
requireRefusedAtLockedMemoryLimit(t, stateDir, tooSmallLimitKiB,
|
|
||||||
tooSmallLimitError, nil, "import", "imported", "--source", shortFile)
|
|
||||||
requireRefusedAtLockedMemoryLimit(t, stateDir, tooSmallLimitKiB,
|
|
||||||
tooSmallLimitError, nil, "generate", "secret", "generated")
|
|
||||||
|
|
||||||
output, err := runAtLockedMemoryLimit(t, stateDir, oldDefaultLimitKiB,
|
|
||||||
strings.NewReader(shortValue), "add", "added")
|
|
||||||
require.NoError(t, err, "add: %s", output)
|
|
||||||
|
|
||||||
output, err = runAtLockedMemoryLimit(t, stateDir, oldDefaultLimitKiB,
|
|
||||||
nil, "import", "imported", "--source", shortFile)
|
|
||||||
require.NoError(t, err, "import: %s", output)
|
|
||||||
|
|
||||||
output, err = runAtLockedMemoryLimit(t, stateDir, oldDefaultLimitKiB,
|
|
||||||
nil, "generate", "secret", "generated")
|
|
||||||
require.NoError(t, err, "generate secret: %s", output)
|
|
||||||
}
|
|
||||||
|
|
||||||
// slowReader simulates a reader that returns data in small chunks
|
// slowReader simulates a reader that returns data in small chunks
|
||||||
type slowReader struct {
|
type slowReader struct {
|
||||||
data []byte
|
data []byte
|
||||||
|
|||||||
@@ -371,3 +371,19 @@ func TestRemoveSecretAbortsWhenVersionsUnreadable(t *testing.T) {
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, exists, "the secret must not be removed")
|
assert.True(t, exists, "the secret must not be removed")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestVaultImportAbortsWhenPubKeyUnreadable asserts that a mnemonic import
|
||||||
|
// stops when whether the vault already has a long-term key cannot be
|
||||||
|
// determined.
|
||||||
|
func TestVaultImportAbortsWhenPubKeyUnreadable(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
base := newListTestVault(t, 1)
|
||||||
|
instance, cmd := newTestInstance(&statFailFs{
|
||||||
|
Fs: base, path: filepath.Join(testVaultDir(listTestVaultName), "pub.age"),
|
||||||
|
})
|
||||||
|
|
||||||
|
err := instance.importMnemonic(cmd, listTestVaultName)
|
||||||
|
|
||||||
|
require.ErrorIs(t, err, errStatFailed)
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@@ -15,12 +16,19 @@ import (
|
|||||||
"sneak.berlin/go/secret/internal/bip39"
|
"sneak.berlin/go/secret/internal/bip39"
|
||||||
"sneak.berlin/go/secret/internal/secret"
|
"sneak.berlin/go/secret/internal/secret"
|
||||||
"sneak.berlin/go/secret/internal/vault"
|
"sneak.berlin/go/secret/internal/vault"
|
||||||
|
"sneak.berlin/go/secret/pkg/agehd"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Sentinel errors for vault operations
|
// Sentinel errors for vault operations
|
||||||
var (
|
var (
|
||||||
errMnemonicEmpty = errors.New("mnemonic cannot be empty")
|
errMnemonicEmpty = errors.New("mnemonic cannot be empty")
|
||||||
errInvalidMnemonicPhrase = errors.New("invalid BIP39 mnemonic phrase")
|
errInvalidMnemonicPhrase = errors.New("invalid BIP39 mnemonic phrase")
|
||||||
|
errVaultHasLongTermKey = errors.New(
|
||||||
|
"already has a long-term key configured")
|
||||||
|
errMnemonicEnvNotSet = errors.New(
|
||||||
|
"SB_SECRET_MNEMONIC environment variable not set")
|
||||||
|
errPassphraseEnvNotSet = errors.New(
|
||||||
|
"SB_UNLOCK_PASSPHRASE environment variable not set")
|
||||||
errCannotRemoveLastVault = errors.New("cannot remove the last vault")
|
errCannotRemoveLastVault = errors.New("cannot remove the last vault")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,6 +42,7 @@ func newVaultCmd() *cobra.Command {
|
|||||||
cmd.AddCommand(newVaultListCmd())
|
cmd.AddCommand(newVaultListCmd())
|
||||||
cmd.AddCommand(newVaultCreateCmd())
|
cmd.AddCommand(newVaultCreateCmd())
|
||||||
cmd.AddCommand(newVaultSelectCmd())
|
cmd.AddCommand(newVaultSelectCmd())
|
||||||
|
cmd.AddCommand(newVaultImportCmd())
|
||||||
cmd.AddCommand(newVaultRemoveCmd())
|
cmd.AddCommand(newVaultRemoveCmd())
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
@@ -102,6 +111,38 @@ func newVaultSelectCmd() *cobra.Command {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func newVaultImportCmd() *cobra.Command {
|
||||||
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to initialize CLI: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &cobra.Command{
|
||||||
|
Use: "import <vault-name>",
|
||||||
|
Short: "Import a mnemonic into a vault",
|
||||||
|
Long: `Import a BIP39 mnemonic phrase into the specified vault ` +
|
||||||
|
`(default if not specified).`,
|
||||||
|
Args: cobra.MaximumNArgs(1),
|
||||||
|
ValidArgsFunction: getVaultNamesCompletionFunc(cli.fs, cli.stateDir),
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
vaultName := "default"
|
||||||
|
if len(args) > 0 {
|
||||||
|
vaultName = args[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
cli, err := NewCLIInstance()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize CLI: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
destroySecrets := cli.readSecretEnv()
|
||||||
|
defer destroySecrets()
|
||||||
|
|
||||||
|
return cli.VaultImport(cmd, vaultName)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func newVaultRemoveCmd() *cobra.Command {
|
func newVaultRemoveCmd() *cobra.Command {
|
||||||
cli, err := NewCLIInstance()
|
cli, err := NewCLIInstance()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -301,6 +342,196 @@ func (cli *Instance) SelectVault(cmd *cobra.Command, name string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// vaultImportPreflight verifies the vault exists without a long-term key
|
||||||
|
// and returns the vault directory, public key path, and validated mnemonic
|
||||||
|
func (cli *Instance) vaultImportPreflight(
|
||||||
|
vlt *vault.Vault, vaultName string,
|
||||||
|
) (string, string, string, error) {
|
||||||
|
// Check if vault exists
|
||||||
|
vaultDir, err := vlt.GetDirectory()
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, err := afero.DirExists(cli.fs, vaultDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("failed to check if vault exists: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return "", "", "", fmt.Errorf("vault '%s' %w",
|
||||||
|
vaultName, vault.ErrVaultNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if vault already has a public key
|
||||||
|
pubKeyPath := vaultDir + "/pub.age"
|
||||||
|
|
||||||
|
exists, err = afero.Exists(cli.fs, pubKeyPath)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("failed to check %s: %w", pubKeyPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if exists {
|
||||||
|
return "", "", "", fmt.Errorf("vault '%s' %w",
|
||||||
|
vaultName, errVaultHasLongTermKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get mnemonic from environment
|
||||||
|
if cli.Mnemonic == nil {
|
||||||
|
return "", "", "", errMnemonicEnvNotSet
|
||||||
|
}
|
||||||
|
|
||||||
|
mnemonic := cli.Mnemonic.String()
|
||||||
|
|
||||||
|
// Validate the mnemonic
|
||||||
|
mnemonicWords := strings.Fields(mnemonic)
|
||||||
|
secret.Debug("Validating BIP39 mnemonic", "word_count", len(mnemonicWords))
|
||||||
|
|
||||||
|
if !bip39.IsMnemonicValid(mnemonic) {
|
||||||
|
return "", "", "", errInvalidMnemonicPhrase
|
||||||
|
}
|
||||||
|
|
||||||
|
return vaultDir, pubKeyPath, mnemonic, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateVaultImportMetadata stores the derivation info in vault metadata
|
||||||
|
func updateVaultImportMetadata(
|
||||||
|
fs afero.Fs, vaultDir string, derivationIndex uint32,
|
||||||
|
publicKeyHash, familyHash string,
|
||||||
|
) error {
|
||||||
|
// Load existing metadata
|
||||||
|
existingMetadata, err := vault.LoadVaultMetadata(fs, vaultDir)
|
||||||
|
if err != nil {
|
||||||
|
// If metadata doesn't exist, create new
|
||||||
|
existingMetadata = &vault.Metadata{
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update metadata with new derivation info
|
||||||
|
existingMetadata.DerivationIndex = derivationIndex
|
||||||
|
existingMetadata.PublicKeyHash = publicKeyHash
|
||||||
|
existingMetadata.MnemonicFamilyHash = familyHash
|
||||||
|
|
||||||
|
err = vault.SaveVaultMetadata(fs, vaultDir, existingMetadata)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to save vault metadata", "error", err)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to save vault metadata: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.Debug("Saved vault metadata with derivation index and public key hash")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VaultImport imports a mnemonic into a specific vault, holding the state
|
||||||
|
// directory lock while importMnemonic runs
|
||||||
|
func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
||||||
|
err := vault.ValidateVaultName(vaultName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer release()
|
||||||
|
|
||||||
|
return cli.importMnemonic(cmd, vaultName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// importMnemonic gives the vault a long-term key derived from the mnemonic
|
||||||
|
// and a passphrase unlocker
|
||||||
|
func (cli *Instance) importMnemonic(cmd *cobra.Command, vaultName string) error {
|
||||||
|
secret.Debug("Importing mnemonic into vault",
|
||||||
|
"vault_name", vaultName, "state_dir", cli.stateDir)
|
||||||
|
|
||||||
|
// Get the specific vault by name
|
||||||
|
vlt := vault.NewVault(cli.fs, cli.stateDir, vaultName)
|
||||||
|
|
||||||
|
vaultDir, pubKeyPath, mnemonic, err := cli.vaultImportPreflight(vlt, vaultName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the next available derivation index for this mnemonic
|
||||||
|
derivationIndex, err := vault.GetNextDerivationIndex(cli.fs, cli.stateDir, mnemonic)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to get next derivation index", "error", err)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to get next derivation index: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.Debug("Using derivation index", "index", derivationIndex)
|
||||||
|
|
||||||
|
// Derive long-term key from mnemonic with the appropriate index
|
||||||
|
secret.Debug("Deriving long-term key from mnemonic", "index", derivationIndex)
|
||||||
|
|
||||||
|
ltIdentity, err := agehd.DeriveIdentity(mnemonic, derivationIndex)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to derive long-term key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store long-term public key in vault
|
||||||
|
ltPublicKey := ltIdentity.Recipient().String()
|
||||||
|
secret.Debug("Storing long-term public key",
|
||||||
|
"pubkey", ltPublicKey, "vault_dir", vaultDir)
|
||||||
|
|
||||||
|
err = secret.WriteFileAtomic(cli.fs, pubKeyPath, []byte(ltPublicKey))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to store long-term public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate public key hash from the actual derivation index being used
|
||||||
|
// This is used to verify that the derived key matches what was stored
|
||||||
|
publicKeyHash := vault.ComputeDoubleSHA256([]byte(ltIdentity.Recipient().String()))
|
||||||
|
|
||||||
|
// Calculate family hash from index 0 (same for all vaults with this
|
||||||
|
// mnemonic). This is used to identify which vaults belong to the same
|
||||||
|
// mnemonic family.
|
||||||
|
identity0, err := agehd.DeriveIdentity(mnemonic, 0)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to derive identity for index 0: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
familyHash := vault.ComputeDoubleSHA256([]byte(identity0.Recipient().String()))
|
||||||
|
|
||||||
|
err = updateVaultImportMetadata(
|
||||||
|
cli.fs, vaultDir, derivationIndex, publicKeyHash, familyHash)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get passphrase from environment variable
|
||||||
|
passphraseBuffer := cli.UnlockPassphrase
|
||||||
|
if passphraseBuffer == nil {
|
||||||
|
return errPassphraseEnvNotSet
|
||||||
|
}
|
||||||
|
|
||||||
|
secret.Debug("Using unlock passphrase from environment variable")
|
||||||
|
|
||||||
|
// Unlock the vault with the derived long-term key
|
||||||
|
vlt.Unlock(ltIdentity)
|
||||||
|
|
||||||
|
// Create passphrase-protected unlocker
|
||||||
|
secret.Debug("Creating passphrase-protected unlocker")
|
||||||
|
|
||||||
|
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
|
||||||
|
if err != nil {
|
||||||
|
secret.Debug("Failed to create unlocker", "error", err)
|
||||||
|
|
||||||
|
return fmt.Errorf("failed to create unlocker: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd.Printf("Successfully imported mnemonic into vault '%s'\n", vaultName)
|
||||||
|
cmd.Printf("Long-term public key: %s\n", ltPublicKey)
|
||||||
|
cmd.Printf("Unlocker ID: %s\n", passphraseUnlocker.GetID())
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// countVaultSecrets returns the number of secrets in the vault directory
|
// countVaultSecrets returns the number of secrets in the vault directory
|
||||||
func (cli *Instance) countVaultSecrets(vaultDir string) (int, error) {
|
func (cli *Instance) countVaultSecrets(vaultDir string) (int, error) {
|
||||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||||
|
|||||||
@@ -240,11 +240,11 @@ func CheckNewVaultName(fs afero.Fs, stateDir string, name string) error {
|
|||||||
// CreateVault creates a new vault and selects it as the current vault. When
|
// CreateVault creates a new vault and selects it as the current vault. When
|
||||||
// mnemonic is not nil, the vault's long-term key is derived from it, and the
|
// mnemonic is not nil, the vault's long-term key is derived from it, and the
|
||||||
// returned vault has it as its Mnemonic; when it is nil, the vault has no
|
// returned vault has it as its Mnemonic; when it is nil, the vault has no
|
||||||
// long-term key. When passphrase is not nil, the vault gets a passphrase
|
// long-term key until one is imported. When passphrase is not nil, the vault
|
||||||
// unlocker protected by it, as its current unlocker; that needs a mnemonic.
|
// gets a passphrase unlocker protected by it, as its current unlocker; that
|
||||||
// It refuses an invalid name or a vault that already exists, as
|
// needs a mnemonic. It refuses an invalid name or a vault that already exists,
|
||||||
// CheckNewVaultName does, before writing anything. The commands that call it
|
// as CheckNewVaultName does, before writing anything. The commands that call
|
||||||
// hold the state directory lock, so no other command can create the vault
|
// it hold the state directory lock, so no other command can create the vault
|
||||||
// between the check and the writes.
|
// between the check and the writes.
|
||||||
//
|
//
|
||||||
// The vault is written whole into a temporary directory, which is renamed
|
// The vault is written whole into a temporary directory, which is renamed
|
||||||
|
|||||||
@@ -296,6 +296,91 @@ func TestSampleHashCalculation(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestWorkflowMismatch(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// Create a temporary directory for testing
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
fs := afero.NewOsFs()
|
||||||
|
|
||||||
|
// Test Case 1: Create vault WITH mnemonic (like init command)
|
||||||
|
_, err := vault.CreateVault(fs, tempDir, "default", testMnemonicBuffer(t), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create vault with mnemonic: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load metadata for vault1
|
||||||
|
vault1Dir := filepath.Join(tempDir, "vaults.d", "default")
|
||||||
|
|
||||||
|
metadata1, err := vault.LoadVaultMetadata(fs, vault1Dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to load vault1 metadata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Vault1 (with mnemonic) - DerivationIndex: %d, PublicKeyHash: %s",
|
||||||
|
metadata1.DerivationIndex, metadata1.PublicKeyHash)
|
||||||
|
|
||||||
|
// Test Case 2: Create vault WITHOUT mnemonic, then import (work vault)
|
||||||
|
_, err = vault.CreateVault(fs, tempDir, "work", nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create vault without mnemonic: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
vault2Dir := filepath.Join(tempDir, "vaults.d", "work")
|
||||||
|
|
||||||
|
// Simulate the vault import process: get the next available derivation
|
||||||
|
// index for this mnemonic
|
||||||
|
derivationIndex, err := vault.GetNextDerivationIndex(fs, tempDir, testMnemonic)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get next derivation index: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Next derivation index for import: %d", derivationIndex)
|
||||||
|
|
||||||
|
// Calculate public key hash from index 0 (same as in VaultImport)
|
||||||
|
identity0, err := agehd.DeriveIdentity(testMnemonic, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to derive identity for index 0: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
publicKeyHash := vault.ComputeDoubleSHA256(
|
||||||
|
[]byte(identity0.Recipient().String()))
|
||||||
|
|
||||||
|
// Load existing metadata and update it (same as in VaultImport)
|
||||||
|
existingMetadata, err := vault.LoadVaultMetadata(fs, vault2Dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to load existing metadata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update metadata with new derivation info
|
||||||
|
existingMetadata.DerivationIndex = derivationIndex
|
||||||
|
existingMetadata.PublicKeyHash = publicKeyHash
|
||||||
|
|
||||||
|
err = vault.SaveVaultMetadata(fs, vault2Dir, existingMetadata)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to save vault metadata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load updated metadata for vault2
|
||||||
|
metadata2, err := vault.LoadVaultMetadata(fs, vault2Dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to load vault2 metadata: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Vault2 (imported mnemonic) - DerivationIndex: %d, PublicKeyHash: %s",
|
||||||
|
metadata2.DerivationIndex, metadata2.PublicKeyHash)
|
||||||
|
|
||||||
|
// Verify that both vaults have the same public key hash
|
||||||
|
if metadata1.PublicKeyHash != metadata2.PublicKeyHash {
|
||||||
|
t.Errorf("Public key hashes don't match!")
|
||||||
|
t.Logf("Vault1 hash: %s", metadata1.PublicKeyHash)
|
||||||
|
t.Logf("Vault2 hash: %s", metadata2.PublicKeyHash)
|
||||||
|
} else {
|
||||||
|
t.Logf("SUCCESS: Both vaults have the same public key hash: %s",
|
||||||
|
metadata1.PublicKeyHash)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestReverseEngineerHash(t *testing.T) {
|
func TestReverseEngineerHash(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user