Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66a0714f92 | ||
|
|
62967f28d0 |
@@ -33,10 +33,21 @@ Bring the repo into policy compliance in one commit:
|
|||||||
`vault.CreateVault` takes the mnemonic (nil for none), a `Vault` derives its
|
`vault.CreateVault` takes the mnemonic (nil for none), a `Vault` derives its
|
||||||
long-term key from its `Mnemonic` and gives its `UnlockPassphrase` to a
|
long-term key from its `Mnemonic` and gives its `UnlockPassphrase` to a
|
||||||
passphrase unlocker, and the PGP, keychain and Secure Enclave unlocker
|
passphrase unlocker, and the PGP, keychain and Secure Enclave unlocker
|
||||||
constructors take both. `init` and `vault create` no longer put the
|
constructors take both. `CreatePGPUnlocker` sets both on the vault it
|
||||||
mnemonic into the environment. Unsetting erases nothing: the starting
|
loads, through `SetMnemonic` and `SetUnlockPassphrase`, now part of
|
||||||
environment (`/proc/<pid>/environ`) and memory still hold the value. The
|
`VaultInterface`, before calling its `GetOrDeriveLongTermKey`. `init` and
|
||||||
README warns against both variables.
|
`vault create` no longer put the mnemonic into the environment. Unsetting
|
||||||
|
erases nothing: the starting environment (`/proc/<pid>/environ`) and
|
||||||
|
memory still hold the value. The README warns against both variables.
|
||||||
|
- 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,
|
- 2026-10-04: A vault name may use only lowercase ASCII letters, digits,
|
||||||
`.`, `-` and `_`, and must not be empty, `.` or `..`
|
`.`, `-` and `_`, and must not be empty, `.` or `..`
|
||||||
(https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md`
|
(https://git.eeqj.de/sneak/secret/issues/68); the error and `README.md`
|
||||||
|
|||||||
@@ -5,18 +5,88 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"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 no mnemonic given, from the passphrase unlocker. It
|
||||||
|
// then reads a secret with neither the mnemonic nor the passphrase given, so
|
||||||
|
// through the new unlocker, which the add selects.
|
||||||
|
//
|
||||||
|
//nolint:paralleltest // t.Setenv (GNUPGHOME) forbids parallel tests
|
||||||
|
func TestAddPGPUnlocker(t *testing.T) {
|
||||||
|
newTestGPGKey(t)
|
||||||
|
|
||||||
|
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
|
||||||
|
t.Cleanup(passphrase.Destroy)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
// mnemonic is the mnemonic given while the unlocker is added, or nil.
|
||||||
|
mnemonic *memguard.LockedBuffer
|
||||||
|
}{
|
||||||
|
{"long-term key from the mnemonic", testMnemonicBuffer(t)},
|
||||||
|
{"long-term key from the current unlocker", nil},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
fs := afero.NewMemMapFs()
|
||||||
|
vlt, err := vault.CreateVault(fs, listTestStateDir, listTestVaultName,
|
||||||
|
testMnemonicBuffer(t))
|
||||||
|
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)
|
||||||
|
|
||||||
|
instance, cmd := newTestInstance(fs)
|
||||||
|
instance.Mnemonic = test.mnemonic
|
||||||
|
instance.UnlockPassphrase = passphrase
|
||||||
|
|
||||||
|
cmd.Flags().String("keyid", unreadableTestGPGUserID, "")
|
||||||
|
require.NoError(t, instance.UnlockersAdd(unlockerTypePGP, cmd))
|
||||||
|
|
||||||
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,9 @@ 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) SetMnemonic(*memguard.LockedBuffer) { panic("not used") }
|
||||||
|
func (v *realVault) SetUnlockPassphrase(*memguard.LockedBuffer) { 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")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,10 +81,3 @@ func CreateKeychainUnlocker(
|
|||||||
) (*KeychainUnlocker, error) {
|
) (*KeychainUnlocker, error) {
|
||||||
return nil, errKeychainNotSupported
|
return nil, errKeychainNotSupported
|
||||||
}
|
}
|
||||||
|
|
||||||
// getLongTermPrivateKey returns an error on non-Darwin platforms
|
|
||||||
func getLongTermPrivateKey(
|
|
||||||
_ afero.Fs, _ VaultInterface, _, _ *memguard.LockedBuffer,
|
|
||||||
) (*memguard.LockedBuffer, error) {
|
|
||||||
return nil, errKeychainNotSupported
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -271,6 +271,10 @@ func CreatePGPUnlocker(
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The vault's GetOrDeriveLongTermKey, in step 2, uses both
|
||||||
|
vault.SetMnemonic(mnemonic)
|
||||||
|
vault.SetUnlockPassphrase(passphrase)
|
||||||
|
|
||||||
// Step 1: Generate a new age keypair for the PGP unlocker
|
// Step 1: Generate a new age keypair for the PGP unlocker
|
||||||
ageIdentity, err := age.GenerateX25519Identity()
|
ageIdentity, err := age.GenerateX25519Identity()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -280,7 +284,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, mnemonic, passphrase)
|
vault, ageIdentity, gpgKeyID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -319,15 +323,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,
|
|
||||||
mnemonic, passphrase *memguard.LockedBuffer,
|
|
||||||
) ([]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, mnemonic, passphrase)
|
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).
|
|
||||||
//
|
//
|
||||||
//nolint:paralleltest // installFakeGPG uses t.Setenv
|
//nolint:paralleltest // installFakeGPG uses t.Setenv
|
||||||
func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
|
func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ type VaultInterface interface {
|
|||||||
GetName() string
|
GetName() string
|
||||||
GetFilesystem() afero.Fs
|
GetFilesystem() afero.Fs
|
||||||
GetCurrentUnlocker() (Unlocker, error)
|
GetCurrentUnlocker() (Unlocker, error)
|
||||||
|
GetOrDeriveLongTermKey() (*age.X25519Identity, error)
|
||||||
|
// SetMnemonic and SetUnlockPassphrase give GetOrDeriveLongTermKey the
|
||||||
|
// mnemonic to derive the long-term key from, and the passphrase for a
|
||||||
|
// current passphrase unlocker; nil for none.
|
||||||
|
SetMnemonic(mnemonic *memguard.LockedBuffer)
|
||||||
|
SetUnlockPassphrase(passphrase *memguard.LockedBuffer)
|
||||||
CreatePassphraseUnlocker(
|
CreatePassphraseUnlocker(
|
||||||
passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
|
passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,6 +108,16 @@ 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) SetMnemonic(mnemonic *memguard.LockedBuffer) {
|
||||||
|
m.mnemonic = mnemonic
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
|
||||||
|
|
||||||
func (m *MockVault) CreatePassphraseUnlocker(
|
func (m *MockVault) CreatePassphraseUnlocker(
|
||||||
_ *memguard.LockedBuffer,
|
_ *memguard.LockedBuffer,
|
||||||
) (*PassphraseUnlocker, error) {
|
) (*PassphraseUnlocker, error) {
|
||||||
|
|||||||
@@ -87,6 +87,14 @@ 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) SetMnemonic(_ *memguard.LockedBuffer) {}
|
||||||
|
|
||||||
|
func (m *MockVersionVault) SetUnlockPassphrase(_ *memguard.LockedBuffer) {}
|
||||||
|
|
||||||
func (m *MockVersionVault) CreatePassphraseUnlocker(
|
func (m *MockVersionVault) CreatePassphraseUnlocker(
|
||||||
_ *memguard.LockedBuffer,
|
_ *memguard.LockedBuffer,
|
||||||
) (*secret.PassphraseUnlocker, error) {
|
) (*secret.PassphraseUnlocker, error) {
|
||||||
|
|||||||
@@ -63,6 +63,18 @@ func (v *Vault) ClearLongTermKey() {
|
|||||||
v.longTermKey = nil
|
v.longTermKey = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMnemonic sets v.Mnemonic, for code that has v only as a
|
||||||
|
// secret.VaultInterface.
|
||||||
|
func (v *Vault) SetMnemonic(mnemonic *memguard.LockedBuffer) {
|
||||||
|
v.Mnemonic = mnemonic
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUnlockPassphrase sets v.UnlockPassphrase, for code that has v only as
|
||||||
|
// a secret.VaultInterface.
|
||||||
|
func (v *Vault) SetUnlockPassphrase(passphrase *memguard.LockedBuffer) {
|
||||||
|
v.UnlockPassphrase = passphrase
|
||||||
|
}
|
||||||
|
|
||||||
// GetOrDeriveLongTermKey gets the long-term key from memory or derives it
|
// GetOrDeriveLongTermKey gets the long-term key from memory or derives it
|
||||||
// from available sources
|
// from available sources
|
||||||
func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
func (v *Vault) GetOrDeriveLongTermKey() (*age.X25519Identity, error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user