A wrong passphrase gives an error that says so (closes #157)
check / check (push) Waiting to run
check / check (push) Waiting to run
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
This commit is contained in:
@@ -18,6 +18,14 @@ 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
|
- 2026-10-14: `secret generate mnemonic`, `secret vault list` and
|
||||||
`secret unlocker list` print their output to stdout, as `secret list` does, so
|
`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).
|
it can be piped or captured (https://git.eeqj.de/sneak/secret/issues/138).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user