2 Commits
Author SHA1 Message Date
sneak 24e74e5f61 A wrong passphrase gives an error that says so (closes #157)
check / check (push) Canceled after 0s
A passphrase that did not decrypt the passphrase unlocker failed with age's
"no identity matched any of the recipients" and advice to give the vault a
new unlocker from its mnemonic, which would replace an unlocker that works.
GetIdentity now turns that age error into "wrong passphrase for unlocker
<id>", wrapping the new secret.ErrWrongPassphrase, and withMnemonicAdvice
leaves it alone, as it does when no passphrase was read. A passphrase from
SB_UNLOCK_PASSPHRASE and a typed one both reach this code. A priv.age that
is not an age file keeps the advice.

Judgement call: age cannot tell a wrong passphrase from a damaged key inside
a priv.age whose header reads, so that case now reads as a wrong passphrase.

Model: opus-5-5
2026-10-14 20:32:56 +00:00
clawbot fe94e99801 Print the mnemonic and the vault and unlocker listings to stdout (closes #138)
check / check (push) Canceled after 0s
`secret generate mnemonic`, `secret vault list` and `secret unlocker list`,
in text and `--json` form, printed through cobra's `cmd.Println` and
`cmd.Printf`. Those write to stderr when no output writer is set, and the
binary sets none, so a pipe or `$(...)` got nothing. They now write to
`cmd.OutOrStdout()`, as `secret list` does, and so does "No versions found"
from `secret version list`. Status messages and the warning printed after
the mnemonic stay on stderr.

A new test runs the built binary and reads the stdout of `generate
mnemonic` and of both listings in both forms.

Model: opus-5-5
2026-10-14 22:31:47 +02:00
11 changed files with 157 additions and 39 deletions
+2 -1
View File
@@ -197,7 +197,8 @@ for confirmation, naming the version, the secret and its vault (see
#### `secret generate mnemonic` #### `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]` #### `secret generate secret <name> [--length=16] [--type=base58] [--force]`
+16
View File
@@ -18,6 +18,22 @@ https://git.eeqj.de/sneak/secret/milestone/12
# Completed Steps # 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: `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 - 2026-10-14: Removing the current unlocker no longer leaves the vault unable to
open through the unlockers it still has open through the unlockers it still has
(https://git.eeqj.de/sneak/secret/issues/137). `current-unlocker` went on (https://git.eeqj.de/sneak/secret/issues/137). `current-unlocker` went on
+1 -1
View File
@@ -103,7 +103,7 @@ func (cli *Instance) GenerateMnemonic(cmd *cobra.Command) error {
} }
// Output mnemonic to stdout // Output mnemonic to stdout
cmd.Println(mnemonic) _, _ = fmt.Fprintln(cmd.OutOrStdout(), mnemonic)
// Output helpful information to stderr // Output helpful information to stderr
fmt.Fprintln(os.Stderr, "") fmt.Fprintln(os.Stderr, "")
+65
View File
@@ -2,6 +2,7 @@ package cli_test
import ( import (
"bytes" "bytes"
"encoding/json"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -9,6 +10,7 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/secret/internal/bip39"
"sneak.berlin/go/secret/internal/secret" "sneak.berlin/go/secret/internal/secret"
) )
@@ -90,3 +92,66 @@ func TestGetCommandOutputsToStdout(t *testing.T) {
// Nothing should be in stderr // Nothing should be in stderr
assert.Empty(t, stderr.String(), "stderr should be empty") 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)")
}
+39 -18
View File
@@ -1,12 +1,12 @@
// Unlock Failure Tests // Unlock Failure Tests
// //
// When a vault cannot be opened through its current unlocker, because a // 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 // file the unlocker needs is missing, the error keeps its cause and ends by
// keeps its cause and ends by saying that the mnemonic still opens that // saying that the mnemonic still opens that vault, but only for a vault that
// vault, but only for a vault that the mnemonic does open, and not when the // the mnemonic does open, and not when the passphrase could not be read at
// passphrase could not be read at all. When the current unlocker does not // all. A wrong passphrase gives an error that says so, without that advice.
// exist and the vault has another that `secret unlocker select` accepts, the // When the current unlocker does not exist and the vault has another that
// error says to select one instead. // `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 // 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. // version current again. Each test that pins such advice also follows it.
@@ -20,7 +20,6 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"filippo.io/age"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -164,26 +163,48 @@ func TestUnlockFailureNamesMnemonic(t *testing.T) {
} }
} }
// TestWrongPassphraseNamesMnemonic checks the error of `secret get` given a // TestWrongPassphraseNamesPassphrase checks the error of `secret get` given
// passphrase that does not decrypt the passphrase unlocker: it keeps age's // a passphrase that does not decrypt the passphrase unlocker: it says that
// error and ends with the advice that the mnemonic still opens the vault. // the passphrase is wrong, without the advice to use the mnemonic, since the
func TestWrongPassphraseNamesMnemonic(t *testing.T) { // unlocker still opens the vault with the right passphrase. The test then
// gives the right passphrase.
func TestWrongPassphraseNamesPassphrase(t *testing.T) {
t.Parallel() t.Parallel()
_, c := newUnlockTestCLI(t, newTwoVaultFs(t)) unlockerDir, c := newUnlockTestCLI(t, newTwoVaultFs(t))
c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte("wrong passphrase")) c.UnlockPassphrase = memguard.NewBufferFromBytes([]byte("wrong passphrase"))
t.Cleanup(c.UnlockPassphrase.Destroy) t.Cleanup(c.UnlockPassphrase.Destroy)
err := c.GetSecret(discardCmd(), "x") err := c.GetSecret(discardCmd(), "x")
var noMatch *age.NoIdentityMatchError require.ErrorIs(t, err, secret.ErrWrongPassphrase)
assert.NotContains(t, err.Error(), mnemonicAdvice)
require.ErrorAs(t, err, &noMatch)
require.EqualError(t, err, "failed to unlock vault: "+ require.EqualError(t, err, "failed to unlock vault: "+
"failed to get long-term key: failed to get unlocker identity: "+ "failed to get long-term key: failed to get unlocker identity: "+
"failed to decrypt unlocker private key: failed to create decryptor: "+ "wrong passphrase for unlocker "+filepath.Base(unlockerDir))
noMatch.Error()+mnemonicAdvice)
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 // TestMoveUnlockFailureNamesVault checks the error of `secret move` into
+10 -7
View File
@@ -326,22 +326,25 @@ func (cli *Instance) printUnlockersJSON(
return fmt.Errorf("failed to marshal JSON: %w", err) return fmt.Errorf("failed to marshal JSON: %w", err)
} }
cli.cmd.Println(string(jsonBytes)) _, _ = fmt.Fprintln(cli.cmd.OutOrStdout(), string(jsonBytes))
return nil return nil
} }
// printUnlockersTable prints unlockers in a formatted table // printUnlockersTable prints unlockers in a formatted table
func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error { func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
out := cli.cmd.OutOrStdout()
if len(unlockers) == 0 { if len(unlockers) == 0 {
cli.cmd.Println("No unlockers found in current vault.") _, _ = fmt.Fprintln(out, "No unlockers found in current vault.")
cli.cmd.Println("Run 'secret unlocker add passphrase' to create one.") _, _ = fmt.Fprintln(out, "Run 'secret unlocker add passphrase' to create one.")
return nil return nil
} }
cli.cmd.Printf(" %-40s %-12s %-20s %s\n", "UNLOCKER ID", "TYPE", "CREATED", "FLAGS") _, _ = fmt.Fprintf(out, " %-40s %-12s %-20s %s\n",
cli.cmd.Printf(" %-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("-", unlockerIDWidth), strings.Repeat("-", unlockerTypeWidth),
strings.Repeat("-", unlockerDateWidth), strings.Repeat("-", unlockerFlagsWidth)) strings.Repeat("-", unlockerDateWidth), strings.Repeat("-", unlockerFlagsWidth))
@@ -356,7 +359,7 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
prefix = "* " prefix = "* "
} }
cli.cmd.Printf("%s%-40s %-12s %-20s %s\n", _, _ = fmt.Fprintf(out, "%s%-40s %-12s %-20s %s\n",
prefix, prefix,
unlocker.ID, unlocker.ID,
unlocker.Type, unlocker.Type,
@@ -364,7 +367,7 @@ func (cli *Instance) printUnlockersTable(unlockers []UnlockerInfo) error {
flags) flags)
} }
cli.cmd.Printf("\nTotal: %d unlocker(s)\n", len(unlockers)) _, _ = fmt.Fprintf(out, "\nTotal: %d unlocker(s)\n", len(unlockers))
return nil return nil
} }
+7 -5
View File
@@ -186,6 +186,8 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
return err return err
} }
out := cmd.OutOrStdout()
if jsonOutput { //nolint:nestif // Separate JSON and text output formatting logic if jsonOutput { //nolint:nestif // Separate JSON and text output formatting logic
// Get current vault name for context // Get current vault name for context
currentVault := "" currentVault := ""
@@ -205,13 +207,13 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
return err return err
} }
cmd.Println(string(jsonBytes)) _, _ = fmt.Fprintln(out, string(jsonBytes))
} else { } else {
// Text output // Text output
cmd.Println("Available vaults:") _, _ = fmt.Fprintln(out, "Available vaults:")
if len(vaults) == 0 { if len(vaults) == 0 {
cmd.Println(" (none)") _, _ = fmt.Fprintln(out, " (none)")
} else { } else {
// Try to get current vault for marking // Try to get current vault for marking
currentVault := "" currentVault := ""
@@ -223,9 +225,9 @@ func (cli *Instance) ListVaults(cmd *cobra.Command, jsonOutput bool) error {
for _, vaultName := range vaults { for _, vaultName := range vaults {
if vaultName == currentVault { if vaultName == currentVault {
cmd.Printf(" %s (current)\n", vaultName) _, _ = fmt.Fprintf(out, " %s (current)\n", vaultName)
} else { } else {
cmd.Printf(" %s\n", vaultName) _, _ = fmt.Fprintf(out, " %s\n", vaultName)
} }
} }
} }
+1 -1
View File
@@ -167,7 +167,7 @@ func (cli *Instance) ListVersions(cmd *cobra.Command, secretName string) error {
} }
if len(versions) == 0 { if len(versions) == 0 {
cmd.Println("No versions found") _, _ = fmt.Fprintln(cmd.OutOrStdout(), "No versions found")
return nil return nil
} }
+10
View File
@@ -16,6 +16,10 @@ import (
// empty. A passphrase unlocker that fails with it was not tried. // empty. A passphrase unlocker that fails with it was not tried.
var ErrPassphraseNotRead = errors.New("failed to read passphrase") 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 // PassphraseUnlocker represents a passphrase-protected unlocker
type PassphraseUnlocker struct { type PassphraseUnlocker struct {
Directory string Directory string
@@ -73,6 +77,12 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
Debug("Failed to decrypt unlocker private key", Debug("Failed to decrypt unlocker private key",
"error", err, "unlocker_id", p.GetID()) "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) return nil, fmt.Errorf("failed to decrypt unlocker private key: %w", err)
} }
defer privKeyBuffer.Destroy() defer privKeyBuffer.Destroy()
+5 -2
View File
@@ -291,10 +291,13 @@ func (v *Vault) unlockLongTermKey(
// without a mnemonic records none, and without its metadata the key cannot // 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 // 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 // unlocker was not tried, and adding one would need a passphrase read the
// same way. Nor is it added when the current unlocker does not exist and the // same way. Nor is it added when the passphrase is wrong: the unlocker still
// vault has others: the error already says to select one of them. // 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 { 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) { errors.Is(err, ErrUnlockerNotFound) {
return err return err
} }
+1 -4
View File
@@ -5,7 +5,6 @@ import (
"path/filepath" "path/filepath"
"testing" "testing"
"filippo.io/age"
"github.com/awnumar/memguard" "github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -140,9 +139,7 @@ func TestAddSecretForceWithWrongPassphraseChangesNothing(t *testing.T) {
err = vlt.AddSecret(testSecretName, second, true) err = vlt.AddSecret(testSecretName, second, true)
var noMatch *age.NoIdentityMatchError require.ErrorIs(t, err, secret.ErrWrongPassphrase)
require.ErrorAs(t, err, &noMatch)
assert.Equal(t, before, dirContents(t, fs, secretDir)) assert.Equal(t, before, dirContents(t, fs, secretDir))
} }