Compare commits
2
Commits
f9ef4b64ca
...
a8282ccd8b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a8282ccd8b | ||
|
|
007254a1f0 |
@@ -91,6 +91,9 @@ Lists all available vaults. The current vault is marked.
|
|||||||
|
|
||||||
Creates a new vault with the specified name.
|
Creates a new vault with the specified name.
|
||||||
|
|
||||||
|
**Vault Name Format:** only lowercase ASCII letters, digits, `.`, `-` and `_`
|
||||||
|
are allowed, and a name must not be empty, `.` or `..`.
|
||||||
|
|
||||||
#### `secret vault select <name>`
|
#### `secret vault select <name>`
|
||||||
|
|
||||||
Switches to the specified vault for subsequent operations.
|
Switches to the specified vault for subsequent operations.
|
||||||
|
|||||||
@@ -25,6 +25,24 @@ Bring the repo into policy compliance in one commit:
|
|||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
|
- 2026-10-04: `secret unlocker add pgp` works on Linux
|
||||||
|
(https://git.eeqj.de/sneak/secret/issues/88). `CreatePGPUnlocker` gets
|
||||||
|
the vault's long-term key as adding a passphrase unlocker does, with the
|
||||||
|
vault's `GetOrDeriveLongTermKey`, now part of `VaultInterface`: from the
|
||||||
|
mnemonic, checked against the vault, or else from the current unlocker.
|
||||||
|
Before, it used the keychain unlocker's helper, which on every platform
|
||||||
|
but macOS always failed. A test adds a PGP unlocker for a throwaway GPG
|
||||||
|
key, getting the long-term key once from the mnemonic and once from a
|
||||||
|
passphrase unlocker, and reads a secret through the new unlocker.
|
||||||
|
- 2026-10-04: A vault name may use only lowercase ASCII letters, digits,
|
||||||
|
`.`, `-` and `_`, and must not be empty, `.` or `..`
|
||||||
|
(https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md`
|
||||||
|
state the rule. `vault create`, `vault import`, `vault select`,
|
||||||
|
`vault remove`, both vault names of `mv` and shell completion of a
|
||||||
|
`vault:secret` argument check the name as typed with
|
||||||
|
`vault.ValidateVaultName` before building any path from it. Before,
|
||||||
|
`vault import ..` wrote a long-term key and an unlocker into the state
|
||||||
|
directory itself, and `vault select ..` made that the current vault.
|
||||||
- 2026-10-04: `script/cibuild` runs the checks again on an unchanged
|
- 2026-10-04: `script/cibuild` runs the checks again on an unchanged
|
||||||
tree (https://git.eeqj.de/sneak/secret/issues/54). It passes the
|
tree (https://git.eeqj.de/sneak/secret/issues/54). It passes the
|
||||||
current time as the `CHECK_EPOCH` build argument, which both the lint
|
current time as the `CHECK_EPOCH` build argument, which both the lint
|
||||||
|
|||||||
@@ -123,7 +123,9 @@ func getVaultNamesCompletionFunc(fs afero.Fs, stateDir string) func(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// completeVaultQualifiedSecrets completes "vault:secret" references once a
|
// completeVaultQualifiedSecrets completes "vault:secret" references once a
|
||||||
// colon is present in the input
|
// colon is present in the input. It completes nothing when the vault part
|
||||||
|
// is not a valid vault name, so that a name such as ".." cannot list a
|
||||||
|
// directory outside vaults.d.
|
||||||
func completeVaultQualifiedSecrets(
|
func completeVaultQualifiedSecrets(
|
||||||
fs afero.Fs, stateDir, toComplete string,
|
fs afero.Fs, stateDir, toComplete string,
|
||||||
) []string {
|
) []string {
|
||||||
@@ -134,6 +136,10 @@ func completeVaultQualifiedSecrets(
|
|||||||
vaultName := parts[0]
|
vaultName := parts[0]
|
||||||
secretPrefix := parts[1]
|
secretPrefix := parts[1]
|
||||||
|
|
||||||
|
if vault.ValidateVaultName(vaultName) != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
vlt := vault.NewVault(fs, stateDir, vaultName)
|
vlt := vault.NewVault(fs, stateDir, vaultName)
|
||||||
|
|
||||||
secrets, err := vlt.ListSecrets()
|
secrets, err := vlt.ListSecrets()
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
//nolint:testpackage // white-box test of unexported internals
|
||||||
|
package cli
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestVaultSecretCompletionRejectsInvalidVaultName is a regression test for
|
||||||
|
// https://git.eeqj.de/sneak/secret/issues/68: completing a `vault:secret`
|
||||||
|
// argument lists nothing when the vault part is not a valid vault name, even
|
||||||
|
// where that name, joined onto vaults.d, leads to a secrets.d directory.
|
||||||
|
func TestVaultSecretCompletionRejectsInvalidVaultName(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
stateDir = "/state"
|
||||||
|
dirPerm = 0o700
|
||||||
|
)
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
|
||||||
|
// The vault "work" holds the secret "x". So does every directory an
|
||||||
|
// invalid name below would lead to from vaults.d.
|
||||||
|
for _, vaultName := range []string{"work", ".", "..", "a/b"} {
|
||||||
|
secretDir := filepath.Join(stateDir, "vaults.d", vaultName, "secrets.d", "x")
|
||||||
|
require.NoError(t, fs.MkdirAll(secretDir, dirPerm))
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, []string{"work:x"},
|
||||||
|
completeVaultQualifiedSecrets(fs, stateDir, "work:"))
|
||||||
|
|
||||||
|
for _, toComplete := range []string{".:", "..:", "a/b:"} {
|
||||||
|
assert.Empty(t, completeVaultQualifiedSecrets(fs, stateDir, toComplete),
|
||||||
|
"completing %q", toComplete)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,26 +48,25 @@ func TestRejectedMoveWithinVaultLeavesStateUnchanged(t *testing.T) {
|
|||||||
"mv work:nosuch work:y", "work:nosuch", "work:y", false,
|
"mv work:nosuch work:y", "work:nosuch", "work:y", false,
|
||||||
"secret 'nosuch' not found",
|
"secret 'nosuch' not found",
|
||||||
},
|
},
|
||||||
// Only an existing vault is used, so ".." cannot reach the state
|
// Only an existing vault is used.
|
||||||
// directory itself.
|
|
||||||
{
|
{
|
||||||
"mv --force ..:x ..:y", "..:x", "..:y", true,
|
"mv --force nosuch:x nosuch:y", "nosuch:x", "nosuch:y", true,
|
||||||
"vault '..' does not exist",
|
"vault 'nosuch' does not exist",
|
||||||
},
|
},
|
||||||
// Each of these spells "work" a second way. The spelling is not an
|
// Each of these spells "work" a second way. The spelling is not a
|
||||||
// existing vault name, so the move is not taken for a move between
|
// valid vault name, so the move is not taken for a move between two
|
||||||
// two vaults, which would delete the destination, here the source.
|
// vaults, which would delete the destination, here the source.
|
||||||
{
|
{
|
||||||
"mv --force work:x work/:x", workX, "work/:x", true,
|
"mv --force work:x work/:x", workX, "work/:x", true,
|
||||||
"vault 'work/' does not exist",
|
vault.ValidateVaultName("work/").Error(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"mv --force work/:x work:", "work/:x", "work:", true,
|
"mv --force work/:x work:", "work/:x", "work:", true,
|
||||||
"vault 'work/' does not exist",
|
vault.ValidateVaultName("work/").Error(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"mv --force work:x ./work:x", workX, "./work:x", true,
|
"mv --force work:x ./work:x", workX, "./work:x", true,
|
||||||
"vault './work' does not exist",
|
vault.ValidateVaultName("./work").Error(),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -292,6 +292,58 @@ 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
|
||||||
|
// vault. Each command that takes a vault name must reject an invalid one
|
||||||
|
// before building a path from it. The mnemonic and the passphrase are set,
|
||||||
|
// and moves and removals use --force, so that only the name check stands
|
||||||
|
// in the way.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // newTwoVaultFs uses t.Setenv
|
||||||
|
func TestInvalidVaultNameLeavesStateUnchanged(t *testing.T) {
|
||||||
|
before := snapshotStateDir(t, newTwoVaultFs(t))
|
||||||
|
|
||||||
|
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||||
|
|
||||||
|
cmd := &cobra.Command{}
|
||||||
|
|
||||||
|
// Each command is a format with %q where the vault name goes.
|
||||||
|
commands := []struct {
|
||||||
|
command string
|
||||||
|
run func(c *cli.Instance, name string) error
|
||||||
|
}{
|
||||||
|
{"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)
|
||||||
|
}},
|
||||||
|
{"vault remove --force %q", func(c *cli.Instance, name string) error {
|
||||||
|
return c.RemoveVault(cmd, name, true)
|
||||||
|
}},
|
||||||
|
{"mv --force %q:x work:x", func(c *cli.Instance, name string) error {
|
||||||
|
return c.MoveSecret(cmd, name+":x", "work:x", true)
|
||||||
|
}},
|
||||||
|
{"mv --force default:x %q:x", func(c *cli.Instance, name string) error {
|
||||||
|
return c.MoveSecret(cmd, "default:x", name+":x", true)
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range commands {
|
||||||
|
for _, name := range []string{"", ".", "..", "a/b"} {
|
||||||
|
t.Run(fmt.Sprintf(tt.command, name), func(t *testing.T) {
|
||||||
|
requireRejectedAndUnchanged(t, before, vault.ValidateVaultName(name),
|
||||||
|
func(c *cli.Instance) error { return tt.run(c, name) })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestRemoveVersionRemovesOnlyThatVersion checks that `secret version rm`
|
// TestRemoveVersionRemovesOnlyThatVersion checks that `secret version rm`
|
||||||
// with a version that is not the current one removes that version and
|
// with a version that is not the current one removes that version and
|
||||||
// changes nothing else.
|
// changes nothing else.
|
||||||
|
|||||||
+11
-5
@@ -811,9 +811,9 @@ func (cli *Instance) moveSecret(
|
|||||||
cmd, vlt, srcSecretName, destSecretName, force)
|
cmd, vlt, srcSecretName, destSecretName, force)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both vaults must be existing vaults by exact name, so that two
|
// Both vault names must be valid and name existing vaults exactly, so
|
||||||
// spellings of one vault, such as "work" and "work/", are never taken for
|
// that two spellings of one vault, such as "work" and "work/", are never
|
||||||
// two vaults. A named vault does not become the current vault.
|
// taken for two vaults. A named vault does not become the current vault.
|
||||||
srcVault, err := cli.existingVault(srcVaultName)
|
srcVault, err := cli.existingVault(srcVaultName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -833,9 +833,15 @@ func (cli *Instance) moveSecret(
|
|||||||
cmd, srcVault, srcSecretName, destVault, destSecretName, force)
|
cmd, srcVault, srcSecretName, destVault, destSecretName, force)
|
||||||
}
|
}
|
||||||
|
|
||||||
// existingVault returns the vault with the given name, or an error if there
|
// existingVault returns the vault with the given name, or an error if the
|
||||||
// is none. Unlike vault.SelectVault, it leaves the current vault as it is.
|
// name is not a valid vault name or there is no such vault. Unlike
|
||||||
|
// vault.SelectVault, it leaves the current vault as it is.
|
||||||
func (cli *Instance) existingVault(name string) (*vault.Vault, error) {
|
func (cli *Instance) existingVault(name string) (*vault.Vault, error) {
|
||||||
|
err := vault.ValidateVaultName(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
vaults, err := vault.ListVaults(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to list vaults: %w", err)
|
return nil, fmt.Errorf("failed to list vaults: %w", err)
|
||||||
|
|||||||
@@ -5,18 +5,88 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.eeqj.de/sneak/secret/internal/secret"
|
||||||
|
"git.eeqj.de/sneak/secret/internal/vault"
|
||||||
|
"github.com/awnumar/memguard"
|
||||||
|
"github.com/spf13/afero"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// unknownTestGPGUserID is a GPG user ID that no key in the test keyring has.
|
// unknownTestGPGUserID is a GPG user ID that no key in the test keyring has.
|
||||||
const unknownTestGPGUserID = "not-in-keyring@example.com"
|
const unknownTestGPGUserID = "not-in-keyring@example.com"
|
||||||
|
|
||||||
|
// The secret TestAddPGPUnlocker stores, then reads through the new unlocker.
|
||||||
|
const (
|
||||||
|
addTestSecretName = "api-key"
|
||||||
|
addTestSecretValue = "value"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAddPGPUnlocker adds a PGP unlocker for a throwaway GPG key to a vault
|
||||||
|
// with a passphrase unlocker, getting the vault's long-term key from the
|
||||||
|
// mnemonic or, with the mnemonic unset, from the passphrase unlocker. It
|
||||||
|
// then reads a secret with neither the mnemonic nor the passphrase set, so
|
||||||
|
// through the new unlocker, which the add selects.
|
||||||
|
func TestAddPGPUnlocker(t *testing.T) {
|
||||||
|
newTestGPGKey(t)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
// mnemonic is the mnemonic set while the unlocker is added.
|
||||||
|
mnemonic string
|
||||||
|
}{
|
||||||
|
{"long-term key from the mnemonic", testMnemonic},
|
||||||
|
{"long-term key from the current unlocker", ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
t.Setenv(secret.EnvMnemonic, testMnemonic)
|
||||||
|
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
|
||||||
|
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
vlt, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = vlt.AddSecret(addTestSecretName,
|
||||||
|
memguard.NewBufferFromBytes([]byte(addTestSecretValue)), false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
_, err = vlt.CreatePassphraseUnlocker(
|
||||||
|
memguard.NewBufferFromBytes([]byte(testPassphrase)))
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Setenv(secret.EnvMnemonic, test.mnemonic)
|
||||||
|
|
||||||
|
instance, cmd := newTestInstance(fs)
|
||||||
|
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
|
||||||
|
require.NoError(t, instance.UnlockersAdd(unlockerTypePGP, cmd))
|
||||||
|
|
||||||
|
t.Setenv(secret.EnvMnemonic, "")
|
||||||
|
t.Setenv(secret.EnvUnlockPassphrase, "")
|
||||||
|
|
||||||
|
reopened := vault.NewVault(fs, listTestStateDir, listTestVaultName)
|
||||||
|
|
||||||
|
current, err := reopened.GetCurrentUnlocker()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, unlockerTypePGP, current.GetType())
|
||||||
|
|
||||||
|
value, err := reopened.GetSecret(addTestSecretName)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
defer value.Destroy()
|
||||||
|
|
||||||
|
assert.Equal(t, addTestSecretValue, value.String())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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
|
||||||
// lookup moved after anything is written would also come after getting the
|
// lookup moved after anything is written would also come after getting the
|
||||||
// vault's long-term key, which fails first on every platform but macOS
|
// vault's long-term key, which fails first here: this vault's unlockers hold
|
||||||
// (https://git.eeqj.de/sneak/secret/issues/88).
|
// no keys.
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
|
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
|
||||||
func TestAddPGPUnlockerUnknownKey(t *testing.T) {
|
func TestAddPGPUnlockerUnknownKey(t *testing.T) {
|
||||||
|
|||||||
@@ -122,7 +122,8 @@ func assertDirEntries(t *testing.T, fs afero.Fs, dir string, want ...string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// newTestGPGKey points GNUPGHOME at a fresh directory, generates a GPG key
|
// newTestGPGKey points GNUPGHOME at a fresh directory, generates a GPG key
|
||||||
// without a passphrase there, and returns the key's fingerprint.
|
// without a passphrase there, with a subkey for encryption, and returns the
|
||||||
|
// key's fingerprint.
|
||||||
func newTestGPGKey(t *testing.T) string {
|
func newTestGPGKey(t *testing.T) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
@@ -151,6 +152,14 @@ func newTestGPGKey(t *testing.T) string {
|
|||||||
fingerprint, err := secret.ResolveGPGKeyFingerprint(unreadableTestGPGUserID)
|
fingerprint, err := secret.ResolveGPGKeyFingerprint(unreadableTestGPGUserID)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
//nolint:gosec // G204: fingerprint is the test key's, as gpg printed it
|
||||||
|
output, err = exec.CommandContext(t.Context(), "gpg", "--batch",
|
||||||
|
"--pinentry-mode", "loopback", "--passphrase", "",
|
||||||
|
"--quick-add-key", fingerprint, "cv25519", "encr", "never",
|
||||||
|
).CombinedOutput()
|
||||||
|
require.NoError(t, err, "adding the test GPG key's encryption subkey: %s",
|
||||||
|
output)
|
||||||
|
|
||||||
return fingerprint
|
return fingerprint
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -462,6 +462,11 @@ func updateVaultImportMetadata(
|
|||||||
// VaultImport imports a mnemonic into a specific vault, holding the state
|
// VaultImport imports a mnemonic into a specific vault, holding the state
|
||||||
// directory lock while importMnemonic runs
|
// directory lock while importMnemonic runs
|
||||||
func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
|
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)
|
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -617,6 +622,11 @@ func (cli *Instance) switchAwayFromVault(
|
|||||||
// RemoveVault removes a vault, holding the state directory lock while
|
// RemoveVault removes a vault, holding the state directory lock while
|
||||||
// removeVault runs
|
// removeVault runs
|
||||||
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error {
|
func (cli *Instance) RemoveVault(cmd *cobra.Command, name string, force bool) error {
|
||||||
|
err := vault.ValidateVaultName(name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
release, err := vault.LockStateDir(cli.fs, cli.stateDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"filippo.io/age"
|
||||||
"git.eeqj.de/sneak/secret/pkg/agehd"
|
"git.eeqj.de/sneak/secret/pkg/agehd"
|
||||||
"github.com/awnumar/memguard"
|
"github.com/awnumar/memguard"
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
@@ -32,6 +33,7 @@ func (v *realVault) GetFilesystem() afero.Fs { return v.fs }
|
|||||||
// Unused by getLongTermPrivateKey — these satisfy VaultInterface.
|
// Unused by getLongTermPrivateKey — these satisfy VaultInterface.
|
||||||
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error { panic("not used") }
|
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) error { panic("not used") }
|
||||||
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { panic("not used") }
|
func (v *realVault) GetCurrentUnlocker() (Unlocker, error) { panic("not used") }
|
||||||
|
func (v *realVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) { panic("not used") }
|
||||||
func (v *realVault) CreatePassphraseUnlocker(*memguard.LockedBuffer) (*PassphraseUnlocker, error) {
|
func (v *realVault) CreatePassphraseUnlocker(*memguard.LockedBuffer) (*PassphraseUnlocker, error) {
|
||||||
panic("not used")
|
panic("not used")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
|
|
||||||
"filippo.io/age"
|
"filippo.io/age"
|
||||||
"github.com/awnumar/memguard"
|
|
||||||
"github.com/spf13/afero"
|
"github.com/spf13/afero"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -79,10 +78,3 @@ func (k *KeychainUnlocker) Remove() error {
|
|||||||
func CreateKeychainUnlocker(_ afero.Fs, _ string) (*KeychainUnlocker, error) {
|
func CreateKeychainUnlocker(_ afero.Fs, _ string) (*KeychainUnlocker, error) {
|
||||||
return nil, errKeychainNotSupported
|
return nil, errKeychainNotSupported
|
||||||
}
|
}
|
||||||
|
|
||||||
// getLongTermPrivateKey returns an error on non-Darwin platforms
|
|
||||||
func getLongTermPrivateKey(
|
|
||||||
_ afero.Fs, _ VaultInterface,
|
|
||||||
) (*memguard.LockedBuffer, error) {
|
|
||||||
return nil, errKeychainNotSupported
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -277,7 +277,7 @@ func CreatePGPUnlocker(
|
|||||||
// Step 2: Encrypt the long-term private key to the new keypair, and the
|
// Step 2: Encrypt the long-term private key to the new keypair, and the
|
||||||
// keypair's private key to the GPG key
|
// keypair's private key to the GPG key
|
||||||
encryptedLtPrivKey, encryptedAgePrivKey, err := encryptPGPUnlockerKeys(
|
encryptedLtPrivKey, encryptedAgePrivKey, err := encryptPGPUnlockerKeys(
|
||||||
fs, vault, ageIdentity, gpgKeyID)
|
vault, ageIdentity, gpgKeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -316,14 +316,15 @@ func CreatePGPUnlocker(
|
|||||||
// to the new PGP unlocker's age keypair, and that keypair's private key
|
// to the new PGP unlocker's age keypair, and that keypair's private key
|
||||||
// encrypted to the GPG key gpgKeyID.
|
// encrypted to the GPG key gpgKeyID.
|
||||||
func encryptPGPUnlockerKeys(
|
func encryptPGPUnlockerKeys(
|
||||||
fs afero.Fs, vault VaultInterface,
|
vault VaultInterface, ageIdentity *age.X25519Identity, gpgKeyID string,
|
||||||
ageIdentity *age.X25519Identity, gpgKeyID string,
|
|
||||||
) ([]byte, []byte, error) {
|
) ([]byte, []byte, error) {
|
||||||
// Get or derive the long-term private key
|
// From the mnemonic or the current unlocker, as for a passphrase unlocker
|
||||||
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault)
|
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, fmt.Errorf("failed to get long-term key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ltPrivKeyData := memguard.NewBufferFromBytes([]byte(ltIdentity.String()))
|
||||||
defer ltPrivKeyData.Destroy()
|
defer ltPrivKeyData.Destroy()
|
||||||
|
|
||||||
encryptedLtPrivKey, err := EncryptToRecipient(
|
encryptedLtPrivKey, err := EncryptToRecipient(
|
||||||
|
|||||||
@@ -40,9 +40,7 @@ func installFakeGPG(t *testing.T) {
|
|||||||
// TestCreatePGPUnlockerFailureWritesNothing makes CreatePGPUnlocker fail at
|
// TestCreatePGPUnlockerFailureWritesNothing makes CreatePGPUnlocker fail at
|
||||||
// getting the vault's long-term key, which used to come after part of the
|
// getting the vault's long-term key, which used to come after part of the
|
||||||
// unlocker was written, and asserts that nothing is written. Getting the key
|
// unlocker was written, and asserts that nothing is written. Getting the key
|
||||||
// fails because on macOS there is no mnemonic and no current unlocker, and
|
// fails because there is no mnemonic and no current unlocker.
|
||||||
// on every other platform it always fails
|
|
||||||
// (https://git.eeqj.de/sneak/secret/issues/88).
|
|
||||||
func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
|
func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
|
||||||
installFakeGPG(t)
|
installFakeGPG(t)
|
||||||
t.Setenv(secret.EnvMnemonic, "")
|
t.Setenv(secret.EnvMnemonic, "")
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ type VaultInterface interface {
|
|||||||
GetName() string
|
GetName() string
|
||||||
GetFilesystem() afero.Fs
|
GetFilesystem() afero.Fs
|
||||||
GetCurrentUnlocker() (Unlocker, error)
|
GetCurrentUnlocker() (Unlocker, error)
|
||||||
|
GetOrDeriveLongTermKey() (*age.X25519Identity, error)
|
||||||
CreatePassphraseUnlocker(
|
CreatePassphraseUnlocker(
|
||||||
passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
|
passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -107,6 +107,10 @@ func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
|
|||||||
return nil, errNotImplementedInMock
|
return nil, errNotImplementedInMock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||||
|
return nil, errNotImplementedInMock
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockVault) CreatePassphraseUnlocker(
|
func (m *MockVault) CreatePassphraseUnlocker(
|
||||||
_ *memguard.LockedBuffer,
|
_ *memguard.LockedBuffer,
|
||||||
) (*PassphraseUnlocker, error) {
|
) (*PassphraseUnlocker, error) {
|
||||||
|
|||||||
@@ -87,6 +87,10 @@ func (m *MockVersionVault) GetCurrentUnlocker() (secret.Unlocker, error) {
|
|||||||
return nil, errNotImplementedInMock
|
return nil, errNotImplementedInMock
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockVersionVault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||||
|
return nil, errNotImplementedInMock
|
||||||
|
}
|
||||||
|
|
||||||
func (m *MockVersionVault) CreatePassphraseUnlocker(
|
func (m *MockVersionVault) CreatePassphraseUnlocker(
|
||||||
_ *memguard.LockedBuffer,
|
_ *memguard.LockedBuffer,
|
||||||
) (*secret.PassphraseUnlocker, error) {
|
) (*secret.PassphraseUnlocker, error) {
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ var (
|
|||||||
"derived public key does not match vault: mnemonic may be incorrect",
|
"derived public key does not match vault: mnemonic may be incorrect",
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrInvalidVaultName indicates a vault name that does not match the
|
// ErrInvalidVaultName indicates a vault name that breaks the naming
|
||||||
// allowed pattern [a-z0-9.\-_]+. Composed as
|
// rule: only lowercase ASCII letters, digits, '.', '-' and '_'; not
|
||||||
// "invalid vault name '<name>': must match pattern [a-z0-9.\-_]+".
|
// empty, "." or "..". Composed by ValidateVaultName as
|
||||||
|
// "invalid vault name '<name>': <the rule>".
|
||||||
ErrInvalidVaultName = errors.New("invalid vault name")
|
ErrInvalidVaultName = errors.New("invalid vault name")
|
||||||
|
|
||||||
// ErrVaultNotFound indicates the named vault does not exist. Composed
|
// ErrVaultNotFound indicates the named vault does not exist. Composed
|
||||||
|
|||||||
@@ -24,10 +24,12 @@ func init() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// isValidVaultName validates vault names according to the format [a-z0-9\.\-\_]+
|
// isValidVaultName reports whether name is a valid vault name: only
|
||||||
// Note: We don't allow slashes in vault names unlike secret names
|
// lowercase ASCII letters, digits, '.', '-' and '_', and not empty, "." or
|
||||||
|
// "..". With no path separator allowed, a vault is always one directory
|
||||||
|
// directly under vaults.d.
|
||||||
func isValidVaultName(name string) bool {
|
func isValidVaultName(name string) bool {
|
||||||
if name == "" {
|
if name == "" || name == "." || name == ".." {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +38,21 @@ func isValidVaultName(name string) bool {
|
|||||||
return matched
|
return matched
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateVaultName returns an error wrapping ErrInvalidVaultName when name
|
||||||
|
// is not a valid vault name. Call it on the name exactly as the user gave it,
|
||||||
|
// before building any path from it.
|
||||||
|
func ValidateVaultName(name string) error {
|
||||||
|
if !isValidVaultName(name) {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"%w '%s': only lowercase ASCII letters, digits, '.', '-' and '_' "+
|
||||||
|
"are allowed, and a name must not be empty, '.' or '..'",
|
||||||
|
ErrInvalidVaultName, name,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ResolveVaultSymlink reads the currentvault file to get the path to the current vault
|
// ResolveVaultSymlink reads the currentvault file to get the path to the current vault
|
||||||
// The file contains just the vault name (e.g., "default")
|
// The file contains just the vault name (e.g., "default")
|
||||||
func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
|
func ResolveVaultSymlink(fs afero.Fs, currentVaultPath string) (string, error) {
|
||||||
@@ -199,14 +216,11 @@ func processMnemonicForVault(
|
|||||||
func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
||||||
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
|
secret.Debug("Creating new vault", "name", name, "state_dir", stateDir)
|
||||||
|
|
||||||
// Validate vault name
|
err := ValidateVaultName(name)
|
||||||
if !isValidVaultName(name) {
|
if err != nil {
|
||||||
secret.Debug("Invalid vault name provided", "vault_name", name)
|
secret.Debug("Invalid vault name provided", "vault_name", name)
|
||||||
|
|
||||||
return nil, fmt.Errorf(
|
return nil, err
|
||||||
"%w '%s': must match pattern [a-z0-9.\\-_]+",
|
|
||||||
ErrInvalidVaultName, name,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Vault name validation passed", "vault_name", name)
|
secret.Debug("Vault name validation passed", "vault_name", name)
|
||||||
@@ -285,14 +299,11 @@ func CreateVault(fs afero.Fs, stateDir string, name string) (*Vault, error) {
|
|||||||
func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
func SelectVault(fs afero.Fs, stateDir string, name string) error {
|
||||||
secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir)
|
secret.Debug("Selecting vault", "vault_name", name, "state_dir", stateDir)
|
||||||
|
|
||||||
// Validate vault name
|
err := ValidateVaultName(name)
|
||||||
if !isValidVaultName(name) {
|
if err != nil {
|
||||||
secret.Debug("Invalid vault name provided", "vault_name", name)
|
secret.Debug("Invalid vault name provided", "vault_name", name)
|
||||||
|
|
||||||
return fmt.Errorf(
|
return err
|
||||||
"%w '%s': must match pattern [a-z0-9.\\-_]+",
|
|
||||||
ErrInvalidVaultName, name,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
secret.Debug("Vault name validation passed", "vault_name", name)
|
secret.Debug("Vault name validation passed", "vault_name", name)
|
||||||
|
|||||||
Reference in New Issue
Block a user