Keep secret get values in locked memory (closes #37)
check / check (push) Waiting to run

Vault.GetSecret and Vault.GetSecretVersion return the decrypted value
as a *memguard.LockedBuffer instead of copying it into an ordinary
[]byte that nothing wiped. Every caller destroys the buffer, and
`secret get` writes its bytes straight to stdout, still with no
trailing newline. Instance.Print, which formatted through fmt and had
no other callers, is removed, and so is a debug log line in
`get --version` that held the plaintext value.

Model: opus-5-5
This commit was merged in pull request #87.
This commit is contained in:
2026-10-04 11:16:23 +02:00
parent 00713b8677
commit 4ed77902d1
14 changed files with 177 additions and 73 deletions
+4 -2
View File
@@ -1,6 +1,7 @@
package vault_test
import (
"bytes"
"os"
"path/filepath"
"slices"
@@ -197,10 +198,11 @@ func testDeepPathSecrets(t *testing.T, fs afero.Fs, tempDir string) {
if err != nil {
t.Fatalf("Failed to retrieve deep path secret: %v", err)
}
defer retrievedValue.Destroy()
if string(retrievedValue) != string(expectedValue) {
if !bytes.Equal(retrievedValue.Bytes(), expectedValue) {
t.Errorf("Retrieved value doesn't match. Expected %q, got %q",
string(expectedValue), string(retrievedValue))
expectedValue, retrievedValue.Bytes())
}
}
+35 -9
View File
@@ -119,7 +119,10 @@ func testCreateInitialVersion(
// Verify secret can be retrieved
value, err := vault.GetSecret(secretName)
require.NoError(t, err)
assert.Equal(t, []byte("version-1-data"), value)
defer value.Destroy()
assert.Equal(t, []byte("version-1-data"), value.Bytes())
// Verify version directory structure
secretDir := filepath.Join(vaultDir, "secrets.d", "integration%test")
@@ -166,7 +169,10 @@ func testCreateSecondVersion(
// Verify new value is current
value, err := vault.GetSecret(secretName)
require.NoError(t, err)
assert.Equal(t, []byte("version-2-data"), value)
defer value.Destroy()
assert.Equal(t, []byte("version-2-data"), value.Bytes())
// Verify we now have two versions
versions, err = secret.ListVersions(fs, secretDir)
@@ -209,7 +215,10 @@ func testCreateThirdVersion(
// Current should be version-3
value, err := vault.GetSecret(secretName)
require.NoError(t, err)
assert.Equal(t, []byte("version-3-data"), value)
defer value.Destroy()
assert.Equal(t, []byte("version-3-data"), value.Bytes())
}
func testRetrieveSpecificVersions(
@@ -225,15 +234,24 @@ func testRetrieveSpecificVersions(
// Get each version by its name
value1, err := vault.GetSecretVersion(secretName, versions[2]) // oldest
require.NoError(t, err)
assert.Equal(t, []byte("version-1-data"), value1)
defer value1.Destroy()
assert.Equal(t, []byte("version-1-data"), value1.Bytes())
value2, err := vault.GetSecretVersion(secretName, versions[1]) // middle
require.NoError(t, err)
assert.Equal(t, []byte("version-2-data"), value2)
defer value2.Destroy()
assert.Equal(t, []byte("version-2-data"), value2.Bytes())
value3, err := vault.GetSecretVersion(secretName, versions[0]) // newest
require.NoError(t, err)
assert.Equal(t, []byte("version-3-data"), value3)
defer value3.Destroy()
assert.Equal(t, []byte("version-3-data"), value3.Bytes())
// An empty version is not one of the versions; GetSecret gets the
// current one
@@ -259,7 +277,10 @@ func testPromoteOldVersion(
// Verify current now returns the old version's value
value, err := vault.GetSecret(secretName)
require.NoError(t, err)
assert.Equal(t, []byte("version-1-data"), value)
defer value.Destroy()
assert.Equal(t, []byte("version-1-data"), value.Bytes())
// Verify the version metadata hasn't changed
// (promoting shouldn't modify timestamps)
@@ -353,8 +374,13 @@ func TestVersionConcurrency(t *testing.T) {
value, err := vault.GetSecret(secretName)
if err != nil {
errCh <- err
} else if string(value) != "initial" {
errCh <- fmt.Errorf("%w: %s", errUnexpectedValue, value)
} else {
if value.String() != "initial" {
errCh <- fmt.Errorf("%w: %s",
errUnexpectedValue, value.Bytes())
}
value.Destroy()
}
done <- true
+9 -17
View File
@@ -301,8 +301,9 @@ func updateVersionMetadata(
return nil
}
// GetSecret retrieves the current version of a secret from this vault
func (v *Vault) GetSecret(name string) ([]byte, error) {
// GetSecret retrieves the current version of a secret from this vault.
// The caller must destroy the returned buffer.
func (v *Vault) GetSecret(name string) (*memguard.LockedBuffer, error) {
secret.DebugWith("Getting secret from vault",
slog.String("vault_name", v.Name),
slog.String("secret_name", name),
@@ -326,7 +327,10 @@ func (v *Vault) GetSecret(name string) ([]byte, error) {
// GetSecretVersion retrieves a specific version of a secret. The version
// must be one of the secret's versions; GetSecret gets the current one.
func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
// The caller must destroy the returned buffer.
func (v *Vault) GetSecretVersion(
name string, version string,
) (*memguard.LockedBuffer, error) {
secret.DebugWith("Getting secret version from vault",
slog.String("vault_name", v.Name),
slog.String("secret_name", name),
@@ -372,26 +376,14 @@ func (v *Vault) GetSecretVersion(name string, version string) ([]byte, error) {
return nil, fmt.Errorf("failed to decrypt version: %w", err)
}
// Create a copy to return since the buffer will be destroyed
result := make([]byte, decryptedValue.Size())
copy(result, decryptedValue.Bytes())
decryptedValue.Destroy()
secret.DebugWith("Successfully decrypted secret version",
slog.String("secret_name", name),
slog.String("version", version),
slog.String("vault_name", v.Name),
slog.Int("decrypted_length", len(result)),
slog.Int("decrypted_length", decryptedValue.Size()),
)
// Debug: Log metadata about the decrypted value without exposing the actual secret
secret.Debug("Vault secret decryption debug info",
"secret_name", name,
"version", version,
"decrypted_value_length", len(result),
"is_empty", len(result) == 0)
return result, nil
return decryptedValue, nil
}
// UnlockVault unlocks the vault and returns the long-term private key
+18 -6
View File
@@ -131,7 +131,10 @@ func TestVaultAddSecretCreatesVersion(t *testing.T) {
// Get the secret value
retrievedValue, err := vault.GetSecret(testSecretPath)
require.NoError(t, err)
assert.Equal(t, expectedValue, retrievedValue)
defer retrievedValue.Destroy()
assert.Equal(t, expectedValue, retrievedValue.Bytes())
}
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
@@ -165,7 +168,10 @@ func TestVaultAddSecretMultipleVersions(t *testing.T) {
// Current value should be version-2
value, err := vault.GetSecret(testSecretPath)
require.NoError(t, err)
assert.Equal(t, []byte("version-2"), value)
defer value.Destroy()
assert.Equal(t, []byte("version-2"), value.Bytes())
}
//nolint:paralleltest // createTestVaultWithKey uses t.Setenv
@@ -192,15 +198,21 @@ func TestVaultGetSecretVersion(t *testing.T) {
// Get specific version (first one)
firstVersion := versions[1] // Last in list is first created
value, err := vault.GetSecretVersion(testSecretPath, firstVersion)
first, err := vault.GetSecretVersion(testSecretPath, firstVersion)
require.NoError(t, err)
assert.Equal(t, []byte("version-1"), value)
defer first.Destroy()
assert.Equal(t, []byte("version-1"), first.Bytes())
// Get specific version (second one)
secondVersion := versions[0] // First in list is most recent
value, err = vault.GetSecretVersion(testSecretPath, secondVersion)
second, err := vault.GetSecretVersion(testSecretPath, secondVersion)
require.NoError(t, err)
assert.Equal(t, []byte("version-2"), value)
defer second.Destroy()
assert.Equal(t, []byte("version-2"), second.Bytes())
// An empty version is not one of the versions; GetSecret gets the
// current one
+4 -2
View File
@@ -1,6 +1,7 @@
package vault_test
import (
"bytes"
"path/filepath"
"slices"
"testing"
@@ -184,10 +185,11 @@ func testSecretOperations(t *testing.T, fs afero.Fs) {
if err != nil {
t.Fatalf("Failed to get secret: %v", err)
}
defer retrievedValue.Destroy()
if string(retrievedValue) != string(expectedValue) {
if !bytes.Equal(retrievedValue.Bytes(), expectedValue) {
t.Errorf("Expected secret value '%s', got '%s'",
string(expectedValue), string(retrievedValue))
expectedValue, retrievedValue.Bytes())
}
}