Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d18c3d4b8 | ||
|
|
faf46d12ff | ||
|
|
6d95d96477 | ||
|
|
72cd6c30a1 | ||
|
|
46c395e045 | ||
|
|
fe94e99801 | ||
|
|
0630a32167 | ||
|
|
5371382cb4 |
@@ -150,6 +150,13 @@ Retrieves and outputs a secret value to stdout.
|
||||
|
||||
Lists all secrets in the current vault. Optional filter for substring matching.
|
||||
|
||||
The `LAST UPDATED` column, and `updated_at` in the `--json` output, give the
|
||||
time the secret's `current` file was last written: every command that adds a
|
||||
version replaces it, and so does `secret version promote`. `created_at` gives
|
||||
the time the `value.age` of the secret's oldest version was written. Both are
|
||||
the files' modification times, so listing asks for no passphrase. Moving a
|
||||
secret to another vault keeps both times.
|
||||
|
||||
#### `secret remove <secret-name> [--force]` / `secret rm` ⚠️ 🛑
|
||||
|
||||
**DANGER**: Permanently removes a secret and ALL its versions. It first asks for
|
||||
@@ -181,7 +188,9 @@ period.
|
||||
#### `secret version promote <secret-name> <version>`
|
||||
|
||||
Promotes a specific version to current by rewriting the secret's `current` file
|
||||
to name it. Does not modify any timestamps, allowing for rollback scenarios.
|
||||
to name it. The versions' own times are left unchanged, allowing for rollback
|
||||
scenarios; `secret list` then gives the time of the promote as the secret's last
|
||||
update.
|
||||
|
||||
#### `secret version remove <secret-name> <version> [--force]` / `secret version rm` ⚠️ 🛑
|
||||
|
||||
@@ -197,7 +206,8 @@ for confirmation, naming the version, the secret and its vault (see
|
||||
|
||||
#### `secret generate mnemonic`
|
||||
|
||||
Generates a cryptographically secure BIP39 mnemonic phrase.
|
||||
Generates a cryptographically secure BIP39 mnemonic phrase and prints it to
|
||||
stdout. The warning to keep it safe goes to stderr.
|
||||
|
||||
#### `secret generate secret <name> [--length=16] [--type=base58] [--force]`
|
||||
|
||||
@@ -241,6 +251,14 @@ that the vault then opens only with its mnemonic (see
|
||||
[Confirmation Before Removal](#confirmation-before-removal)). An unlocker
|
||||
directory that `secret unlocker list` skips with a warning, because its metadata
|
||||
cannot be read or parsed, is removed by the directory name the warning gives.
|
||||
Removing the current unlocker makes the first of the vault's other unlockers
|
||||
that `secret unlocker select` accepts, in the order `secret unlocker list` shows
|
||||
them, the current one, and says which; when there is none, the vault is left
|
||||
without a current unlocker. `secret unlocker select` refuses an unlocker whose
|
||||
type is not `passphrase` or `pgp`. When `current-unlocker` names an unlocker
|
||||
that does not exist and the vault has another that `secret unlocker select`
|
||||
accepts, opening the vault fails with an error that says to select one with
|
||||
`secret unlocker select`.
|
||||
|
||||
- `--force, -f`: Remove without asking, even the last unlocker
|
||||
- **CRITICAL WARNING**: Without unlockers and without your mnemonic phrase,
|
||||
@@ -259,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
|
||||
name.
|
||||
|
||||
#### `secret vault import [vault-name]`
|
||||
|
||||
Imports a mnemonic phrase into the specified vault (defaults to "default").
|
||||
|
||||
### Encryption Operations
|
||||
|
||||
#### `secret encrypt <secret-name> [--input=file] [--output=file]`
|
||||
@@ -368,9 +382,12 @@ 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
|
||||
`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,
|
||||
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.
|
||||
which every command offers when the variable is not set, is the safer default.
|
||||
|
||||
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
|
||||
`cat file | secret encrypt key`. With no controlling terminal, as under cron or
|
||||
in a CI job, the command fails and names the variable to set.
|
||||
|
||||
## Security Features
|
||||
|
||||
|
||||
@@ -18,6 +18,53 @@ https://git.eeqj.de/sneak/secret/milestone/12
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-14: A wrong passphrase, from `SB_UNLOCK_PASSPHRASE` or typed at the
|
||||
prompt, gives the error `wrong passphrase for unlocker <id>`
|
||||
(https://git.eeqj.de/sneak/secret/issues/157). It used to be age's "no
|
||||
identity matched any of the recipients", followed by advice to give the vault
|
||||
a new unlocker from its mnemonic, which would have replaced an unlocker that
|
||||
works. The error now wraps `secret.ErrWrongPassphrase` and carries no advice
|
||||
to use the mnemonic. A `priv.age` that is not an age file still gets that
|
||||
advice.
|
||||
- 2026-10-14: The passphrase and mnemonic prompts ask on the controlling
|
||||
terminal (`/dev/tty`) instead of stdin, so `echo v | secret add s1 --force`,
|
||||
`cat file | secret encrypt key` and `cat file.age | secret decrypt key` ask
|
||||
for the passphrase at a terminal instead of failing
|
||||
(https://git.eeqj.de/sneak/secret/issues/144). Only a process with no
|
||||
controlling terminal gets the error that names the variable to set. On SIGINT
|
||||
or SIGTERM, `Entry()` restores the settings of that terminal, not of stdin,
|
||||
since that is where the prompt turns echo off. `secret init` and
|
||||
`secret vault create` no longer write a newline to stderr after the mnemonic
|
||||
is typed, since the prompt ends that line on the terminal.
|
||||
- 2026-10-14: `secret generate mnemonic`, `secret vault list` and
|
||||
`secret unlocker list` print their output to stdout, as `secret list` does, so
|
||||
it can be piped or captured (https://git.eeqj.de/sneak/secret/issues/138).
|
||||
They printed with cobra's `cmd.Println` and `cmd.Printf`, which write to
|
||||
stderr when no output writer is set, so `secret vault list --json | jq`
|
||||
received nothing. "No versions found" from `secret version list` moved to
|
||||
stdout too. Messages such as "Created vault", and the warning after the
|
||||
mnemonic, stay on stderr.
|
||||
- 2026-10-14: Removing the current unlocker no longer leaves the vault unable to
|
||||
open through the unlockers it still has
|
||||
(https://git.eeqj.de/sneak/secret/issues/137). `current-unlocker` went on
|
||||
naming the removed unlocker, so every command that opened the vault failed,
|
||||
with advice to use the mnemonic. `secret unlocker remove` of the current
|
||||
unlocker now makes the first of the vault's other unlockers that
|
||||
`secret unlocker select` accepts, in the order `secret unlocker list` shows,
|
||||
current before removing it, and says so; when there is none, it also removes
|
||||
`current-unlocker`. `vault.RemoveUnlocker` returns the ID of the unlocker it
|
||||
made current. When `current-unlocker` names an unlocker that does not exist
|
||||
and the vault has another that `secret unlocker select` accepts, the error
|
||||
wraps `vault.ErrUnlockerNotFound` and says to run `secret unlocker select`,
|
||||
without the advice to use the mnemonic.
|
||||
- 2026-10-14: A `secret add --force` that cannot unlock the vault, given a wrong
|
||||
passphrase or mnemonic, leaves the secret as it was
|
||||
(https://git.eeqj.de/sneak/secret/issues/136). The new version used to be
|
||||
written before the vault was unlocked to set `notAfter` on the version it
|
||||
replaces, so each failed add left one more version that `secret version list`
|
||||
showed. The vault is now unlocked, and that version's metadata read, before
|
||||
anything is written. `generate secret --force` and `import --force` take the
|
||||
same path.
|
||||
- 2026-10-12: A mnemonic typed with extra spaces, tabs or line breaks gives the
|
||||
same long-term key as its words separated by single spaces
|
||||
(https://git.eeqj.de/sneak/secret/issues/135). The key was derived from the
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"os/exec"
|
||||
"slices"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
@@ -140,6 +141,30 @@ func TestVaultCreationLeavesNoSecretInEnvironment(t *testing.T) {
|
||||
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
|
||||
// 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
|
||||
@@ -197,10 +222,10 @@ func TestStopAtPassphrasePromptLeavesNothing(t *testing.T) {
|
||||
|
||||
// TestMnemonicNotReadNamesOnlyMnemonic is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/115: `secret init` without
|
||||
// SB_SECRET_MNEMONIC and with a stdin that is not a terminal said "failed to
|
||||
// read mnemonic: failed to read passphrase: ...". The error must wrap
|
||||
// SB_SECRET_MNEMONIC and with no terminal said "failed to read mnemonic:
|
||||
// failed to read passphrase: ...". The error must wrap
|
||||
// secret.ErrMnemonicNotRead and name the mnemonic only. The message is
|
||||
// pinned on the built binary, whose stdin is surely not a terminal.
|
||||
// pinned on the built binary, run in a new session, which has no terminal.
|
||||
func TestMnemonicNotReadNamesOnlyMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -216,14 +241,15 @@ func TestMnemonicNotReadNamesOnlyMnemonic(t *testing.T) {
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"HOME=" + os.Getenv("HOME"),
|
||||
}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
require.Error(t, err)
|
||||
|
||||
require.Equal(t, "Initialized secrets manager at: "+stateDir+"\n"+
|
||||
"Error: failed to read mnemonic: stdin is not a terminal (piped input "+
|
||||
"or script). Please set the SB_SECRET_MNEMONIC environment variable "+
|
||||
"or run interactively\n", string(output))
|
||||
"Error: failed to read mnemonic: there is no terminal to ask on. "+
|
||||
"Please set the SB_SECRET_MNEMONIC environment variable or run "+
|
||||
"interactively\n", string(output))
|
||||
}
|
||||
|
||||
// TestStopDuringCreateLeavesWholeVaultOrNone is a regression test for
|
||||
|
||||
@@ -43,7 +43,7 @@ func newGenerateMnemonicCmd() *cobra.Command {
|
||||
Short: "Generate a random BIP39 mnemonic phrase",
|
||||
Long: `Generate a cryptographically secure random BIP39 ` +
|
||||
`mnemonic phrase that can be used with 'secret init' ` +
|
||||
`or 'secret import'.`,
|
||||
`or 'secret vault create'.`,
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
@@ -103,7 +103,7 @@ func (cli *Instance) GenerateMnemonic(cmd *cobra.Command) error {
|
||||
}
|
||||
|
||||
// Output mnemonic to stdout
|
||||
cmd.Println(mnemonic)
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), mnemonic)
|
||||
|
||||
// Output helpful information to stderr
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
@@ -115,8 +115,10 @@ func (cli *Instance) GenerateMnemonic(cmd *cobra.Command) error {
|
||||
" • If you lose this phrase, your secrets cannot be recovered")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "Use this mnemonic with:")
|
||||
fmt.Fprintln(os.Stderr, " 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 init (to initialize a new secret manager)")
|
||||
fmt.Fprintln(os.Stderr,
|
||||
" secret vault create <name> (to create another vault)")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
@@ -57,13 +56,11 @@ func (cli *Instance) promptMnemonic() (*memguard.LockedBuffer, func(), error) {
|
||||
// Read mnemonic securely without echo
|
||||
mnemonicBuffer, err := secret.ReadMnemonic("Enter your BIP39 mnemonic phrase: ")
|
||||
if err != nil {
|
||||
secret.Debug("Failed to read mnemonic from stdin", "error", err)
|
||||
secret.Debug("Failed to read mnemonic", "error", err)
|
||||
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
fmt.Fprintln(os.Stderr) // Add newline after hidden input
|
||||
|
||||
return mnemonicBuffer, mnemonicBuffer.Destroy, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -7,12 +7,10 @@ import (
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// TestInvalidMnemonicError checks that every command that takes a mnemonic
|
||||
// returns errInvalidMnemonicPhrase for one that is not valid BIP39. The vault
|
||||
// "other" has no long-term key, as vault import needs.
|
||||
// returns errInvalidMnemonicPhrase for one that is not valid BIP39.
|
||||
func TestInvalidMnemonicError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -24,20 +22,13 @@ func TestInvalidMnemonicError(t *testing.T) {
|
||||
{"secret vault create work", func(c *Instance) error {
|
||||
return c.CreateVault(c.cmd, "work")
|
||||
}},
|
||||
{"secret vault import other", func(c *Instance) error {
|
||||
return c.VaultImport(c.cmd, "other")
|
||||
}},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.command, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
_, err := vault.CreateVault(fs, listTestStateDir, "other", nil, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
instance, _ := newTestInstance(fs)
|
||||
instance, _ := newTestInstance(afero.NewMemMapFs())
|
||||
instance.Mnemonic = memguard.NewBufferFromBytes([]byte("not a mnemonic"))
|
||||
t.Cleanup(instance.Mnemonic.Destroy)
|
||||
|
||||
|
||||
@@ -140,16 +140,6 @@ func TestSecretManagerIntegration(t *testing.T) {
|
||||
// - currentvault symlink updated to point to work
|
||||
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
|
||||
// Command: echo "password123" | secret add database/password
|
||||
// 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")
|
||||
}
|
||||
|
||||
//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)) {
|
||||
t.Helper()
|
||||
|
||||
@@ -1939,14 +1870,6 @@ func test23ErrorHandling(t *testing.T, tempDir, secretPath, testMnemonic string,
|
||||
_, err = runSecret("vault", "select", "nonexistent")
|
||||
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
|
||||
_, err = runSecretWithEnv(map[string]string{
|
||||
secret.EnvMnemonic: testMnemonic,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/cli"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
const (
|
||||
// listClockStep is how long TestListShowsWhenSecretWasWritten waits
|
||||
// between its steps, so that a time read in one step cannot be the
|
||||
// time read in the next.
|
||||
listClockStep = 200 * time.Millisecond
|
||||
|
||||
// fileTimeSlack allows for the clock the kernel stamps files with,
|
||||
// which advances only every few milliseconds: a file's modification
|
||||
// time can be slightly earlier than a time.Now() read before the file
|
||||
// was written.
|
||||
fileTimeSlack = 100 * time.Millisecond
|
||||
)
|
||||
|
||||
// TestListShowsWhenSecretWasWritten is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/142, where `secret list` gave
|
||||
// the time of the listing as every secret's creation and last-update time.
|
||||
// Two listings with a clock step between must be the same, and give the
|
||||
// time of the add; after a forced add, the creation time is unchanged and
|
||||
// the last update is the time of that add. It runs on a real filesystem,
|
||||
// where writing a file into a directory also changes the directory's time.
|
||||
func TestListShowsWhenSecretWasWritten(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
stateDir := t.TempDir()
|
||||
|
||||
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
||||
c := cli.NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
|
||||
beforeAdd := time.Now()
|
||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("one")), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
afterAdd := time.Now()
|
||||
|
||||
firstListing := listSecrets(t, c, true)
|
||||
|
||||
time.Sleep(listClockStep)
|
||||
require.Equal(t, firstListing, listSecrets(t, c, true))
|
||||
|
||||
added := listedTimes(t, firstListing)
|
||||
assert.WithinRange(t, added.CreatedAt, beforeAdd.Add(-fileTimeSlack), afterAdd)
|
||||
assert.WithinRange(t, added.UpdatedAt, beforeAdd.Add(-fileTimeSlack), afterAdd)
|
||||
|
||||
time.Sleep(listClockStep)
|
||||
|
||||
beforeUpdate := time.Now()
|
||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("two")), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
afterUpdate := time.Now()
|
||||
|
||||
updated := listedTimes(t, listSecrets(t, c, true))
|
||||
assert.True(t, added.CreatedAt.Equal(updated.CreatedAt),
|
||||
"created %s, then %s", added.CreatedAt, updated.CreatedAt)
|
||||
assert.WithinRange(t, updated.UpdatedAt,
|
||||
beforeUpdate.Add(-fileTimeSlack), afterUpdate)
|
||||
|
||||
// The table's LAST UPDATED column gives the same time, to the minute
|
||||
assert.Contains(t, listSecrets(t, c, false),
|
||||
updated.UpdatedAt.Format("2006-01-02 15:04"))
|
||||
}
|
||||
|
||||
// TestMovedSecretKeepsItsTimes checks that a secret moved to another vault
|
||||
// is listed there with the times it was listed with before the move, and
|
||||
// not with the time of the move.
|
||||
func TestMovedSecretKeepsItsTimes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewOsFs()
|
||||
stateDir := t.TempDir()
|
||||
|
||||
_, err := vault.CreateVault(fs, stateDir, "other", testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// "default" is created last, so it is the current vault
|
||||
vlt, err := vault.CreateVault(fs, stateDir, "default", testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
vlt.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
// Two versions written apart, so that the creation and the last update
|
||||
// are different times
|
||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("one")), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
time.Sleep(listClockStep)
|
||||
|
||||
err = vlt.AddSecret("x", memguard.NewBufferFromBytes([]byte("two")), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
c := cli.NewCLIInstanceWithStateDir(fs, stateDir)
|
||||
c.Mnemonic = testMnemonicBuffer(t)
|
||||
beforeMove := listedTimes(t, listSecrets(t, c, true))
|
||||
|
||||
time.Sleep(listClockStep)
|
||||
require.NoError(t, c.MoveSecret(&cobra.Command{}, "default:x", "other", false))
|
||||
require.NoError(t, vault.SelectVault(fs, stateDir, "other"))
|
||||
|
||||
afterMove := listedTimes(t, listSecrets(t, c, true))
|
||||
assert.True(t, beforeMove.CreatedAt.Equal(afterMove.CreatedAt),
|
||||
"created %s, then %s", beforeMove.CreatedAt, afterMove.CreatedAt)
|
||||
assert.True(t, beforeMove.UpdatedAt.Equal(afterMove.UpdatedAt),
|
||||
"updated %s, then %s", beforeMove.UpdatedAt, afterMove.UpdatedAt)
|
||||
}
|
||||
|
||||
// listSecrets returns the output of `secret list`, or of
|
||||
// `secret list --json` when jsonOutput is set.
|
||||
func listSecrets(t *testing.T, c *cli.Instance, jsonOutput bool) string {
|
||||
t.Helper()
|
||||
|
||||
var out bytes.Buffer
|
||||
|
||||
cmd := &cobra.Command{}
|
||||
cmd.SetOut(&out)
|
||||
require.NoError(t, c.ListSecrets(cmd, jsonOutput, false, ""))
|
||||
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// listedTimes returns the times that the output of `secret list --json`
|
||||
// gives for the one secret in the vault.
|
||||
func listedTimes(t *testing.T, listing string) secret.Metadata {
|
||||
t.Helper()
|
||||
|
||||
var parsed struct {
|
||||
Secrets []map[string]string `json:"secrets"`
|
||||
}
|
||||
|
||||
require.NoError(t, json.Unmarshal([]byte(listing), &parsed))
|
||||
require.Len(t, parsed.Secrets, 1)
|
||||
|
||||
createdAt, err := time.Parse(time.RFC3339Nano, parsed.Secrets[0]["created_at"])
|
||||
require.NoError(t, err)
|
||||
|
||||
updatedAt, err := time.Parse(time.RFC3339Nano, parsed.Secrets[0]["updated_at"])
|
||||
require.NoError(t, err)
|
||||
|
||||
return secret.Metadata{CreatedAt: createdAt, UpdatedAt: updatedAt}
|
||||
}
|
||||
@@ -282,11 +282,10 @@ func stateDirModTimes(t *testing.T, fs afero.Fs) map[string]int64 {
|
||||
|
||||
// setupEveryCommand makes what each command in
|
||||
// TestChangingCommandsWaitForLock needs: the current vault "work" with two
|
||||
// versions of "test/secret", the vault "other" without a long-term key, for
|
||||
// vault import, and the file testInput. There is no vault "default", which
|
||||
// init creates. If withUnlocker is set, it also gives "work" a passphrase
|
||||
// unlocker, which is slow. It returns the older version and the unlocker's
|
||||
// ID.
|
||||
// versions of "test/secret", the vault "other", and the file testInput.
|
||||
// There is no vault "default", which init creates. If withUnlocker is set,
|
||||
// it also gives "work" a passphrase unlocker, which is slow. It returns the
|
||||
// older version and the unlocker's ID.
|
||||
func setupEveryCommand(
|
||||
t *testing.T, fs afero.Fs, withUnlocker bool,
|
||||
) (string, string) {
|
||||
@@ -294,13 +293,9 @@ func setupEveryCommand(
|
||||
|
||||
mnemonic := testMnemonicBuffer(t)
|
||||
|
||||
other, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
|
||||
_, err := vault.CreateVault(fs, testStateDir, "other", mnemonic, nil)
|
||||
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)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -455,9 +450,6 @@ func TestChangingCommandsWaitForLock(t *testing.T) {
|
||||
{"vault select", false, func(cli *Instance, _, _ string) error {
|
||||
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 {
|
||||
return cli.RemoveVault(cli.cmd, "other", true)
|
||||
}},
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/creack/pty"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// TestPassphraseAskedAtTerminalWithPipedStdin is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/144: run at a terminal with stdin
|
||||
// a pipe, as in `echo v2 | secret add s1 --force`, a command that needed
|
||||
// the passphrase failed instead of asking for it. Each command here must ask
|
||||
// on the terminal and succeed once the passphrase is typed there.
|
||||
func TestPassphraseAskedAtTerminalWithPipedStdin(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stateDir := t.TempDir()
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
defer passphrase.Destroy()
|
||||
|
||||
vlt, err := vault.CreateVault(afero.NewOsFs(), stateDir, "default",
|
||||
testMnemonicBuffer(t), passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
value := memguard.NewBufferFromBytes([]byte("v1"))
|
||||
defer value.Destroy()
|
||||
|
||||
require.NoError(t, vlt.AddSecret("s1", value, false))
|
||||
|
||||
identity, err := age.GenerateX25519Identity()
|
||||
require.NoError(t, err)
|
||||
|
||||
key := secret.IdentityToLockedBuffer(identity)
|
||||
defer key.Destroy()
|
||||
|
||||
require.NoError(t, vlt.AddSecret("mykey", key, false))
|
||||
|
||||
runAtTerminal(t, stateDir, "v2\n", "add", "s1", "--force")
|
||||
|
||||
vlt.UnlockPassphrase = passphrase
|
||||
|
||||
added, err := vlt.GetSecret("s1")
|
||||
require.NoError(t, err)
|
||||
|
||||
defer added.Destroy()
|
||||
|
||||
assert.Equal(t, "v2", added.String())
|
||||
|
||||
encrypted := runAtTerminal(t, stateDir, "a document\n", "encrypt", "mykey")
|
||||
decrypted := runAtTerminal(t, stateDir, encrypted, "decrypt", "mykey")
|
||||
assert.Equal(t, "a document\n", decrypted)
|
||||
}
|
||||
|
||||
// TestInitWritesNoBlankLineAfterMnemonic runs `secret init 2>log` at a
|
||||
// terminal: the terminal is its controlling terminal and none of stdin,
|
||||
// stdout and stderr. The mnemonic is typed on the terminal and the prompt
|
||||
// ends its line there, so stderr must not get a newline for it as well, a
|
||||
// blank line in the log. pty.Open is described above
|
||||
// TestRemoveIgnoresTerminalOnStdout.
|
||||
func TestInitWritesNoBlankLineAfterMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
||||
defer cancel()
|
||||
|
||||
stateDir := t.TempDir()
|
||||
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(ctx, secretBinaryPath(t), "init")
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + stateDir,
|
||||
secret.EnvUnlockPassphrase + "=" + testPassphrase,
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"HOME=" + os.Getenv("HOME"),
|
||||
}
|
||||
|
||||
ptmx, tty, err := pty.Open()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = ptmx.Close() }()
|
||||
|
||||
deadline, _ := ctx.Deadline()
|
||||
require.NoError(t, ptmx.SetReadDeadline(deadline))
|
||||
|
||||
var stderr strings.Builder
|
||||
|
||||
cmd.Stderr = &stderr
|
||||
cmd.ExtraFiles = []*os.File{tty}
|
||||
// Ctty is a descriptor in the new process: 3, the first of ExtraFiles.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 3}
|
||||
|
||||
require.NoError(t, cmd.Start())
|
||||
|
||||
_ = tty.Close()
|
||||
|
||||
var (
|
||||
shown []byte
|
||||
char byte
|
||||
)
|
||||
|
||||
terminal := bufio.NewReader(ptmx)
|
||||
for !bytes.HasSuffix(shown, []byte("Enter your BIP39 mnemonic phrase: ")) {
|
||||
char, err = terminal.ReadByte()
|
||||
require.NoError(t, err, "secret init did not ask on the terminal: %s",
|
||||
shown)
|
||||
|
||||
shown = append(shown, char)
|
||||
}
|
||||
|
||||
_, err = ptmx.WriteString(testMnemonic + "\n")
|
||||
require.NoError(t, err)
|
||||
|
||||
// The read ends once the command has exited and so closed the terminal.
|
||||
rest, _ := io.ReadAll(terminal)
|
||||
|
||||
err = cmd.Wait()
|
||||
require.NoError(t, err, "secret init: %s%s%s", shown, rest, stderr.String())
|
||||
|
||||
assert.True(t, strings.HasPrefix(stderr.String(),
|
||||
"Initialized secrets manager at: "+stateDir+"\n\n"+
|
||||
"Default vault created and configured\n"),
|
||||
"stderr: %q", stderr.String())
|
||||
}
|
||||
|
||||
// runAtTerminal runs the secret binary with args and input on stdin, in a
|
||||
// new session whose controlling terminal is a new terminal, which is also
|
||||
// its stderr: a command typed at a terminal with its input piped. pty.Open
|
||||
// is described above TestRemoveIgnoresTerminalOnStdout. runAtTerminal waits
|
||||
// for the passphrase prompt on the terminal, types the passphrase there,
|
||||
// and returns what the command wrote to stdout once it has succeeded.
|
||||
func runAtTerminal(t *testing.T, stateDir, input string, args ...string) string {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), commandWait)
|
||||
defer cancel()
|
||||
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(ctx, secretBinaryPath(t), args...)
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + stateDir,
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"HOME=" + os.Getenv("HOME"),
|
||||
}
|
||||
|
||||
ptmx, tty, err := pty.Open()
|
||||
require.NoError(t, err)
|
||||
|
||||
defer func() { _ = ptmx.Close() }()
|
||||
|
||||
deadline, _ := ctx.Deadline()
|
||||
require.NoError(t, ptmx.SetReadDeadline(deadline))
|
||||
|
||||
var stdout strings.Builder
|
||||
|
||||
cmd.Stdin = strings.NewReader(input)
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = tty
|
||||
// Ctty is a descriptor in the new process: 2, its stderr.
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true, Setctty: true, Ctty: 2}
|
||||
|
||||
require.NoError(t, cmd.Start())
|
||||
|
||||
_ = tty.Close()
|
||||
|
||||
var (
|
||||
shown []byte
|
||||
char byte
|
||||
)
|
||||
|
||||
terminal := bufio.NewReader(ptmx)
|
||||
for !bytes.HasSuffix(shown, []byte("Enter unlock passphrase: ")) {
|
||||
char, err = terminal.ReadByte()
|
||||
require.NoError(t, err, "secret %v did not ask on the terminal: %s",
|
||||
args, shown)
|
||||
|
||||
shown = append(shown, char)
|
||||
}
|
||||
|
||||
_, err = ptmx.WriteString(testPassphrase + "\n")
|
||||
require.NoError(t, err)
|
||||
|
||||
// The read ends once the command has exited and so closed the terminal.
|
||||
rest, _ := io.ReadAll(terminal)
|
||||
|
||||
require.NoError(t, cmd.Wait(), "secret %v: %s%s", args, shown, rest)
|
||||
|
||||
return stdout.String()
|
||||
}
|
||||
@@ -306,8 +306,7 @@ func TestInvalidVersionLeavesVaultsUnchanged(t *testing.T) {
|
||||
|
||||
// TestInvalidVaultNameLeavesStateUnchanged is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/68, where
|
||||
// `secret vault import ..` wrote a long-term key and an unlocker into the
|
||||
// state directory itself, and `secret vault select ..` made it the current
|
||||
// `secret vault select ..` made the state directory itself the current
|
||||
// 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
|
||||
// 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 {
|
||||
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 {
|
||||
return c.SelectVault(cmd, name)
|
||||
}},
|
||||
|
||||
+21
-8
@@ -15,16 +15,29 @@ import (
|
||||
// but exit with the code.
|
||||
func Entry() int {
|
||||
// On SIGINT or SIGTERM memguard runs this function, wipes every buffer
|
||||
// and exits with status 1. The passphrase prompt turns terminal echo
|
||||
// off until the read finishes, so a signal there would leave echo off.
|
||||
// Only a process in the terminal's foreground process group may reset
|
||||
// it: one in the background that tries is stopped instead of exiting.
|
||||
terminalState, terminalErr := term.GetState(unix.Stdin)
|
||||
// and exits with status 1. The passphrase prompt turns echo off on the
|
||||
// controlling terminal until the read finishes, so a signal there would
|
||||
// leave echo off. Only a process in the terminal's foreground process
|
||||
// group may reset it: one in the background that tries is stopped
|
||||
// instead of exiting.
|
||||
terminal, terminalErr := os.Open(secret.TerminalPath)
|
||||
|
||||
var terminalState *term.State
|
||||
|
||||
if terminalErr == nil {
|
||||
defer func() { _ = terminal.Close() }()
|
||||
|
||||
terminalState, terminalErr = term.GetState(int(terminal.Fd()))
|
||||
}
|
||||
|
||||
memguard.CatchSignal(func(os.Signal) {
|
||||
foreground, err := unix.IoctlGetInt(unix.Stdin, unix.TIOCGPGRP)
|
||||
if terminalErr == nil && err == nil && foreground == unix.Getpgrp() {
|
||||
_ = term.Restore(unix.Stdin, terminalState)
|
||||
if terminalErr != nil {
|
||||
return
|
||||
}
|
||||
|
||||
foreground, err := unix.IoctlGetInt(int(terminal.Fd()), unix.TIOCGPGRP)
|
||||
if err == nil && foreground == unix.Getpgrp() {
|
||||
_ = term.Restore(int(terminal.Fd()), terminalState)
|
||||
}
|
||||
}, os.Interrupt, unix.SIGTERM)
|
||||
|
||||
|
||||
@@ -541,8 +541,12 @@ func printSecretsJSON(
|
||||
"name": secretName,
|
||||
}
|
||||
|
||||
// Try to get metadata using GetSecretObject
|
||||
// A secret whose times cannot be read is listed without them
|
||||
secretObj, err := vlt.GetSecretObject(secretName)
|
||||
if err == nil {
|
||||
err = secretObj.LoadMetadata()
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
metadata := secretObj.GetMetadata()
|
||||
secretInfo["created_at"] = metadata.CreatedAt
|
||||
@@ -618,6 +622,10 @@ func printSecretsTable(
|
||||
lastUpdated := "unknown"
|
||||
|
||||
secretObj, err := vlt.GetSecretObject(secretName)
|
||||
if err == nil {
|
||||
err = secretObj.LoadMetadata()
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
metadata := secretObj.GetMetadata()
|
||||
lastUpdated = metadata.UpdatedAt.Format("2006-01-02 15:04")
|
||||
|
||||
@@ -2,6 +2,7 @@ package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/bip39"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
)
|
||||
|
||||
@@ -90,3 +92,66 @@ func TestGetCommandOutputsToStdout(t *testing.T) {
|
||||
// Nothing should be in stderr
|
||||
assert.Empty(t, stderr.String(), "stderr should be empty")
|
||||
}
|
||||
|
||||
// TestMnemonicAndListingsOutputToStdout tests that 'secret generate
|
||||
// mnemonic', 'secret vault list' and 'secret unlocker list' print their
|
||||
// output to stdout, so that it can be piped
|
||||
func TestMnemonicAndListingsOutputToStdout(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tempDir := t.TempDir()
|
||||
secretPath := secretBinaryPath(t)
|
||||
|
||||
// run runs the secret binary and returns what it wrote to stdout and to
|
||||
// stderr
|
||||
run := func(args ...string) (string, string) {
|
||||
//nolint:gosec // G204: test executes the freshly built secret binary
|
||||
cmd := exec.CommandContext(t.Context(), secretPath, args...)
|
||||
cmd.Env = []string{
|
||||
secret.EnvStateDir + "=" + tempDir,
|
||||
secret.EnvMnemonic + "=" + testMnemonic,
|
||||
secret.EnvUnlockPassphrase + "=test-passphrase",
|
||||
"PATH=/usr/bin:/bin",
|
||||
}
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
err := cmd.Run()
|
||||
require.NoError(t, err, "%v should succeed: %s", args, stderr.String())
|
||||
|
||||
return stdout.String(), stderr.String()
|
||||
}
|
||||
|
||||
stdout, stderr := run("generate", "mnemonic")
|
||||
assert.True(t, bip39.IsMnemonicValid(strings.TrimSpace(stdout)),
|
||||
"stdout should hold the mnemonic, not %q", stdout)
|
||||
assert.Contains(t, stderr, "Save this mnemonic phrase securely",
|
||||
"the warning should be on stderr")
|
||||
|
||||
run("init")
|
||||
|
||||
stdout, _ = run("vault", "list", "--json")
|
||||
|
||||
var vaults map[string]any
|
||||
|
||||
require.NoError(t, json.Unmarshal([]byte(stdout), &vaults),
|
||||
"stdout should hold the vaults as JSON, not %q", stdout)
|
||||
assert.Equal(t, "default", vaults["currentVault"])
|
||||
|
||||
stdout, _ = run("unlocker", "list", "--json")
|
||||
|
||||
var unlockers map[string]any
|
||||
|
||||
require.NoError(t, json.Unmarshal([]byte(stdout), &unlockers),
|
||||
"stdout should hold the unlockers as JSON, not %q", stdout)
|
||||
assert.Len(t, unlockers["unlockers"], 1)
|
||||
|
||||
stdout, _ = run("vault", "list")
|
||||
assert.Contains(t, stdout, "default (current)")
|
||||
|
||||
stdout, _ = run("unlocker", "list")
|
||||
assert.Contains(t, stdout, "Total: 1 unlocker(s)")
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
// Unlock Failure Tests
|
||||
//
|
||||
// When a vault cannot be opened through its current unlocker, because a
|
||||
// file the unlocker needs is missing or the passphrase is wrong, the error
|
||||
// keeps its cause and ends by saying that the mnemonic still opens that
|
||||
// vault, but only for a vault that the mnemonic does open, and not when the
|
||||
// passphrase could not be read at all. When a secret's current file is
|
||||
// missing, the error says how to make a version current again. Each test
|
||||
// that pins such advice also follows it.
|
||||
// file the unlocker needs is missing, the error keeps its cause and ends by
|
||||
// saying that the mnemonic still opens that vault, but only for a vault that
|
||||
// the mnemonic does open, and not when the passphrase could not be read at
|
||||
// all. A wrong passphrase gives an error that says so, without that advice.
|
||||
// When the current unlocker does not exist and the vault has another that
|
||||
// `secret unlocker select` accepts, the error says to select one instead.
|
||||
// When a secret's current file is missing, the error says how to make a
|
||||
// version current again. Each test that pins such advice also follows it.
|
||||
|
||||
package cli_test
|
||||
|
||||
@@ -16,9 +18,9 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/spf13/cobra"
|
||||
@@ -162,26 +164,48 @@ func TestUnlockFailureNamesMnemonic(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWrongPassphraseNamesMnemonic checks the error of `secret get` given a
|
||||
// passphrase that does not decrypt the passphrase unlocker: it keeps age's
|
||||
// error and ends with the advice that the mnemonic still opens the vault.
|
||||
func TestWrongPassphraseNamesMnemonic(t *testing.T) {
|
||||
// TestWrongPassphraseNamesPassphrase checks the error of `secret get` given
|
||||
// a passphrase that does not decrypt the passphrase unlocker: it says that
|
||||
// the passphrase is wrong, without the advice to use the mnemonic, since the
|
||||
// unlocker still opens the vault with the right passphrase. The test then
|
||||
// gives the right passphrase.
|
||||
func TestWrongPassphraseNamesPassphrase(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, c := newUnlockTestCLI(t, newTwoVaultFs(t))
|
||||
unlockerDir, c := newUnlockTestCLI(t, newTwoVaultFs(t))
|
||||
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte("wrong passphrase"))
|
||||
t.Cleanup(c.UnlockPassphrase.Destroy)
|
||||
|
||||
err := c.GetSecret(discardCmd(), "x")
|
||||
|
||||
var noMatch *age.NoIdentityMatchError
|
||||
|
||||
require.ErrorAs(t, err, &noMatch)
|
||||
|
||||
require.ErrorIs(t, err, secret.ErrWrongPassphrase)
|
||||
assert.NotContains(t, err.Error(), mnemonicAdvice)
|
||||
require.EqualError(t, err, "failed to unlock vault: "+
|
||||
"failed to get long-term key: failed to get unlocker identity: "+
|
||||
"failed to decrypt unlocker private key: failed to create decryptor: "+
|
||||
noMatch.Error()+mnemonicAdvice)
|
||||
"wrong passphrase for unlocker "+filepath.Base(unlockerDir))
|
||||
|
||||
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
t.Cleanup(c.UnlockPassphrase.Destroy)
|
||||
assert.Equal(t, "value", getSecret(t, c, "x"))
|
||||
}
|
||||
|
||||
// TestDamagedUnlockerNamesMnemonic checks the error of `secret get` when the
|
||||
// passphrase unlocker's priv.age is not an age file: the passphrase is not
|
||||
// blamed, and the error ends with the advice that the mnemonic still opens
|
||||
// the vault.
|
||||
func TestDamagedUnlockerNamesMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newTwoVaultFs(t)
|
||||
unlockerDir, c := newUnlockTestCLI(t, fs)
|
||||
|
||||
require.NoError(t, afero.WriteFile(fs, filepath.Join(unlockerDir, "priv.age"),
|
||||
[]byte("damaged"), secret.FilePerms))
|
||||
|
||||
err := c.GetSecret(discardCmd(), "x")
|
||||
|
||||
require.NotErrorIs(t, err, secret.ErrWrongPassphrase)
|
||||
require.ErrorContains(t, err, mnemonicAdvice)
|
||||
}
|
||||
|
||||
// TestMoveUnlockFailureNamesVault checks the error of `secret move` into
|
||||
@@ -222,8 +246,71 @@ func TestMoveUnlockFailureNamesVault(t *testing.T) {
|
||||
assert.Equal(t, "value", getSecret(t, c, "y"))
|
||||
}
|
||||
|
||||
// TestMissingCurrentUnlockerNamesSelect checks the error of `secret get`
|
||||
// when current-unlocker names an unlocker that does not exist and the vault
|
||||
// has another: it names the missing unlocker and says to select another
|
||||
// one, without the advice to use the mnemonic. The test then follows that
|
||||
// advice.
|
||||
func TestMissingCurrentUnlockerNamesSelect(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newTwoVaultFs(t)
|
||||
unlockerDir, c := newUnlockTestCLI(t, fs)
|
||||
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(unlockTestVaultDir, "current-unlocker"),
|
||||
[]byte("missing"), secret.FilePerms))
|
||||
|
||||
err := c.GetSecret(discardCmd(), "x")
|
||||
|
||||
require.ErrorIs(t, err, vault.ErrUnlockerNotFound)
|
||||
require.EqualError(t, err, "failed to unlock vault: "+
|
||||
"failed to get long-term key: failed to get current unlocker: "+
|
||||
"unlocker with ID missing not found; the vault 'default' has other "+
|
||||
"unlockers: run 'secret unlocker select' with the ID of one of them "+
|
||||
"to make it the current unlocker")
|
||||
|
||||
require.NoError(t, c.UnlockerSelect(filepath.Base(unlockerDir)))
|
||||
assert.Equal(t, "value", getSecret(t, c, "x"))
|
||||
}
|
||||
|
||||
// TestMissingCurrentUnlockerNamesMnemonic checks the error of `secret get`
|
||||
// when the unlocker that current-unlocker names does not exist and the
|
||||
// vault's only other unlocker is the directory "odd", whose metadata names a
|
||||
// type that `secret unlocker select` refuses: the error ends with the advice
|
||||
// that the mnemonic still opens the vault, not with advice to select another
|
||||
// unlocker. The test then follows that advice.
|
||||
func TestMissingCurrentUnlockerNamesMnemonic(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := newTwoVaultFs(t)
|
||||
unlockerDir, c := newUnlockTestCLI(t, fs)
|
||||
|
||||
require.NoError(t, fs.RemoveAll(unlockerDir))
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(unlockTestVaultDir, "unlockers.d", "odd",
|
||||
"unlocker-metadata.json"),
|
||||
[]byte(`{"type": "odd"}`), secret.FilePerms))
|
||||
|
||||
err := c.GetSecret(discardCmd(), "x")
|
||||
|
||||
var cause *os.PathError
|
||||
|
||||
require.ErrorAs(t, err, &cause)
|
||||
require.NotErrorIs(t, err, vault.ErrUnlockerNotFound)
|
||||
require.EqualError(t, err, "failed to unlock vault: "+
|
||||
"failed to get long-term key: failed to get current unlocker: "+
|
||||
"failed to read unlocker metadata: "+cause.Error()+mnemonicAdvice)
|
||||
|
||||
c.Mnemonic = testMnemonicBuffer(t)
|
||||
require.NoError(t, c.UnlockersAdd("passphrase", discardCmd()))
|
||||
|
||||
c.Mnemonic = nil
|
||||
assert.Equal(t, "value", getSecret(t, c, "x"))
|
||||
}
|
||||
|
||||
// TestPassphraseNotReadNamesNoMnemonic runs `secret get x` on the built
|
||||
// binary without SB_UNLOCK_PASSPHRASE and with a stdin that is not a
|
||||
// binary without SB_UNLOCK_PASSPHRASE and in a new session, which has no
|
||||
// terminal, so the passphrase cannot be read. The unlocker was not tried,
|
||||
// and adding one would need a passphrase read the same way, so the error
|
||||
// is the cause alone, without the advice to use the mnemonic.
|
||||
@@ -254,15 +341,16 @@ func TestPassphraseNotReadNamesNoMnemonic(t *testing.T) {
|
||||
"PATH=" + os.Getenv("PATH"),
|
||||
"HOME=" + os.Getenv("HOME"),
|
||||
}
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
require.Error(t, err)
|
||||
|
||||
assert.Equal(t, "Error: failed to unlock vault: "+
|
||||
"failed to get long-term key: failed to get unlocker identity: "+
|
||||
"failed to read passphrase: stdin is not a terminal (piped input or "+
|
||||
"script). Please set the SB_UNLOCK_PASSPHRASE environment variable or "+
|
||||
"run interactively\n", string(output))
|
||||
"failed to read passphrase: there is no terminal to ask on. Please "+
|
||||
"set the SB_UNLOCK_PASSPHRASE environment variable or run "+
|
||||
"interactively\n", string(output))
|
||||
}
|
||||
|
||||
// TestCryptoUnlockFailureNamesMnemonic checks that `secret encrypt` and
|
||||
|
||||
+49
-16
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"maps"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -66,12 +67,13 @@ func getDefaultGPGKey() (string, error) {
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err == nil {
|
||||
for line := range strings.SplitSeq(string(output), "\n") {
|
||||
fields := strings.Split(line, ":")
|
||||
if len(fields) > 9 && fields[0] == "default-key" && fields[9] != "" {
|
||||
// The default key is in field 10 (index 9)
|
||||
return fields[9], nil
|
||||
}
|
||||
defaultKey, parseErr := parseDefaultGPGKey(string(output))
|
||||
if parseErr != nil {
|
||||
return "", parseErr
|
||||
}
|
||||
|
||||
if defaultKey != "" {
|
||||
return defaultKey, nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,6 +100,27 @@ func getDefaultGPGKey() (string, error) {
|
||||
return "", errNoGPGSecretKeys
|
||||
}
|
||||
|
||||
// parseDefaultGPGKey returns the default-key value in the output of
|
||||
// `gpgconf --list-options gpg`, or "" when gpg.conf sets none. gpgconf(1)
|
||||
// prints a string value with a leading `"` and percent-escaped.
|
||||
func parseDefaultGPGKey(gpgconfOutput string) (string, error) {
|
||||
for line := range strings.SplitSeq(gpgconfOutput, "\n") {
|
||||
fields := strings.Split(line, ":")
|
||||
if len(fields) > 9 && fields[0] == "default-key" && fields[9] != "" {
|
||||
// The default key is in field 10 (index 9)
|
||||
keyID, err := url.PathUnescape(strings.TrimPrefix(fields[9], `"`))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to decode gpgconf default-key %s: %w",
|
||||
fields[9], err)
|
||||
}
|
||||
|
||||
return keyID, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func newUnlockerCmd() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "unlocker",
|
||||
@@ -222,8 +245,11 @@ func newUnlockerRemoveCmd() *cobra.Command {
|
||||
Long: `Remove an unlocker from the current vault. Asks for ` +
|
||||
`confirmation first, saying whether it is the vault's last ` +
|
||||
`unlocker; when stdin is not a terminal, fails unless --force ` +
|
||||
`is given. Warning: Without unlockers and without your ` +
|
||||
`mnemonic, vault data will be permanently inaccessible.`,
|
||||
`is given. Removing the current unlocker makes another of the ` +
|
||||
`vault's unlockers current, if it has one that ` +
|
||||
`'secret unlocker select' accepts. Warning: Without ` +
|
||||
`unlockers and without your mnemonic, vault data will be ` +
|
||||
`permanently inaccessible.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
ValidArgsFunction: getUnlockerIDsCompletionFunc(cli.fs, cli.stateDir),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
@@ -323,22 +349,25 @@ func (cli *Instance) printUnlockersJSON(
|
||||
return fmt.Errorf("failed to marshal JSON: %w", err)
|
||||
}
|
||||
|
||||
cli.cmd.Println(string(jsonBytes))
|
||||
_, _ = fmt.Fprintln(cli.cmd.OutOrStdout(), string(jsonBytes))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// printUnlockersTable prints unlockers in a formatted table
|
||||
func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
||||
out := cli.cmd.OutOrStdout()
|
||||
|
||||
if len(unlockers) == 0 {
|
||||
cli.cmd.Println("No unlockers found in current vault.")
|
||||
cli.cmd.Println("Run 'secret unlocker add passphrase' to create one.")
|
||||
_, _ = fmt.Fprintln(out, "No unlockers found in current vault.")
|
||||
_, _ = fmt.Fprintln(out, "Run 'secret unlocker add passphrase' to create one.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.cmd.Printf(" %-40s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
|
||||
cli.cmd.Printf(" %-40s %-12s %-20s %s\n",
|
||||
_, _ = fmt.Fprintf(out, " %-40s %-12s %-20s %s\n",
|
||||
"UNLOCKER ID", "TYPE", "CREATED", "FLAGS")
|
||||
_, _ = fmt.Fprintf(out, " %-40s %-12s %-20s %s\n",
|
||||
strings.Repeat("-", unlockerIDWidth), strings.Repeat("-", unlockerTypeWidth),
|
||||
strings.Repeat("-", unlockerDateWidth), strings.Repeat("-", unlockerFlagsWidth))
|
||||
|
||||
@@ -353,7 +382,7 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
||||
prefix = "* "
|
||||
}
|
||||
|
||||
cli.cmd.Printf("%s%-40s %-12s %-20s %s\n",
|
||||
_, _ = fmt.Fprintf(out, "%s%-40s %-12s %-20s %s\n",
|
||||
prefix,
|
||||
unlocker.ID,
|
||||
unlocker.Type,
|
||||
@@ -361,7 +390,7 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
|
||||
flags)
|
||||
}
|
||||
|
||||
cli.cmd.Printf("\nTotal: %d unlocker(s)\n", len(unlockers))
|
||||
_, _ = fmt.Fprintf(out, "\nTotal: %d unlocker(s)\n", len(unlockers))
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -612,13 +641,17 @@ func (cli *Instance) removeUnlocker(
|
||||
"have your mnemonic phrase to access this vault again!")
|
||||
}
|
||||
|
||||
err := found.vlt.RemoveUnlocker(unlockerID)
|
||||
newCurrentID, err := found.vlt.RemoveUnlocker(unlockerID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Printf("Removed unlocker '%s'\n", unlockerID)
|
||||
|
||||
if newCurrentID != "" {
|
||||
cmd.Printf("Switched current unlocker to '%s'\n", newCurrentID)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -83,6 +85,77 @@ func TestAddPGPUnlocker(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddPGPUnlockerDefaultKey is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/141: with default-key set in
|
||||
// gpg.conf and no --keyid, `secret unlocker add pgp` uses that key.
|
||||
// gpg.conf names the key by its user ID, and the printed key ID must be that
|
||||
// user ID. The first secret key, which is used when gpg.conf sets no
|
||||
// default-key, prints as a hex key ID.
|
||||
func TestAddPGPUnlockerDefaultKey(t *testing.T) {
|
||||
newTestGPGKey(t)
|
||||
// The key comes from neither --keyid nor the environment.
|
||||
t.Setenv(secret.EnvGPGKeyID, "")
|
||||
|
||||
gpgConf := filepath.Join(os.Getenv("GNUPGHOME"), "gpg.conf")
|
||||
//nolint:gosec // G703: GNUPGHOME is the directory newTestGPGKey made
|
||||
require.NoError(t, os.WriteFile(gpgConf,
|
||||
[]byte("default-key "+unreadableTestGPGUserID+"\n"), 0o600))
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
_, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
|
||||
testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
instance, cmd := newTestInstance(fs)
|
||||
instance.Mnemonic = testMnemonicBuffer(t)
|
||||
|
||||
var output bytes.Buffer
|
||||
cmd.SetOut(&output)
|
||||
|
||||
require.NoError(t, instance.UnlockersAdd(unlockerTypePGP, cmd))
|
||||
assert.Contains(t, output.String(),
|
||||
"Using default GPG key: "+unreadableTestGPGUserID+"\n")
|
||||
}
|
||||
|
||||
// TestParseDefaultGPGKey is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/141: gpgconf prints the
|
||||
// default-key value with a leading `"` and percent-escaped, and the key ID
|
||||
// is that value decoded.
|
||||
func TestParseDefaultGPGKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// gpgconfLine is the default-key line of `gpgconf --list-options gpg`
|
||||
// up to the value, its last field.
|
||||
const gpgconfLine = "default-key:0:0:use NAME as default secret key:" +
|
||||
"1:1:NAME:::"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"fingerprint", `"56163D7F3FAFF709608855BEC1A360216D007F41`,
|
||||
"56163D7F3FAFF709608855BEC1A360216D007F41",
|
||||
},
|
||||
{
|
||||
"user ID with a comma", `"Doe%2c Alice <alice@example.com>`,
|
||||
"Doe, Alice <alice@example.com>",
|
||||
},
|
||||
{"no default-key in gpg.conf", "", ""},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
keyID, err := parseDefaultGPGKey(gpgconfLine + test.value + "\n")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, test.want, keyID)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAddPGPUnlockerUnknownKey asserts that adding a PGP unlocker for a key
|
||||
// the keyring does not hold fails at looking up the key's fingerprint and
|
||||
// leaves no new unlocker directory. The error must come from the lookup: a
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
//nolint:testpackage // white-box test of unexported internals
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
)
|
||||
|
||||
// TestUnlockerRemoveCurrent is a regression test for
|
||||
// https://git.eeqj.de/sneak/secret/issues/137. A vault has a passphrase
|
||||
// unlocker and a PGP unlocker, the current one. Removing the PGP unlocker
|
||||
// makes the passphrase unlocker current and says so, and the vault then
|
||||
// opens through it. Removing the passphrase unlocker too removes
|
||||
// current-unlocker.
|
||||
//
|
||||
// The second case also gives the vault the unlocker directory "odd", whose
|
||||
// metadata names a type that `secret unlocker select` refuses, and which
|
||||
// sorts before the passphrase unlocker: neither removal makes it current.
|
||||
//
|
||||
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
|
||||
func TestUnlockerRemoveCurrent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
withOdd bool
|
||||
}{
|
||||
{name: "two unlockers", withOdd: false},
|
||||
{name: "and the directory odd", withOdd: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
newTestGPGKey(t)
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
t.Cleanup(passphrase.Destroy)
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
vlt, err := vault.CreateVault(fs, listTestStateDir,
|
||||
listTestVaultName, testMnemonicBuffer(t), nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
if tt.withOdd {
|
||||
require.NoError(t, afero.WriteFile(fs,
|
||||
filepath.Join(testVaultDir(listTestVaultName), "unlockers.d",
|
||||
"odd", "unlocker-metadata.json"),
|
||||
[]byte(`{"type": "odd"}`), secret.FilePerms))
|
||||
}
|
||||
|
||||
instance, cmd := newTestInstance(fs)
|
||||
instance.UnlockPassphrase = passphrase
|
||||
|
||||
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
|
||||
require.NoError(t, instance.UnlockersAdd(unlockerTypePGP, cmd))
|
||||
|
||||
pgpUnlocker, err := vlt.GetCurrentUnlocker()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, unlockerTypePGP, pgpUnlocker.GetType())
|
||||
|
||||
var output bytes.Buffer
|
||||
|
||||
cmd.SetOut(&output)
|
||||
require.NoError(t,
|
||||
instance.UnlockersRemove(pgpUnlocker.GetID(), true, cmd))
|
||||
assert.Contains(t, output.String(),
|
||||
"Switched current unlocker to '"+passphraseUnlocker.GetID()+"'")
|
||||
|
||||
reopened := vault.NewVault(fs, listTestStateDir, listTestVaultName)
|
||||
reopened.UnlockPassphrase = passphrase
|
||||
|
||||
_, err = reopened.GetOrDeriveLongTermKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NoError(t,
|
||||
instance.UnlockersRemove(passphraseUnlocker.GetID(), true, cmd))
|
||||
|
||||
exists, err := afero.Exists(fs, filepath.Join(
|
||||
testVaultDir(listTestVaultName), "current-unlocker"))
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists,
|
||||
"current-unlocker is left after the passphrase unlocker")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -371,19 +371,3 @@ func TestRemoveSecretAbortsWhenVersionsUnreadable(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
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)
|
||||
}
|
||||
|
||||
+7
-236
@@ -8,7 +8,6 @@ import (
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/awnumar/memguard"
|
||||
"github.com/spf13/afero"
|
||||
@@ -16,19 +15,12 @@ import (
|
||||
"sneak.berlin/go/secret/internal/bip39"
|
||||
"sneak.berlin/go/secret/internal/secret"
|
||||
"sneak.berlin/go/secret/internal/vault"
|
||||
"sneak.berlin/go/secret/pkg/agehd"
|
||||
)
|
||||
|
||||
// Sentinel errors for vault operations
|
||||
var (
|
||||
errMnemonicEmpty = errors.New("mnemonic cannot be empty")
|
||||
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")
|
||||
)
|
||||
|
||||
@@ -42,7 +34,6 @@ func newVaultCmd() *cobra.Command {
|
||||
cmd.AddCommand(newVaultListCmd())
|
||||
cmd.AddCommand(newVaultCreateCmd())
|
||||
cmd.AddCommand(newVaultSelectCmd())
|
||||
cmd.AddCommand(newVaultImportCmd())
|
||||
cmd.AddCommand(newVaultRemoveCmd())
|
||||
|
||||
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 {
|
||||
cli, err := NewCLIInstance()
|
||||
if err != nil {
|
||||
@@ -186,6 +145,8 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
out := cmd.OutOrStdout()
|
||||
|
||||
if jsonOutput { //nolint:nestif // Separate JSON and text output formatting logic
|
||||
// Get current vault name for context
|
||||
currentVault := ""
|
||||
@@ -205,13 +166,13 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||
return err
|
||||
}
|
||||
|
||||
cmd.Println(string(jsonBytes))
|
||||
_, _ = fmt.Fprintln(out, string(jsonBytes))
|
||||
} else {
|
||||
// Text output
|
||||
cmd.Println("Available vaults:")
|
||||
_, _ = fmt.Fprintln(out, "Available vaults:")
|
||||
|
||||
if len(vaults) == 0 {
|
||||
cmd.Println(" (none)")
|
||||
_, _ = fmt.Fprintln(out, " (none)")
|
||||
} else {
|
||||
// Try to get current vault for marking
|
||||
currentVault := ""
|
||||
@@ -223,9 +184,9 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
|
||||
|
||||
for _, vaultName := range vaults {
|
||||
if vaultName == currentVault {
|
||||
cmd.Printf(" %s (current)\n", vaultName)
|
||||
_, _ = fmt.Fprintf(out, " %s (current)\n", vaultName)
|
||||
} else {
|
||||
cmd.Printf(" %s\n", vaultName)
|
||||
_, _ = fmt.Fprintf(out, " %s\n", vaultName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -334,196 +295,6 @@ func (cli *Instance) SelectVault(cmd *cobra.Command, name string) error {
|
||||
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
|
||||
func (cli *Instance) countVaultSecrets(vaultDir string) (int, error) {
|
||||
secretsDir := filepath.Join(vaultDir, "secrets.d")
|
||||
|
||||
@@ -65,7 +65,9 @@ func VersionCommands(cli *Instance) *cobra.Command {
|
||||
Use: "promote <secret-name> <version>",
|
||||
Short: "Promote a specific version to current",
|
||||
Long: "Updates the current symlink to point to the specified " +
|
||||
"version without modifying timestamps",
|
||||
"version. The versions' own times are left unchanged; secret " +
|
||||
"list then gives the time of the promote as the secret's last " +
|
||||
"update.",
|
||||
Args: cobra.ExactArgs(2), //nolint:mnd // secret-name and version args
|
||||
ValidArgsFunction: func(
|
||||
cmd *cobra.Command, args []string, toComplete string,
|
||||
@@ -167,7 +169,7 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
|
||||
}
|
||||
|
||||
if len(versions) == 0 {
|
||||
cmd.Println("No versions found")
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "No versions found")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ const (
|
||||
// EnvGPGKeyID is the environment variable for providing the GPG key ID
|
||||
EnvGPGKeyID = "SB_GPG_KEY_ID"
|
||||
|
||||
// TerminalPath opens the controlling terminal of the process that opens
|
||||
// it, where the passphrase and mnemonic prompts ask.
|
||||
TerminalPath = "/dev/tty"
|
||||
|
||||
// UnlockerTimeFormat is the layout of the time, in UTC, in the name of a
|
||||
// new unlocker's directory. It runs to the nanosecond, so that every new
|
||||
// unlocker, even one added right after another, gets a directory of its
|
||||
|
||||
+17
-26
@@ -6,7 +6,6 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"syscall"
|
||||
"unsafe"
|
||||
|
||||
"filippo.io/age"
|
||||
@@ -16,11 +15,8 @@ import (
|
||||
|
||||
var (
|
||||
errNilPassphraseBuffer = errors.New("passphrase buffer is nil")
|
||||
errStdinNotTerminal = errors.New(
|
||||
"stdin is not a terminal (piped input or script)")
|
||||
errStderrNotTerminal = errors.New(
|
||||
"stderr is not a terminal (running in non-interactive mode)")
|
||||
errNothingEntered = errors.New("nothing was entered")
|
||||
errNoTerminal = errors.New("there is no terminal to ask on")
|
||||
errNothingEntered = errors.New("nothing was entered")
|
||||
)
|
||||
|
||||
// ErrMnemonicNotRead is wrapped in every error of ReadMnemonic: there is no
|
||||
@@ -193,38 +189,33 @@ func ReadMnemonic(prompt string) (*memguard.LockedBuffer, error) {
|
||||
return readFromTerminal(prompt, ErrMnemonicNotRead, EnvMnemonic)
|
||||
}
|
||||
|
||||
// readFromTerminal reads input from the terminal without echoing it. Every
|
||||
// error it returns wraps notRead; without a terminal, the error says to set
|
||||
// envVar instead.
|
||||
// readFromTerminal asks on the controlling terminal and reads the answer
|
||||
// there without echoing it, whatever stdin is: in
|
||||
// `echo value | secret add name --force`, stdin is a pipe. Every error it
|
||||
// returns wraps notRead; without a terminal, the error says to set envVar
|
||||
// instead.
|
||||
func readFromTerminal(
|
||||
prompt string, notRead error, envVar string,
|
||||
) (*memguard.LockedBuffer, error) {
|
||||
// Check if stdin is a terminal
|
||||
if !term.IsTerminal(syscall.Stdin) {
|
||||
// Not a terminal - never read secrets from piped input
|
||||
// for security reasons
|
||||
terminal, err := os.OpenFile(TerminalPath, os.O_RDWR, 0)
|
||||
if err != nil {
|
||||
Debug("Failed to open the controlling terminal", "error", err)
|
||||
|
||||
return nil, fmt.Errorf(
|
||||
"%w: %w. Please set the %s environment variable or run interactively",
|
||||
notRead, errStdinNotTerminal, envVar)
|
||||
notRead, errNoTerminal, envVar)
|
||||
}
|
||||
defer func() { _ = terminal.Close() }()
|
||||
|
||||
// stdin is a terminal, check if stderr is also a terminal for
|
||||
// interactive prompting
|
||||
if !term.IsTerminal(syscall.Stderr) {
|
||||
return nil, fmt.Errorf("%w: %w. Please set the %s environment variable",
|
||||
notRead, errStderrNotTerminal, envVar)
|
||||
}
|
||||
_, _ = fmt.Fprint(terminal, prompt)
|
||||
|
||||
// Both stdin and stderr are terminals - use secure password reading
|
||||
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
|
||||
|
||||
input, err := term.ReadPassword(syscall.Stdin)
|
||||
input, err := term.ReadPassword(int(terminal.Fd()))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: %w", notRead, err)
|
||||
}
|
||||
|
||||
// Print newline to stderr since ReadPassword doesn't echo
|
||||
fmt.Fprintln(os.Stderr)
|
||||
// The newline typed after the input, which ReadPassword did not echo
|
||||
_, _ = fmt.Fprintln(terminal)
|
||||
|
||||
if len(input) == 0 {
|
||||
return nil, fmt.Errorf("%w: %w", notRead, errNothingEntered)
|
||||
|
||||
@@ -16,6 +16,10 @@ import (
|
||||
// empty. A passphrase unlocker that fails with it was not tried.
|
||||
var ErrPassphraseNotRead = errors.New("failed to read passphrase")
|
||||
|
||||
// ErrWrongPassphrase is wrapped in the error of GetIdentity when the
|
||||
// passphrase does not decrypt the unlocker's private key.
|
||||
var ErrWrongPassphrase = errors.New("wrong passphrase")
|
||||
|
||||
// PassphraseUnlocker represents a passphrase-protected unlocker
|
||||
type PassphraseUnlocker struct {
|
||||
Directory string
|
||||
@@ -73,6 +77,12 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
||||
Debug("Failed to decrypt unlocker private key",
|
||||
"error", err, "unlocker_id", p.GetID())
|
||||
|
||||
// age says that no identity matched when the passphrase is wrong
|
||||
var noMatch *age.NoIdentityMatchError
|
||||
if errors.As(err, &noMatch) {
|
||||
return nil, fmt.Errorf("%w for unlocker %s", ErrWrongPassphrase, p.GetID())
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to decrypt unlocker private key: %w", err)
|
||||
}
|
||||
defer privKeyBuffer.Destroy()
|
||||
|
||||
+38
-15
@@ -2,10 +2,11 @@ package secret
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"filippo.io/age"
|
||||
"github.com/awnumar/memguard"
|
||||
@@ -66,31 +67,53 @@ func NewSecret(vault VaultInterface, name string) *Secret {
|
||||
Name: name,
|
||||
Directory: secretDir,
|
||||
vault: vault,
|
||||
Metadata: Metadata{
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LoadMetadata is deprecated - metadata is now per-version and encrypted
|
||||
// LoadMetadata sets the secret's times from the modification times of its
|
||||
// files, which can be read without unlocking the vault. UpdatedAt is when
|
||||
// the secret's current file was last written, by an add or a promote.
|
||||
// CreatedAt is when its oldest version's value.age was written. That file
|
||||
// is never rewritten; the version's directory is no use, as its time
|
||||
// changes when the next add rewrites the version's metadata.age.
|
||||
func (s *Secret) LoadMetadata() error {
|
||||
Debug("LoadMetadata called but is deprecated in versioned model",
|
||||
"secret_name", s.Name)
|
||||
// For backward compatibility, we'll populate with basic info
|
||||
now := time.Now()
|
||||
fs := s.vault.GetFilesystem()
|
||||
|
||||
current, err := fs.Stat(filepath.Join(s.Directory, "current"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read when secret %s was updated: %w",
|
||||
s.Name, err)
|
||||
}
|
||||
|
||||
versions, err := ListVersions(fs, s.Directory)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(versions) == 0 {
|
||||
return fmt.Errorf("secret %s has no versions: %w", s.Name, os.ErrNotExist)
|
||||
}
|
||||
|
||||
// ListVersions lists the newest version first
|
||||
oldest := versions[len(versions)-1]
|
||||
|
||||
value, err := fs.Stat(
|
||||
filepath.Join(s.Directory, "versions", oldest, "value.age"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read when secret %s was created: %w",
|
||||
s.Name, err)
|
||||
}
|
||||
|
||||
s.Metadata = Metadata{
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
CreatedAt: value.ModTime(),
|
||||
UpdatedAt: current.ModTime(),
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMetadata returns the secret metadata (deprecated)
|
||||
// GetMetadata returns the times LoadMetadata read
|
||||
func (s *Secret) GetMetadata() Metadata {
|
||||
Debug("GetMetadata called but is deprecated in versioned model", "secret_name", s.Name)
|
||||
|
||||
return s.Metadata
|
||||
}
|
||||
|
||||
|
||||
@@ -81,7 +81,9 @@ func TestVaultErrors(t *testing.T) {
|
||||
return vlt.CopySecretAllVersions(vlt, versionless, "copy", false)
|
||||
}, vault.ErrNoVersions},
|
||||
{"remove a missing unlocker", func(vlt *vault.Vault) error {
|
||||
return vlt.RemoveUnlocker(missingName)
|
||||
_, err := vlt.RemoveUnlocker(missingName)
|
||||
|
||||
return err
|
||||
}, vault.ErrUnlockerNotFound},
|
||||
{"select a missing unlocker", func(vlt *vault.Vault) error {
|
||||
return vlt.SelectUnlocker(missingName)
|
||||
|
||||
@@ -215,11 +215,11 @@ func processMnemonicForVault(
|
||||
// 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
|
||||
// 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
|
||||
// gets a passphrase unlocker protected by it, as its current unlocker; that
|
||||
// needs a mnemonic. It refuses a vault that already exists before writing
|
||||
// anything: creating it again would replace its keys, and its secrets could
|
||||
// no longer be decrypted. The commands that call it hold the state directory
|
||||
// long-term key. When passphrase is not nil, the vault gets a passphrase
|
||||
// unlocker protected by it, as its current unlocker; that needs a mnemonic.
|
||||
// It refuses a vault that already exists before writing anything: creating
|
||||
// it again would replace its keys, and its secrets could no longer be
|
||||
// decrypted. The commands that call it hold the state directory
|
||||
// lock, so no other command can create the vault between the check and the
|
||||
// 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) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+83
-27
@@ -219,6 +219,15 @@ func (v *Vault) addVersion(
|
||||
name, secretDir string, value *memguard.LockedBuffer,
|
||||
previousVersion *secret.Version,
|
||||
) error {
|
||||
// Unlock the vault before writing anything, so that a wrong passphrase
|
||||
// or mnemonic leaves the secret as it was
|
||||
err := v.loadPreviousVersionMetadata(previousVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Taken after the unlock, which can wait for the user to type the
|
||||
// passphrase, so that the new version starts when it is created
|
||||
now := time.Now()
|
||||
|
||||
// Create the new version and save the encrypted value
|
||||
@@ -418,7 +427,8 @@ func (v *Vault) UnlockVault() (*age.X25519Identity, error) {
|
||||
return longTermIdentity, nil
|
||||
}
|
||||
|
||||
// GetSecretObject retrieves a Secret object with metadata loaded from this vault
|
||||
// GetSecretObject retrieves a Secret object from this vault. Its times are
|
||||
// zero until its LoadMetadata reads them.
|
||||
func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
||||
err := ValidateSecretName(name)
|
||||
if err != nil {
|
||||
@@ -445,16 +455,7 @@ func (v *Vault) GetSecretObject(name string) (*secret.Secret, error) {
|
||||
return nil, fmt.Errorf("secret %s %w", name, ErrSecretNotFound)
|
||||
}
|
||||
|
||||
// Create a Secret object
|
||||
secretObj := secret.NewSecret(v, name)
|
||||
|
||||
// Load the metadata from disk
|
||||
err = secretObj.LoadMetadata()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return secretObj, nil
|
||||
return secret.NewSecret(v, name), nil
|
||||
}
|
||||
|
||||
// CopySecretVersion copies a single version from source into destSecretDir
|
||||
@@ -572,8 +573,8 @@ func (v *Vault) CopySecretAllVersions(
|
||||
}
|
||||
|
||||
// Copy each version and the current pointer, then move the copy into place
|
||||
err = v.copyVersions(srcVault, srcIdentity,
|
||||
srcSecretName, destSecretName, destSecretDir, versions, currentVersion)
|
||||
err = v.copyVersions(srcVault, srcIdentity, srcSecretName, srcSecretDir,
|
||||
destSecretName, destSecretDir, versions, currentVersion)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -632,8 +633,35 @@ func (v *Vault) checkExistingSecret(
|
||||
return true, previousVersion, nil
|
||||
}
|
||||
|
||||
// loadPreviousVersionMetadata unlocks the vault and loads the metadata of
|
||||
// the version being superseded. It is a no-op when previousVersion is nil.
|
||||
func (v *Vault) loadPreviousVersionMetadata(
|
||||
previousVersion *secret.Version,
|
||||
) error {
|
||||
if previousVersion == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get long-term key for previous version", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
err = previousVersion.LoadMetadata(ltIdentity)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to load previous version metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to load previous version metadata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updatePreviousVersion sets the notAfter timestamp on the version being
|
||||
// superseded. It is a no-op when previousVersion is nil.
|
||||
// superseded, whose metadata loadPreviousVersionMetadata has loaded. It is
|
||||
// a no-op when previousVersion is nil.
|
||||
func (v *Vault) updatePreviousVersion(
|
||||
previousVersion *secret.Version, now *time.Time,
|
||||
) error {
|
||||
@@ -641,22 +669,13 @@ func (v *Vault) updatePreviousVersion(
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get long-term key to decrypt/encrypt metadata
|
||||
// loadPreviousVersionMetadata unlocked the vault, so this returns the
|
||||
// key it keeps in memory
|
||||
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||
if err != nil {
|
||||
secret.Debug("Failed to get long-term key for metadata update", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to get long-term key: %w", err)
|
||||
}
|
||||
|
||||
// Load previous version metadata
|
||||
err = previousVersion.LoadMetadata(ltIdentity)
|
||||
if err != nil {
|
||||
secret.Debug("Failed to load previous version metadata", "error", err)
|
||||
|
||||
return fmt.Errorf("failed to load previous version metadata: %w", err)
|
||||
}
|
||||
|
||||
// Update notAfter timestamp
|
||||
previousVersion.Metadata.NotAfter = now
|
||||
|
||||
@@ -774,10 +793,13 @@ func (v *Vault) createAndSaveVersion(
|
||||
// pointer into a temporary directory, then moves that directory to
|
||||
// destSecretDir, replacing a secret already there. Nothing in this vault
|
||||
// changes until the copy is complete, so an interrupted copy leaves only a
|
||||
// temporary directory behind.
|
||||
// temporary directory behind. Each copied value.age and current file gets
|
||||
// the modification time of the file it was copied from, so `secret list`,
|
||||
// which reads a secret's times from them, lists the copy with the times of
|
||||
// the source.
|
||||
func (v *Vault) copyVersions(
|
||||
srcVault *Vault, srcIdentity *age.X25519Identity,
|
||||
srcSecretName, destSecretName, destSecretDir string,
|
||||
srcSecretName, srcSecretDir, destSecretName, destSecretDir string,
|
||||
versions []string, currentVersion string,
|
||||
) error {
|
||||
buildDir, err := secret.TempDirFor(v.fs, destSecretDir)
|
||||
@@ -796,6 +818,14 @@ func (v *Vault) copyVersions(
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to copy version %s: %w", versionName, err)
|
||||
}
|
||||
|
||||
valuePath := filepath.Join("versions", versionName, "value.age")
|
||||
|
||||
err = copyModTime(srcVault.fs, filepath.Join(srcSecretDir, valuePath),
|
||||
v.fs, filepath.Join(buildDir, valuePath))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = secret.SetCurrentVersion(v.fs, buildDir, currentVersion)
|
||||
@@ -803,6 +833,12 @@ func (v *Vault) copyVersions(
|
||||
return fmt.Errorf("failed to set current version: %w", err)
|
||||
}
|
||||
|
||||
err = copyModTime(srcVault.fs, filepath.Join(srcSecretDir, "current"),
|
||||
v.fs, filepath.Join(buildDir, "current"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// With --force, the secret being replaced goes only now that its
|
||||
// replacement is complete
|
||||
exists, err := afero.DirExists(v.fs, destSecretDir)
|
||||
@@ -827,6 +863,26 @@ func (v *Vault) copyVersions(
|
||||
return nil
|
||||
}
|
||||
|
||||
// copyModTime gives destPath in destFs the modification time of srcPath in
|
||||
// srcFs.
|
||||
func copyModTime(
|
||||
srcFs afero.Fs, srcPath string, destFs afero.Fs, destPath string,
|
||||
) error {
|
||||
info, err := srcFs.Stat(srcPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read modification time of %s: %w",
|
||||
srcPath, err)
|
||||
}
|
||||
|
||||
err = destFs.Chtimes(destPath, info.ModTime(), info.ModTime())
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set modification time of %s: %w",
|
||||
destPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// checkCopyDestination refuses to copy over an existing secret unless force
|
||||
// is set. A secret being replaced is removed by copyVersions, once its
|
||||
// replacement is complete.
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
// version
|
||||
// - TestVaultVersionTimestamps: Tests timestamp logic (notBefore/notAfter)
|
||||
// across versions
|
||||
// - TestVaultVersionTimestampsAfterUnlock: Tests that those timestamps are
|
||||
// taken after AddSecret unlocks the vault
|
||||
// - TestVaultGetNonExistentVersion: Tests error handling for invalid versions
|
||||
// - TestUpdateVersionMetadata: Tests metadata update functionality
|
||||
//
|
||||
@@ -295,6 +297,70 @@ func TestVaultVersionTimestamps(t *testing.T) {
|
||||
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
|
||||
}
|
||||
|
||||
// longTermKeyReadFs records when longterm.age, the last file an unlock
|
||||
// reads, was last opened.
|
||||
type longTermKeyReadFs struct {
|
||||
afero.Fs
|
||||
|
||||
readAt time.Time
|
||||
}
|
||||
|
||||
//nolint:ireturn // afero.File is the interface required by afero.Fs
|
||||
func (f *longTermKeyReadFs) Open(name string) (afero.File, error) {
|
||||
if filepath.Base(name) == "longterm.age" {
|
||||
f.readAt = time.Now()
|
||||
}
|
||||
|
||||
return f.Fs.Open(name)
|
||||
}
|
||||
|
||||
// TestVaultVersionTimestampsAfterUnlock checks that when AddSecret has to
|
||||
// unlock the vault to replace a version, the new version's notBefore and the
|
||||
// replaced version's notAfter are taken after the unlock, which can wait for
|
||||
// the user to type the passphrase.
|
||||
func TestVaultVersionTimestampsAfterUnlock(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := &longTermKeyReadFs{Fs: afero.NewMemMapFs()}
|
||||
|
||||
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
|
||||
defer mnemonic.Destroy()
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte("test-passphrase"))
|
||||
defer passphrase.Destroy()
|
||||
|
||||
created, err := CreateVault(fs, testStateDir, "test", mnemonic, passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
addTestSecretToVault(t, created, testSecretPath, []byte("version-1"), false)
|
||||
|
||||
// A new instance is locked; AddSecret unlocks it with the passphrase
|
||||
vault := NewVault(fs, testStateDir, "test")
|
||||
vault.SetUnlockPassphrase(passphrase)
|
||||
|
||||
addTestSecretToVault(t, vault, testSecretPath, []byte("version-2"), true)
|
||||
require.False(t, fs.readAt.IsZero(), "AddSecret did not unlock the vault")
|
||||
|
||||
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
vaultDir, _ := vault.GetDirectory()
|
||||
secretDir := filepath.Join(vaultDir, "secrets.d", "test%secret")
|
||||
|
||||
versions, err := secret.ListVersions(fs, secretDir)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, versions, 2)
|
||||
|
||||
newVersion := secret.NewVersion(vault, testSecretPath, versions[0])
|
||||
require.NoError(t, newVersion.LoadMetadata(ltIdentity))
|
||||
|
||||
replacedVersion := secret.NewVersion(vault, testSecretPath, versions[1])
|
||||
require.NoError(t, replacedVersion.LoadMetadata(ltIdentity))
|
||||
|
||||
assert.False(t, newVersion.Metadata.NotBefore.Before(fs.readAt))
|
||||
assert.False(t, replacedVersion.Metadata.NotAfter.Before(fs.readAt))
|
||||
}
|
||||
|
||||
func TestVaultGetNonExistentVersion(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+126
-9
@@ -5,8 +5,10 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -19,6 +21,7 @@ import (
|
||||
// Unlocker metadata type strings.
|
||||
const (
|
||||
unlockerTypePassphrase = "passphrase"
|
||||
unlockerTypePGP = "pgp"
|
||||
)
|
||||
|
||||
// GetCurrentUnlocker returns the current unlocker for this vault
|
||||
@@ -52,6 +55,11 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = v.checkCurrentUnlockerExists(unlockerDir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
secret.DebugWith("Resolved unlocker directory",
|
||||
slog.String("unlocker_dir", unlockerDir),
|
||||
slog.String("vault_name", v.Name),
|
||||
@@ -74,7 +82,7 @@ func (v *Vault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
||||
passphraseUnlocker := secret.NewPassphraseUnlocker(v.fs, unlockerDir, metadata)
|
||||
passphraseUnlocker.Passphrase = v.UnlockPassphrase
|
||||
unlocker = passphraseUnlocker
|
||||
case "pgp":
|
||||
case unlockerTypePGP:
|
||||
secret.Debug("Creating PGP unlocker instance", "unlocker_type", metadata.Type)
|
||||
|
||||
unlocker = secret.NewPGPUnlocker(v.fs, unlockerDir, metadata)
|
||||
@@ -119,6 +127,37 @@ func (v *Vault) resolveUnlockerDirectory(currentUnlockerPath string) (string, er
|
||||
return absolutePath, nil
|
||||
}
|
||||
|
||||
// checkCurrentUnlockerExists fails with ErrUnlockerNotFound, saying to
|
||||
// select another unlocker, when unlockerDir, the unlocker directory that
|
||||
// current-unlocker names, does not exist and the vault has another unlocker
|
||||
// that SelectUnlocker accepts. When it has none, it returns nil: reading the
|
||||
// missing unlocker then fails, with the advice that the mnemonic still opens
|
||||
// the vault.
|
||||
func (v *Vault) checkCurrentUnlockerExists(unlockerDir string) error {
|
||||
exists, err := afero.DirExists(v.fs, unlockerDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check for the current unlocker: %w", err)
|
||||
}
|
||||
|
||||
if exists {
|
||||
return nil
|
||||
}
|
||||
|
||||
otherID, err := v.firstOtherUnlockerID(filepath.Base(unlockerDir))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if otherID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("unlocker with ID %s %w; the vault '%s' has other "+
|
||||
"unlockers: run %s with the ID of one of them to make it the current "+
|
||||
"unlocker", filepath.Base(unlockerDir), ErrUnlockerNotFound, v.Name,
|
||||
v.withVaultSelect("'secret unlocker select'"))
|
||||
}
|
||||
|
||||
// findUnlockerByID finds an unlocker by its ID and returns the unlocker
|
||||
// instance and its directory path. A directory that ListUnlockers skips is
|
||||
// skipped here too, with the same warning. Such a directory has no ID: if
|
||||
@@ -159,7 +198,7 @@ func (v *Vault) findUnlockerByID(
|
||||
switch metadata.Type {
|
||||
case unlockerTypePassphrase:
|
||||
tempUnlocker = secret.NewPassphraseUnlocker(v.fs, unlockerDirPath, metadata)
|
||||
case "pgp":
|
||||
case unlockerTypePGP:
|
||||
tempUnlocker = secret.NewPGPUnlocker(v.fs, unlockerDirPath, metadata)
|
||||
default:
|
||||
continue
|
||||
@@ -282,10 +321,15 @@ func (v *Vault) HasUnlocker(unlockerID string) (bool, error) {
|
||||
// RemoveUnlocker removes an unlocker from this vault. An unlocker
|
||||
// directory that ListUnlockers skips is removed by its directory name; its
|
||||
// type is unknown, so only the directory is removed.
|
||||
func (v *Vault) RemoveUnlocker(unlockerID string) error {
|
||||
//
|
||||
// It returns the ID of the unlocker it made current, or "" when it made none
|
||||
// current. If the removed unlocker is the current one, the unlocker
|
||||
// firstOtherUnlockerID returns is made current before the removal; with
|
||||
// none, current-unlocker is removed after it.
|
||||
func (v *Vault) RemoveUnlocker(unlockerID string) (string, error) {
|
||||
vaultDir, err := v.GetDirectory()
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Find the unlocker directory and create the unlocker instance
|
||||
@@ -294,19 +338,92 @@ func (v *Vault) RemoveUnlocker(unlockerID string) error {
|
||||
// Find the unlocker by ID
|
||||
unlocker, unlockerDir, err := v.findUnlockerByID(unlockersDir, unlockerID)
|
||||
if err != nil {
|
||||
return err
|
||||
return "", err
|
||||
}
|
||||
|
||||
if unlockerDir == "" {
|
||||
return fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
|
||||
return "", fmt.Errorf("unlocker with ID %s %w", unlockerID, ErrUnlockerNotFound)
|
||||
}
|
||||
|
||||
currentUnlockerPath := filepath.Join(vaultDir, "current-unlocker")
|
||||
|
||||
currentDir, err := v.resolveUnlockerDirectory(currentUnlockerPath)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
isCurrent := currentDir == unlockerDir
|
||||
newCurrentID := ""
|
||||
|
||||
if isCurrent {
|
||||
newCurrentID, err = v.selectOtherUnlocker(unlockerID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
if unlocker == nil {
|
||||
return secret.RemoveDirAtomic(v.fs, unlockerDir)
|
||||
err = secret.RemoveDirAtomic(v.fs, unlockerDir)
|
||||
} else {
|
||||
err = unlocker.Remove()
|
||||
}
|
||||
|
||||
// Use the unlocker's Remove method
|
||||
return unlocker.Remove()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if isCurrent && newCurrentID == "" {
|
||||
err = v.fs.Remove(currentUnlockerPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to remove current-unlocker file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return newCurrentID, nil
|
||||
}
|
||||
|
||||
// selectOtherUnlocker makes the unlocker firstOtherUnlockerID returns for
|
||||
// unlockerID the current unlocker and returns its ID. It returns "" when
|
||||
// there is no such unlocker.
|
||||
func (v *Vault) selectOtherUnlocker(unlockerID string) (string, error) {
|
||||
otherID, err := v.firstOtherUnlockerID(unlockerID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if otherID == "" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
err = v.SelectUnlocker(otherID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return otherID, nil
|
||||
}
|
||||
|
||||
// firstOtherUnlockerID returns the ID of the vault's first unlocker other
|
||||
// than unlockerID, in ID order, the order `secret unlocker list` shows, that
|
||||
// SelectUnlocker accepts, or "" when there is none. SelectUnlocker refuses
|
||||
// an unlocker whose metadata names a type other than passphrase or pgp,
|
||||
// which ListUnlockers still returns.
|
||||
func (v *Vault) firstOtherUnlockerID(unlockerID string) (string, error) {
|
||||
unlockers, err := v.ListUnlockers()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
for _, id := range slices.Sorted(maps.Keys(unlockers)) {
|
||||
unlockerType := unlockers[id].Type
|
||||
|
||||
if id != unlockerID && (unlockerType == unlockerTypePassphrase ||
|
||||
unlockerType == unlockerTypePGP) {
|
||||
return id, nil
|
||||
}
|
||||
}
|
||||
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// SelectUnlocker selects an unlocker as current for this vault
|
||||
|
||||
+22
-11
@@ -291,9 +291,14 @@ func (v *Vault) unlockLongTermKey(
|
||||
// without a mnemonic records none, and without its metadata the key cannot
|
||||
// be derived. It is not added when the passphrase could not be read: the
|
||||
// unlocker was not tried, and adding one would need a passphrase read the
|
||||
// same way.
|
||||
// same way. Nor is it added when the passphrase is wrong: the unlocker still
|
||||
// opens the vault with the right one. Nor is it added when the current
|
||||
// unlocker does not exist and the vault has others: the error already says
|
||||
// to select one of them.
|
||||
func (v *Vault) withMnemonicAdvice(err error) error {
|
||||
if errors.Is(err, secret.ErrPassphraseNotRead) {
|
||||
if errors.Is(err, secret.ErrPassphraseNotRead) ||
|
||||
errors.Is(err, secret.ErrWrongPassphrase) ||
|
||||
errors.Is(err, ErrUnlockerNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -304,15 +309,21 @@ func (v *Vault) withMnemonicAdvice(err error) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 'secret unlocker add' acts on the current vault only.
|
||||
steps := "'secret unlocker add passphrase'"
|
||||
|
||||
current, currentErr := GetCurrentVault(v.fs, v.stateDir)
|
||||
if currentErr != nil || current.Name != v.Name {
|
||||
steps = fmt.Sprintf("'secret vault select %s', then %s", v.Name, steps)
|
||||
}
|
||||
|
||||
return fmt.Errorf("%w; the vault '%s' still opens with its mnemonic: run "+
|
||||
"%s with %s set to the mnemonic to give it a new unlocker",
|
||||
err, v.Name, steps, secret.EnvMnemonic)
|
||||
err, v.Name, v.withVaultSelect("'secret unlocker add passphrase'"),
|
||||
secret.EnvMnemonic)
|
||||
}
|
||||
|
||||
// withVaultSelect returns command, a quoted 'secret unlocker' command, for
|
||||
// advice about this vault. Such a command acts on the current vault only,
|
||||
// so when this vault is not the current one, the command that selects it
|
||||
// comes first.
|
||||
func (v *Vault) withVaultSelect(command string) string {
|
||||
current, err := GetCurrentVault(v.fs, v.stateDir)
|
||||
if err != nil || current.Name != v.Name {
|
||||
return fmt.Sprintf("'secret vault select %s', then %s", v.Name, command)
|
||||
}
|
||||
|
||||
return command
|
||||
}
|
||||
|
||||
@@ -103,3 +103,76 @@ func TestAddSecretCleansUpOnFailure(t *testing.T) {
|
||||
require.Len(t, entries, 1)
|
||||
assert.Equal(t, "pub.age", entries[0].Name())
|
||||
}
|
||||
|
||||
// TestAddSecretForceWithWrongPassphraseChangesNothing checks that adding a
|
||||
// version to an existing secret, with a passphrase that does not open the
|
||||
// vault, fails and leaves the secret's directory as it was.
|
||||
func TestAddSecretForceWithWrongPassphraseChangesNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fs := afero.NewMemMapFs()
|
||||
|
||||
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||
t.Cleanup(passphrase.Destroy)
|
||||
|
||||
created, err := vault.CreateVault(fs, testStateDir, testVaultName,
|
||||
testMnemonicBuffer(t), passphrase)
|
||||
require.NoError(t, err)
|
||||
|
||||
first := memguard.NewBufferFromBytes([]byte("first"))
|
||||
t.Cleanup(first.Destroy)
|
||||
|
||||
require.NoError(t, created.AddSecret(testSecretName, first, false))
|
||||
|
||||
wrong := memguard.NewBufferFromBytes([]byte("wrong passphrase"))
|
||||
t.Cleanup(wrong.Destroy)
|
||||
|
||||
vlt := vault.NewVault(fs, testStateDir, testVaultName)
|
||||
vlt.SetUnlockPassphrase(wrong)
|
||||
|
||||
secretDir := filepath.Join(testStateDir, "vaults.d", testVaultName,
|
||||
"secrets.d", testSecretName)
|
||||
before := dirContents(t, fs, secretDir)
|
||||
|
||||
second := memguard.NewBufferFromBytes([]byte("second"))
|
||||
t.Cleanup(second.Destroy)
|
||||
|
||||
err = vlt.AddSecret(testSecretName, second, true)
|
||||
|
||||
require.ErrorIs(t, err, secret.ErrWrongPassphrase)
|
||||
assert.Equal(t, before, dirContents(t, fs, secretDir))
|
||||
}
|
||||
|
||||
// dirContents maps every file under dir to its contents, and every
|
||||
// directory, written with a trailing "/", to "".
|
||||
func dirContents(t *testing.T, fs afero.Fs, dir string) map[string]string {
|
||||
t.Helper()
|
||||
|
||||
contents := map[string]string{}
|
||||
|
||||
err := afero.Walk(fs, dir, func(
|
||||
path string, info os.FileInfo, err error,
|
||||
) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if info.IsDir() {
|
||||
contents[path+"/"] = ""
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
content, err := afero.ReadFile(fs, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
contents[path] = string(content)
|
||||
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
return contents
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user