`secret vault import` took only a vault without a long-term key, and `secret init` and `secret vault create` give every vault one, so it always failed. A mnemonic enters through those two commands, so the command goes, with its tests and its README entry. The help and the note of `secret generate mnemonic` named `secret import`, which imports a file as a secret; they now name `secret init` and `secret vault create`. `secret vault import` now prints the help of `secret vault` and exits 0, as cobra does for any subcommand of `secret vault` it does not know. Judgement call: TestWorkflowMismatch in internal/vault, which replayed the import step by step, is deleted. Model: opus-5-5
This commit is contained in:
@@ -277,10 +277,6 @@ 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]`
|
||||||
@@ -386,9 +382,7 @@ 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 except `secret vault import` offers when the variable is not
|
which every command offers when the variable is not set, is the safer default.
|
||||||
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,6 +141,30 @@ 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 import'.`,
|
`or 'secret vault create'.`,
|
||||||
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 {
|
||||||
@@ -115,8 +115,10 @@ 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, " secret init (to initialize a new secret manager)")
|
fmt.Fprintln(os.Stderr,
|
||||||
fmt.Fprintln(os.Stderr, " secret import (to import into an existing vault)")
|
" secret init (to initialize a new secret manager)")
|
||||||
|
fmt.Fprintln(os.Stderr,
|
||||||
|
" secret vault create <name> (to create another vault)")
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,10 @@ 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. The vault
|
// returns errInvalidMnemonicPhrase for one that is not valid BIP39.
|
||||||
// "other" has no long-term key, as vault import needs.
|
|
||||||
func TestInvalidMnemonicError(t *testing.T) {
|
func TestInvalidMnemonicError(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -24,20 +22,13 @@ 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()
|
||||||
|
|
||||||
fs := afero.NewMemMapFs()
|
instance, _ := newTestInstance(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,16 +140,6 @@ 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
|
||||||
@@ -535,65 +525,6 @@ 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()
|
||||||
|
|
||||||
@@ -1939,14 +1870,6 @@ 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,11 +282,10 @@ 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" without a long-term key, for
|
// versions of "test/secret", the vault "other", and the file testInput.
|
||||||
// vault import, and the file testInput. There is no vault "default", which
|
// There is no vault "default", which init creates. If withUnlocker is set,
|
||||||
// init creates. If withUnlocker is set, it also gives "work" a passphrase
|
// it also gives "work" a passphrase unlocker, which is slow. It returns the
|
||||||
// unlocker, which is slow. It returns the older version and the unlocker's
|
// older version and the unlocker's ID.
|
||||||
// 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) {
|
||||||
@@ -294,13 +293,9 @@ func setupEveryCommand(
|
|||||||
|
|
||||||
mnemonic := testMnemonicBuffer(t)
|
mnemonic := testMnemonicBuffer(t)
|
||||||
|
|
||||||
other, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
|
_, 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)
|
||||||
|
|
||||||
@@ -455,9 +450,6 @@ 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,8 +306,7 @@ 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 import ..` wrote a long-term key and an unlocker into the
|
// `secret vault select ..` made the state directory itself the current
|
||||||
// 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
|
||||||
@@ -331,9 +330,6 @@ 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)
|
||||||
}},
|
}},
|
||||||
|
|||||||
@@ -371,19 +371,3 @@ 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,7 +8,6 @@ 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"
|
||||||
@@ -16,19 +15,12 @@ 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")
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,7 +34,6 @@ 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
|
||||||
@@ -111,38 +102,6 @@ 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 {
|
||||||
@@ -336,196 +295,6 @@ 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")
|
||||||
|
|||||||
@@ -215,11 +215,11 @@ func processMnemonicForVault(
|
|||||||
// 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 until one is imported. When passphrase is not nil, the vault
|
// long-term key. When passphrase is not nil, the vault gets a passphrase
|
||||||
// gets a passphrase unlocker protected by it, as its current unlocker; that
|
// unlocker protected by it, as its current unlocker; that needs a mnemonic.
|
||||||
// needs a mnemonic. It refuses a vault that already exists before writing
|
// It refuses a vault that already exists before writing anything: creating
|
||||||
// anything: creating it again would replace its keys, and its secrets could
|
// it again would replace its keys, and its secrets could no longer be
|
||||||
// no longer be decrypted. The commands that call it hold the state directory
|
// decrypted. The commands that call it hold the state directory
|
||||||
// lock, so no other command can create the vault between the check and the
|
// lock, so no other command can create the vault between the check and the
|
||||||
// writes.
|
// writes.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -296,91 +296,6 @@ 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