Compare commits
2
Commits
v1.0.0-rc2
...
0e4f7fe72c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0e4f7fe72c | ||
|
|
5371382cb4 |
@@ -18,6 +18,20 @@ https://git.eeqj.de/sneak/secret/milestone/12
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-14: `secret unlocker add pgp` without `--keyid` uses the `default-key`
|
||||||
|
set in `gpg.conf` (https://git.eeqj.de/sneak/secret/issues/141). The key ID
|
||||||
|
came from `gpgconf --list-options gpg`, which prints a string value with a
|
||||||
|
leading `"` and percent-escaped, and was passed to gpg undecoded, so gpg
|
||||||
|
rejected it. The value is now decoded first. With no `default-key` set, the
|
||||||
|
first secret key is still used.
|
||||||
|
- 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
|
- 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
|
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
|
(https://git.eeqj.de/sneak/secret/issues/135). The key was derived from the
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"maps"
|
"maps"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -66,12 +67,13 @@ func getDefaultGPGKey() (string, error) {
|
|||||||
|
|
||||||
output, err := cmd.Output()
|
output, err := cmd.Output()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
for line := range strings.SplitSeq(string(output), "\n") {
|
defaultKey, parseErr := parseDefaultGPGKey(string(output))
|
||||||
fields := strings.Split(line, ":")
|
if parseErr != nil {
|
||||||
if len(fields) > 9 && fields[0] == "default-key" && fields[9] != "" {
|
return "", parseErr
|
||||||
// The default key is in field 10 (index 9)
|
}
|
||||||
return fields[9], nil
|
|
||||||
}
|
if defaultKey != "" {
|
||||||
|
return defaultKey, nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +100,27 @@ func getDefaultGPGKey() (string, error) {
|
|||||||
return "", errNoGPGSecretKeys
|
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 {
|
func newUnlockerCmd() *cobra.Command {
|
||||||
cmd := &cobra.Command{
|
cmd := &cobra.Command{
|
||||||
Use: "unlocker",
|
Use: "unlocker",
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
package cli
|
package cli
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"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
|
// TestAddPGPUnlockerUnknownKey asserts that adding a PGP unlocker for a key
|
||||||
// the keyring does not hold fails at looking up the key's fingerprint and
|
// 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
|
// leaves no new unlocker directory. The error must come from the lookup: a
|
||||||
|
|||||||
+39
-12
@@ -219,6 +219,15 @@ func (v *Vault) addVersion(
|
|||||||
name, secretDir string, value *memguard.LockedBuffer,
|
name, secretDir string, value *memguard.LockedBuffer,
|
||||||
previousVersion *secret.Version,
|
previousVersion *secret.Version,
|
||||||
) error {
|
) 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()
|
now := time.Now()
|
||||||
|
|
||||||
// Create the new version and save the encrypted value
|
// Create the new version and save the encrypted value
|
||||||
@@ -632,8 +641,35 @@ func (v *Vault) checkExistingSecret(
|
|||||||
return true, previousVersion, nil
|
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
|
// 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(
|
func (v *Vault) updatePreviousVersion(
|
||||||
previousVersion *secret.Version, now *time.Time,
|
previousVersion *secret.Version, now *time.Time,
|
||||||
) error {
|
) error {
|
||||||
@@ -641,22 +677,13 @@ func (v *Vault) updatePreviousVersion(
|
|||||||
return nil
|
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()
|
ltIdentity, err := v.GetOrDeriveLongTermKey()
|
||||||
if err != nil {
|
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)
|
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
|
// Update notAfter timestamp
|
||||||
previousVersion.Metadata.NotAfter = now
|
previousVersion.Metadata.NotAfter = now
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
// version
|
// version
|
||||||
// - TestVaultVersionTimestamps: Tests timestamp logic (notBefore/notAfter)
|
// - TestVaultVersionTimestamps: Tests timestamp logic (notBefore/notAfter)
|
||||||
// across versions
|
// across versions
|
||||||
|
// - TestVaultVersionTimestampsAfterUnlock: Tests that those timestamps are
|
||||||
|
// taken after AddSecret unlocks the vault
|
||||||
// - TestVaultGetNonExistentVersion: Tests error handling for invalid versions
|
// - TestVaultGetNonExistentVersion: Tests error handling for invalid versions
|
||||||
// - TestUpdateVersionMetadata: Tests metadata update functionality
|
// - TestUpdateVersionMetadata: Tests metadata update functionality
|
||||||
//
|
//
|
||||||
@@ -295,6 +297,70 @@ func TestVaultVersionTimestamps(t *testing.T) {
|
|||||||
assert.Nil(t, secondVersion.Metadata.NotAfter) // Current version
|
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) {
|
func TestVaultGetNonExistentVersion(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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"
|
||||||
@@ -103,3 +104,78 @@ func TestAddSecretCleansUpOnFailure(t *testing.T) {
|
|||||||
require.Len(t, entries, 1)
|
require.Len(t, entries, 1)
|
||||||
assert.Equal(t, "pub.age", entries[0].Name())
|
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)
|
||||||
|
|
||||||
|
var noMatch *age.NoIdentityMatchError
|
||||||
|
|
||||||
|
require.ErrorAs(t, err, &noMatch)
|
||||||
|
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