Read secret environment variables once per command, then unset them (closes #60)
check / check (push) Failing after 1s

init and vault create put the mnemonic into the process environment for
vault.CreateVault to read back, so every program they ran, gpg included,
inherited it, and SB_SECRET_MNEMONIC and SB_UNLOCK_PASSPHRASE were read
at 13 places and never unset. Each command that may need them now reads
both once, in its RunE, into locked buffers on the CLI Instance, and
unsets them at once. The buffers are passed down: vault.CreateVault
takes the mnemonic, a Vault carries Mnemonic and UnlockPassphrase, and
the PGP, keychain and Secure Enclave unlocker constructors take both;
CreatePGPUnlocker sets them on the vault it loads through SetMnemonic
and SetUnlockPassphrase, new in VaultInterface. README warns against
both variables.

Model: opus-5-5
This commit was merged in pull request #94.
This commit is contained in:
2026-10-04 16:07:57 +02:00
parent 5ca615a7a6
commit db7d2c952e
42 changed files with 727 additions and 428 deletions
+25 -21
View File
@@ -219,7 +219,7 @@ func newVaultWithSecret(
) *vault.Vault {
t.Helper()
vlt, err := vault.CreateVault(fs, stateDir, name)
vlt, err := vault.CreateVault(fs, stateDir, name, testMnemonicBuffer(t))
require.NoError(t, err)
buffer := memguard.NewBufferFromBytes([]byte(value))
@@ -329,14 +329,14 @@ func TestRemoveDirAtomic(t *testing.T) {
// named with 255 bytes, the most a file name may have, on the real
// filesystem: the temporary directories they use must fit that limit too.
func TestLongestNames(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Parallel()
const longestName = 255
fs := afero.NewOsFs()
name := strings.Repeat("a", longestName)
vlt, err := vault.CreateVault(fs, t.TempDir(), name)
vlt, err := vault.CreateVault(fs, t.TempDir(), name, testMnemonicBuffer(t))
require.NoError(t, err)
value := memguard.NewBufferFromBytes([]byte("long"))
@@ -361,13 +361,13 @@ func TestLongestNames(t *testing.T) {
// another vault, as a forced move between vaults does, and makes the last
// step that completes the copy fail. The secret it was to replace must
// still be there unchanged: it may go only once its replacement is whole.
//
//nolint:paralleltest // t.Setenv forbids t.Parallel
func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t)
src := newVaultWithSecret(t, base, stateDir, "source", "new")
dest := newVaultWithSecret(t, base, stateDir, "dest", "old")
@@ -400,13 +400,13 @@ func TestForcedCopyKeepsDestinationUntilReplaced(t *testing.T) {
// directory directly in secrets.d or in a versions directory. Those are
// listed to find secrets and versions, so a temporary directory made there
// would be listed while half-built, and one left by a crash would stay.
//
//nolint:paralleltest // t.Setenv forbids t.Parallel
func TestTempDirsStayOutOfListings(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t)
newVaultWithSecret(t, base, stateDir, "default", "first")
@@ -419,6 +419,7 @@ func TestTempDirsStayOutOfListings(t *testing.T) {
return nil
}}
vlt := vault.NewVault(fs, stateDir, "default")
vlt.Mnemonic = testMnemonicBuffer(t)
value := memguard.NewBufferFromBytes([]byte("second"))
defer value.Destroy()
@@ -527,13 +528,13 @@ func TestVersionSaveFailureLeavesNothing(t *testing.T) {
// unlocker again and checks, before each change this makes, that the file
// naming the current one exists: a reader or a crash never finds it
// missing.
//
//nolint:paralleltest // t.Setenv forbids t.Parallel
func TestCurrentFilesNeverMissing(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Parallel()
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t)
vlt := newVaultWithSecret(t, base, stateDir, testVaultName, "value")
@@ -625,11 +626,11 @@ func TestWriteFileAtomicTempFile(t *testing.T) {
// anything, so that it never leaves a partial unlocker, nor breaks the one
// it would replace.
func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) {
// No mnemonic, and no current unlocker to get the key from
t.Setenv(secret.EnvMnemonic, "")
t.Parallel()
// No mnemonic, and no current unlocker to get the key from
base := afero.NewMemMapFs()
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName)
_, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil)
require.NoError(t, err)
fs := hookFs{Fs: base, before: func(_, path string) error {
@@ -650,17 +651,18 @@ func TestPassphraseUnlockerGetsKeyFirst(t *testing.T) {
// creating a passphrase unlocker makes, that the unlocker's directory either
// does not exist or holds all of its files: a crash or a failure at any point
// leaves no partial unlocker.
//
//nolint:paralleltest // t.Setenv forbids t.Parallel
func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Parallel()
files := []string{"pub.age", privKeyFile, "longterm.age", unlockerMetadataFile}
for _, tfs := range testFilesystems {
t.Run(tfs.name, func(t *testing.T) {
t.Parallel()
base, stateDir := tfs.open(t)
vlt, err := vault.CreateVault(base, stateDir, testVaultName)
vlt, err := vault.CreateVault(base, stateDir, testVaultName,
testMnemonicBuffer(t))
require.NoError(t, err)
vaultDir, err := vlt.GetDirectory()
@@ -683,8 +685,10 @@ func TestPassphraseUnlockerIsWholeOrAbsent(t *testing.T) {
passphrase := memguard.NewBufferFromBytes([]byte(unlockerPassphrase))
defer passphrase.Destroy()
_, err = vault.NewVault(fs, stateDir, testVaultName).
CreatePassphraseUnlocker(passphrase)
hooked := vault.NewVault(fs, stateDir, testVaultName)
hooked.Mnemonic = vlt.Mnemonic
_, err = hooked.CreatePassphraseUnlocker(passphrase)
require.NoError(t, err)
assert.ElementsMatch(t, files, dirNames(t, base, unlockerDir))
})
+7 -2
View File
@@ -34,6 +34,8 @@ func (v *realVault) GetFilesystem() afero.Fs { return v.fs }
func (v *realVault) AddSecret(string, *memguard.LockedBuffer, bool) 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) {
panic("not used")
}
@@ -59,6 +61,8 @@ func createRealVault(t *testing.T, fs afero.Fs, stateDir, name string, derivatio
}
func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
t.Parallel()
const testMnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
// Derive expected keys at two different indices to prove they differ.
@@ -73,9 +77,10 @@ func TestGetLongTermPrivateKeyUsesVaultDerivationIndex(t *testing.T) {
fs := afero.NewMemMapFs()
vault := createRealVault(t, fs, "/state", "test-vault", 5)
t.Setenv(EnvMnemonic, testMnemonic)
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
defer mnemonic.Destroy()
result, err := getLongTermPrivateKey(fs, vault)
result, err := getLongTermPrivateKey(fs, vault, mnemonic, nil)
require.NoError(t, err)
defer result.Destroy()
+19 -9
View File
@@ -239,12 +239,14 @@ func generateKeychainUnlockerName(vaultName string) (string, error) {
return fmt.Sprintf("secret-%s-%s-%s", vaultName, hostname, enrollmentDate), nil
}
// getLongTermPrivateKey retrieves the long-term private key either from environment or current unlocker
// getLongTermPrivateKey derives the long-term private key from mnemonic when
// it is not nil, else gets it through the current unlocker, which is given
// passphrase when it is a passphrase unlocker.
// Returns a LockedBuffer to ensure the private key is protected in memory
func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedBuffer, error) {
// Check if mnemonic is available in environment variable
envMnemonic := os.Getenv(EnvMnemonic)
if envMnemonic != "" {
func getLongTermPrivateKey(
fs afero.Fs, vault VaultInterface, mnemonic, passphrase *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
if mnemonic != nil {
// Read vault metadata to get the correct derivation index
vaultDir, err := vault.GetDirectory()
if err != nil {
@@ -263,7 +265,7 @@ func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedB
}
// Use mnemonic with the vault's actual derivation index
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
ltIdentity, err := agehd.DeriveIdentity(mnemonic.String(), metadata.DerivationIndex)
if err != nil {
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
}
@@ -278,6 +280,10 @@ func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedB
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
}
if passphraseUnlocker, ok := currentUnlocker.(*PassphraseUnlocker); ok {
passphraseUnlocker.Passphrase = passphrase
}
// Get the current unlocker identity
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
if err != nil {
@@ -322,8 +328,12 @@ func getLongTermPrivateKey(fs afero.Fs, vault VaultInterface) (*memguard.LockedB
return ltPrivKeyBuffer, nil
}
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the vault
func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, error) {
// CreateKeychainUnlocker creates a new keychain unlocker and stores it in the
// vault. The long-term key comes from mnemonic when it is not nil, else from
// the current unlocker, as getLongTermPrivateKey describes.
func CreateKeychainUnlocker(
fs afero.Fs, stateDir string, mnemonic, passphrase *memguard.LockedBuffer,
) (*KeychainUnlocker, error) {
// Check if we're on macOS
if err := checkMacOSAvailable(); err != nil {
return nil, err
@@ -376,7 +386,7 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
}
// Step 4: Get or derive the long-term private key
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault)
ltPrivKeyData, err := getLongTermPrivateKey(fs, vault, mnemonic, passphrase)
if err != nil {
return nil, err
}
+4 -1
View File
@@ -6,6 +6,7 @@ import (
"errors"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
@@ -75,6 +76,8 @@ func (k *KeychainUnlocker) Remove() error {
}
// CreateKeychainUnlocker returns an error on non-Darwin platforms
func CreateKeychainUnlocker(_ afero.Fs, _ string) (*KeychainUnlocker, error) {
func CreateKeychainUnlocker(
_ afero.Fs, _ string, _, _ *memguard.LockedBuffer,
) (*KeychainUnlocker, error) {
return nil, errKeychainNotSupported
}
+34 -19
View File
@@ -19,6 +19,17 @@ import (
const testMnemonic = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about"
// testMnemonicBuffer returns testMnemonic in a locked buffer that is
// destroyed when the test ends.
func testMnemonicBuffer(t *testing.T) *memguard.LockedBuffer {
t.Helper()
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
t.Cleanup(mnemonic.Destroy)
return mnemonic
}
// writeTestPublicKey writes the unlocker public key and verifies it exists.
func writeTestPublicKey(
t *testing.T, fs afero.Fs, unlockerDir string, agePublicKey string,
@@ -163,7 +174,7 @@ func newTestPassphraseUnlocker(
return unlocker, ageIdentity, unlockerDir
}
//nolint:paralleltest // subtests share real-FS state and t.Setenv, order matters
//nolint:paralleltest // subtests share real-FS state, order matters
func TestPassphraseUnlockerWithRealFS(t *testing.T) {
// This test uses real filesystem
if os.Getenv("CI") == "true" {
@@ -195,38 +206,42 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
writeTestLongTermKey(t, fs, unlockerDir, agePublicKey)
})
// Set test environment variable (cleaned up automatically)
t.Setenv(secret.EnvUnlockPassphrase, testPassphrase)
passphrase := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer passphrase.Destroy()
// Test getting identity from environment variable
t.Run("GetIdentityFromEnv", func(t *testing.T) {
identity, err := unlocker.GetIdentity()
if err != nil {
t.Fatalf("Failed to get identity from env: %v", err)
}
unlocker.Passphrase = passphrase
// Verify the identity matches what we expect
expectedPubKey := ageIdentity.Recipient().String()
// Test getting identity with the passphrase the unlocker was given,
// twice: using it must leave it intact for the next use
t.Run("GetIdentityWithPassphrase", func(t *testing.T) {
for range 2 {
identity, err := unlocker.GetIdentity()
if err != nil {
t.Fatalf("Failed to get identity with passphrase: %v", err)
}
actualPubKey := identity.Recipient().String()
if actualPubKey != expectedPubKey {
t.Errorf("Public key mismatch. Expected %s, got %s",
expectedPubKey, actualPubKey)
// Verify the identity matches what we expect
expectedPubKey := ageIdentity.Recipient().String()
actualPubKey := identity.Recipient().String()
if actualPubKey != expectedPubKey {
t.Errorf("Public key mismatch. Expected %s, got %s",
expectedPubKey, actualPubKey)
}
}
})
// Unset the environment variable to test interactive prompt
_ = os.Unsetenv(secret.EnvUnlockPassphrase)
unlocker.Passphrase = nil
// Test getting identity from prompt (this would require mocking the
// prompt). For real integration tests, we'd need a way to mock the
// passphrase input. Here we just verify the error is what we expect
// when no passphrase is available.
t.Run("GetIdentityWithoutEnv", func(t *testing.T) {
t.Run("GetIdentityWithoutPassphrase", func(t *testing.T) {
// This should fail since we're not in an interactive terminal
_, err := unlocker.GetIdentity()
if err == nil {
t.Errorf("Should have failed to get identity without passphrase env var")
t.Errorf("Should have failed to get identity without a passphrase")
}
})
+8 -18
View File
@@ -3,7 +3,6 @@ package secret
import (
"fmt"
"log/slog"
"os"
"path/filepath"
"filippo.io/age"
@@ -135,28 +134,19 @@ func (p *PassphraseUnlocker) Remove() error {
return nil
}
// getPassphrase retrieves the passphrase from memory, environment, or
// user input. Returns a LockedBuffer for secure memory handling
// getPassphrase returns a copy of p.Passphrase, or else asks the user for
// the passphrase. The caller must destroy the returned buffer.
func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
// First check if we already have the passphrase
if p.Passphrase != nil && p.Passphrase.IsAlive() {
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
// Return a copy of the passphrase buffer
return memguard.NewBufferFromBytes(p.Passphrase.Bytes()), nil
// Not NewBufferFromBytes, which would wipe p.Passphrase
passphrase := memguard.NewBuffer(p.Passphrase.Size())
passphrase.Copy(p.Passphrase.Bytes())
return passphrase, nil
}
Debug("No passphrase in memory, checking environment")
// Check environment variable for passphrase
passphraseStr := os.Getenv(EnvUnlockPassphrase)
if passphraseStr != "" {
Debug("Using passphrase from environment", "unlocker_id", p.GetID())
// Convert to secure buffer
secureBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
return secureBuffer, nil
}
Debug("No passphrase in environment, prompting user")
Debug("No passphrase in memory, prompting user")
// Prompt for passphrase
secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ")
if err != nil {
+5 -3
View File
@@ -227,8 +227,10 @@ Passphrase: ` + testPassphrase + `
// Test data
testMnemonic := "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonic))
defer mnemonic.Destroy()
// Set test environment variables
t.Setenv(secret.EnvMnemonic, testMnemonic)
t.Setenv(secret.EnvGPGKeyID, keyID)
// Set up vault structure for testing
@@ -244,7 +246,7 @@ Passphrase: ` + testPassphrase + `
defer timer.Stop()
// Create a test vault directory structure
vlt, err := vault.CreateVault(fs, stateDir, vaultName)
vlt, err := vault.CreateVault(fs, stateDir, vaultName, mnemonic)
if err != nil {
t.Fatalf("Failed to create vault: %v", err)
}
@@ -290,7 +292,7 @@ Passphrase: ` + testPassphrase + `
}
// Now create a PGP unlock key (this will use our custom GPGEncryptFunc)
pgpUnlocker, err := secret.CreatePGPUnlocker(fs, stateDir, keyID, fingerprint)
pgpUnlocker, err := secret.CreatePGPUnlocker(fs, stateDir, keyID, fingerprint, mnemonic, nil)
if err != nil {
t.Fatalf("Failed to create PGP unlock key: %v", err)
}
+8 -1
View File
@@ -254,9 +254,12 @@ func pgpUnlockerDir(
// fingerprint as ResolveGPGKeyFingerprint returns it, in the metadata.
// Everything that can fail short of writing a file is done before anything
// is written, and the files are written through WriteDir, so a failure
// leaves no partial unlocker.
// leaves no partial unlocker. The long-term key comes from mnemonic when it
// is not nil, else from the current unlocker, which is given passphrase when
// it is a passphrase unlocker.
func CreatePGPUnlocker(
fs afero.Fs, stateDir, gpgKeyID, fingerprint string,
mnemonic, passphrase *memguard.LockedBuffer,
) (*PGPUnlocker, error) {
err := checkGPGAvailable()
if err != nil {
@@ -268,6 +271,10 @@ func CreatePGPUnlocker(
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
ageIdentity, err := age.GenerateX25519Identity()
if err != nil {
+4 -3
View File
@@ -41,12 +41,13 @@ func installFakeGPG(t *testing.T) {
// 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
// fails because there is no mnemonic and no current unlocker.
//
//nolint:paralleltest // installFakeGPG uses t.Setenv
func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
installFakeGPG(t)
t.Setenv(secret.EnvMnemonic, "")
base := afero.NewMemMapFs()
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName)
vlt, err := vault.CreateVault(base, testVaultStateDir, testVaultName, nil)
require.NoError(t, err)
fs := hookFs{Fs: base, before: func(_, path string) error {
@@ -56,7 +57,7 @@ func TestCreatePGPUnlockerFailureWritesNothing(t *testing.T) {
}}
_, err = secret.CreatePGPUnlocker(
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint)
fs, testVaultStateDir, testGPGKeyID, testGPGFingerprint, nil, nil)
require.Error(t, err)
vaultDir, err := vlt.GetDirectory()
+17 -11
View File
@@ -5,7 +5,6 @@ import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
@@ -36,6 +35,11 @@ type VaultInterface interface {
GetFilesystem() afero.Fs
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(
passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
}
@@ -77,9 +81,12 @@ func NewSecret(vault VaultInterface, name string) *Secret {
}
}
// GetValue retrieves and decrypts the current version's value using the
// provided unlocker
func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
// GetValue retrieves and decrypts the current version's value, with the
// vault's long-term key derived from mnemonic when it is not nil, else
// obtained through unlocker
func (s *Secret) GetValue(
unlocker Unlocker, mnemonic *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
DebugWith("Getting secret value",
slog.String("secret_name", s.Name),
slog.String("vault_name", s.vault.GetName()),
@@ -114,9 +121,8 @@ func (s *Secret) GetValue(unlocker Unlocker) (*memguard.LockedBuffer, error) {
// Create version object
version := NewVersion(s.vault, s.Name, currentVersion)
// Check for SB_SECRET_MNEMONIC environment variable for direct decryption
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
return s.getValueViaMnemonic(version, envMnemonic)
if mnemonic != nil {
return s.getValueViaMnemonic(version, mnemonic.String())
}
Debug("Using unlocker for vault access", "secret_name", s.Name)
@@ -210,11 +216,11 @@ func (s *Secret) Exists() (bool, error) {
}
// getValueViaMnemonic derives the vault's long-term key from the
// mnemonic in the environment and decrypts the version value with it.
// mnemonic and decrypts the version value with it.
func (s *Secret) getValueViaMnemonic(
version *Version, envMnemonic string,
version *Version, mnemonic string,
) (*memguard.LockedBuffer, error) {
Debug("Using mnemonic from environment for direct long-term key derivation",
Debug("Using mnemonic for direct long-term key derivation",
"secret_name", s.Name)
// Get vault directory to read metadata
@@ -251,7 +257,7 @@ func (s *Secret) getValueViaMnemonic(
)
// Use mnemonic with the vault's derivation index from metadata
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, metadata.DerivationIndex)
ltIdentity, err := agehd.DeriveIdentity(mnemonic, metadata.DerivationIndex)
if err != nil {
Debug("Failed to derive long-term key from mnemonic for secret",
"error", err, "secret_name", s.Name)
+50 -23
View File
@@ -2,6 +2,7 @@
package secret
import (
"encoding/json"
"errors"
"os"
"path/filepath"
@@ -22,7 +23,7 @@ const testMnemonicValue = "abandon abandon abandon abandon abandon abandon " +
"abandon abandon abandon abandon abandon about"
var (
errMnemonicNotSet = errors.New("SB_SECRET_MNEMONIC not set")
errMnemonicNotSet = errors.New("mock vault has no mnemonic")
errNotImplementedInMock = errors.New("not implemented in mock")
)
@@ -32,6 +33,7 @@ type MockVault struct {
fs afero.Fs
directory string
derivationIndex uint32
mnemonic *memguard.LockedBuffer
}
func (m *MockVault) GetDirectory() (string, error) {
@@ -61,12 +63,11 @@ func (m *MockVault) AddSecret(name string, value *memguard.LockedBuffer, _ bool)
ltPubKeyPath := filepath.Join(m.directory, "pub.age")
// Derive long-term key using the vault's derivation index
mnemonic := os.Getenv(EnvMnemonic)
if mnemonic == "" {
if m.mnemonic == nil {
return errMnemonicNotSet
}
ltIdentity, err := agehd.DeriveIdentity(mnemonic, m.derivationIndex)
ltIdentity, err := agehd.DeriveIdentity(m.mnemonic.String(), m.derivationIndex)
if err != nil {
return err
}
@@ -111,6 +112,12 @@ 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(
_ *memguard.LockedBuffer,
) (*PassphraseUnlocker, error) {
@@ -238,13 +245,13 @@ func verifySecretFiles(t *testing.T, fs afero.Fs, vaultDir, secretName string) {
}
}
//nolint:paralleltest // uses t.Setenv (process-global environment)
//nolint:paralleltest // subtests share one vault, order matters
func TestPerSecretKeyFunctionality(t *testing.T) {
// Create an in-memory filesystem for testing
fs := afero.NewMemMapFs()
// Set test mnemonic for direct encryption/decryption
t.Setenv(EnvMnemonic, testMnemonicValue)
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
defer mnemonic.Destroy()
// Set up a test vault structure
baseDir := "/test-config/berlin.sneak.pkg.secret"
@@ -258,6 +265,7 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
fs: fs,
directory: vaultDir,
derivationIndex: 0,
mnemonic: mnemonic,
}
// Test data
@@ -314,26 +322,45 @@ func TestPerSecretKeyFunctionality(t *testing.T) {
})
}
func TestSecretGetValueWithEnvMnemonicUsesVaultDerivationIndex(t *testing.T) {
// This test demonstrates the bug where GetValue uses hardcoded index 0
// instead of the vault's actual derivation index when using environment mnemonic
// TestSecretGetValueWithMnemonicUsesVaultDerivationIndex checks that
// GetValue, given the mnemonic, derives the long-term key at the derivation
// index in the vault's metadata. At index 0 it could not decrypt the secret,
// which was encrypted to the key at index 1.
func TestSecretGetValueWithMnemonicUsesVaultDerivationIndex(t *testing.T) {
t.Parallel()
// Set up test mnemonic
t.Setenv(EnvMnemonic, testMnemonicValue)
fs := afero.NewMemMapFs()
vaultDir := "/test-config/vaults.d/test-vault"
// Create temporary directory for vaults
fs := afero.NewOsFs()
tempDir, err := afero.TempDir(fs, "", "secret-test-")
mnemonic := memguard.NewBufferFromBytes([]byte(testMnemonicValue))
defer mnemonic.Destroy()
vlt := &MockVault{
name: "test-vault",
fs: fs,
directory: vaultDir,
derivationIndex: 1,
mnemonic: mnemonic,
}
metadata, err := json.Marshal(VaultMetadata{DerivationIndex: vlt.derivationIndex})
require.NoError(t, err)
require.NoError(t, fs.MkdirAll(vaultDir, DirPerms))
err = afero.WriteFile(
fs, filepath.Join(vaultDir, "vault-metadata.json"), metadata, FilePerms)
require.NoError(t, err)
defer func() {
_ = fs.RemoveAll(tempDir)
}()
secretName, secretValue := "x", "value"
stateDir := filepath.Join(tempDir, ".secret")
require.NoError(t, fs.MkdirAll(stateDir, 0o700))
err = vlt.AddSecret(secretName,
memguard.NewBufferFromBytes([]byte(secretValue)), false)
require.NoError(t, err)
// This test is now in the integration test file where it can use real vaults
// The bug is demonstrated there - see test31EnvMnemonicUsesVaultDerivationIndex
t.Log("This test demonstrates the bug in the integration test file")
value, err := NewSecret(vlt, secretName).GetValue(nil, mnemonic)
require.NoError(t, err)
defer value.Destroy()
require.Equal(t, secretValue, value.String())
}
+14 -6
View File
@@ -207,9 +207,12 @@ func generateSEKeyLabel(vaultName string) (string, error) {
// CreateSecureEnclaveUnlocker creates a new SE unlocker.
// The vault's long-term private key is encrypted directly by the Secure Enclave
// using ECIES. No intermediate age keypair is used.
// The long-term key comes from mnemonic when it is not nil, else from the
// current unlocker, as getLongTermKeyForSE describes.
func CreateSecureEnclaveUnlocker(
fs afero.Fs,
stateDir string,
mnemonic, passphrase *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) {
if err := checkMacOSAvailable(); err != nil {
return nil, err
@@ -236,7 +239,7 @@ func CreateSecureEnclaveUnlocker(
Debug("Created SE key", "label", seKeyLabel, "hash", seKeyHash)
// Step 2: Get the vault's long-term private key
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault)
ltPrivKeyData, err := getLongTermKeyForSE(fs, vault, mnemonic, passphrase)
if err != nil {
return nil, fmt.Errorf(
"failed to get long-term private key: %w",
@@ -306,14 +309,15 @@ func CreateSecureEnclaveUnlocker(
}, nil
}
// getLongTermKeyForSE retrieves the vault's long-term private key
// either from the mnemonic env var or by unlocking via the current unlocker.
// getLongTermKeyForSE retrieves the vault's long-term private key, derived
// from mnemonic when it is not nil, else through the current unlocker, which
// is given passphrase when it is a passphrase unlocker.
func getLongTermKeyForSE(
fs afero.Fs,
vault VaultInterface,
mnemonic, passphrase *memguard.LockedBuffer,
) (*memguard.LockedBuffer, error) {
envMnemonic := os.Getenv(EnvMnemonic)
if envMnemonic != "" {
if mnemonic != nil {
// Read vault metadata to get the correct derivation index
vaultDir, err := vault.GetDirectory()
if err != nil {
@@ -333,7 +337,7 @@ func getLongTermKeyForSE(
// Use mnemonic with the vault's actual derivation index
ltIdentity, err := agehd.DeriveIdentity(
envMnemonic,
mnemonic.String(),
metadata.DerivationIndex,
)
@@ -352,6 +356,10 @@ func getLongTermKeyForSE(
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
}
if passphraseUnlocker, ok := currentUnlocker.(*PassphraseUnlocker); ok {
passphraseUnlocker.Passphrase = passphrase
}
currentIdentity, err := currentUnlocker.GetIdentity()
if err != nil {
return nil, fmt.Errorf(
+2
View File
@@ -6,6 +6,7 @@ import (
"errors"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
@@ -80,6 +81,7 @@ func (s *SecureEnclaveUnlocker) Remove() error {
func CreateSecureEnclaveUnlocker(
_ afero.Fs,
_ string,
_, _ *memguard.LockedBuffer,
) (*SecureEnclaveUnlocker, error) {
return nil, errSENotSupported
}
+1 -1
View File
@@ -78,7 +78,7 @@ func TestCreateSecureEnclaveUnlockerReturnsError(t *testing.T) {
fs := afero.NewMemMapFs()
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test")
unlocker, err := CreateSecureEnclaveUnlocker(fs, "/tmp/test", nil, nil)
assert.Nil(t, unlocker)
require.Error(t, err)
require.ErrorIs(t, err, errSENotSupported)
+4
View File
@@ -91,6 +91,10 @@ 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(
_ *memguard.LockedBuffer,
) (*secret.PassphraseUnlocker, error) {