WIP: refactor to use memguard for secure memory handling

- Add memguard dependency
- Update ReadPassphrase to return LockedBuffer
- Update EncryptWithPassphrase/DecryptWithPassphrase to accept LockedBuffer
- Remove string wrapper functions
- Update all callers to create LockedBuffers at entry points
- Update interfaces and mock implementations
This commit is contained in:
2025-07-15 07:22:41 +02:00
parent f9938135c6
commit c9774e89e0
18 changed files with 194 additions and 65 deletions

View File

@@ -8,6 +8,7 @@ import (
"syscall"
"filippo.io/age"
"github.com/awnumar/memguard"
"golang.org/x/term"
)
@@ -63,8 +64,15 @@ func DecryptWithIdentity(data []byte, identity age.Identity) ([]byte, error) {
}
// EncryptWithPassphrase encrypts data using a passphrase with age's scrypt-based encryption
func EncryptWithPassphrase(data []byte, passphrase string) ([]byte, error) {
recipient, err := age.NewScryptRecipient(passphrase)
// The passphrase parameter should be a LockedBuffer for secure memory handling
func EncryptWithPassphrase(data []byte, passphrase *memguard.LockedBuffer) ([]byte, error) {
if passphrase == nil {
return nil, fmt.Errorf("passphrase buffer is nil")
}
// Get the passphrase string temporarily
passphraseStr := passphrase.String()
recipient, err := age.NewScryptRecipient(passphraseStr)
if err != nil {
return nil, fmt.Errorf("failed to create scrypt recipient: %w", err)
}
@@ -73,8 +81,15 @@ func EncryptWithPassphrase(data []byte, passphrase string) ([]byte, error) {
}
// DecryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption
func DecryptWithPassphrase(encryptedData []byte, passphrase string) ([]byte, error) {
identity, err := age.NewScryptIdentity(passphrase)
// The passphrase parameter should be a LockedBuffer for secure memory handling
func DecryptWithPassphrase(encryptedData []byte, passphrase *memguard.LockedBuffer) ([]byte, error) {
if passphrase == nil {
return nil, fmt.Errorf("passphrase buffer is nil")
}
// Get the passphrase string temporarily
passphraseStr := passphrase.String()
identity, err := age.NewScryptIdentity(passphraseStr)
if err != nil {
return nil, fmt.Errorf("failed to create scrypt identity: %w", err)
}
@@ -84,18 +99,19 @@ func DecryptWithPassphrase(encryptedData []byte, passphrase string) ([]byte, err
// ReadPassphrase reads a passphrase securely from the terminal without echoing
// This version is for unlocking and doesn't require confirmation
func ReadPassphrase(prompt string) (string, error) {
// Returns a LockedBuffer containing the passphrase for secure memory handling
func ReadPassphrase(prompt string) (*memguard.LockedBuffer, error) {
// Check if stdin is a terminal
if !term.IsTerminal(syscall.Stdin) {
// Not a terminal - never read passphrases from piped input for security reasons
return "", fmt.Errorf("cannot read passphrase from non-terminal stdin " +
return nil, fmt.Errorf("cannot read passphrase from non-terminal stdin " +
"(piped input or script). Please set the SB_UNLOCK_PASSPHRASE " +
"environment variable or run interactively")
}
// stdin is a terminal, check if stderr is also a terminal for interactive prompting
if !term.IsTerminal(syscall.Stderr) {
return "", fmt.Errorf("cannot prompt for passphrase: stderr is not a terminal " +
return nil, fmt.Errorf("cannot prompt for passphrase: stderr is not a terminal " +
"(running in non-interactive mode). Please set the SB_UNLOCK_PASSPHRASE " +
"environment variable")
}
@@ -104,13 +120,22 @@ func ReadPassphrase(prompt string) (string, error) {
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
passphrase, err := term.ReadPassword(syscall.Stdin)
if err != nil {
return "", fmt.Errorf("failed to read passphrase: %w", err)
return nil, fmt.Errorf("failed to read passphrase: %w", err)
}
fmt.Fprintln(os.Stderr) // Print newline to stderr since ReadPassword doesn't echo
if len(passphrase) == 0 {
return "", fmt.Errorf("passphrase cannot be empty")
return nil, fmt.Errorf("passphrase cannot be empty")
}
return string(passphrase), nil
// Create a secure buffer and copy the passphrase
secureBuffer := memguard.NewBufferFromBytes(passphrase)
// Clear the original passphrase slice
for i := range passphrase {
passphrase[i] = 0
}
return secureBuffer, nil
}

View File

@@ -13,6 +13,7 @@ import (
"filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
@@ -102,7 +103,11 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
// Step 5: Decrypt the age private key using the passphrase from keychain
Debug("Decrypting age private key with keychain passphrase", "unlocker_id", k.GetID())
agePrivKeyData, err := DecryptWithPassphrase(encryptedAgePrivKeyData, keychainData.AgePrivKeyPassphrase)
// Create secure buffer for the keychain passphrase
passphraseBuffer := memguard.NewBufferFromBytes([]byte(keychainData.AgePrivKeyPassphrase))
defer passphraseBuffer.Destroy()
agePrivKeyData, err := DecryptWithPassphrase(encryptedAgePrivKeyData, passphraseBuffer)
if err != nil {
Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "unlocker_id", k.GetID())
@@ -116,7 +121,17 @@ func (k *KeychainUnlocker) GetIdentity() (*age.X25519Identity, error) {
// Step 6: Parse the decrypted age private key
Debug("Parsing decrypted age private key", "unlocker_id", k.GetID())
ageIdentity, err := age.ParseX25519Identity(string(agePrivKeyData))
// Create a secure buffer for the private key data
agePrivKeyBuffer := memguard.NewBufferFromBytes(agePrivKeyData)
defer agePrivKeyBuffer.Destroy()
// Clear the original private key data
for i := range agePrivKeyData {
agePrivKeyData[i] = 0
}
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
if err != nil {
Debug("Failed to parse age private key", "error", err, "unlocker_id", k.GetID())
@@ -342,8 +357,15 @@ func CreateKeychainUnlocker(fs afero.Fs, stateDir string) (*KeychainUnlocker, er
}
// Step 4: Encrypt age private key with the generated passphrase and store on disk
agePrivateKeyBytes := []byte(ageIdentity.String())
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivateKeyBytes, agePrivKeyPassphrase)
// Create secure buffers for both the private key and passphrase
agePrivKeyStr := ageIdentity.String()
agePrivKeyBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyStr))
defer agePrivKeyBuffer.Destroy()
passphraseBuffer := memguard.NewBufferFromBytes([]byte(agePrivKeyPassphrase))
defer passphraseBuffer.Destroy()
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivKeyBuffer.Bytes(), passphraseBuffer)
if err != nil {
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
}

View File

@@ -9,6 +9,7 @@ import (
"filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
@@ -76,7 +77,9 @@ func TestPassphraseUnlockerWithRealFS(t *testing.T) {
// Test encrypting private key with passphrase
t.Run("EncryptPrivateKey", func(t *testing.T) {
privKeyData := []byte(agePrivateKey)
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, testPassphrase)
passphraseBuffer := memguard.NewBufferFromBytes([]byte(testPassphrase))
defer passphraseBuffer.Destroy()
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, passphraseBuffer)
if err != nil {
t.Fatalf("Failed to encrypt private key: %v", err)
}

View File

@@ -7,6 +7,7 @@ import (
"path/filepath"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
@@ -15,16 +16,17 @@ type PassphraseUnlocker struct {
Directory string
Metadata UnlockerMetadata
fs afero.Fs
Passphrase string
Passphrase *memguard.LockedBuffer // Secure buffer for passphrase
}
// getPassphrase retrieves the passphrase from memory, environment, or user input
func (p *PassphraseUnlocker) getPassphrase() (string, error) {
// Returns a LockedBuffer for secure memory handling
func (p *PassphraseUnlocker) getPassphrase() (*memguard.LockedBuffer, error) {
// First check if we already have the passphrase
if p.Passphrase != "" {
if p.Passphrase != nil && p.Passphrase.IsAlive() {
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
return p.Passphrase, nil
// Return a copy of the passphrase buffer
return memguard.NewBufferFromBytes(p.Passphrase.Bytes()), nil
}
Debug("No passphrase in memory, checking environment")
@@ -32,20 +34,22 @@ func (p *PassphraseUnlocker) getPassphrase() (string, error) {
passphraseStr := os.Getenv(EnvUnlockPassphrase)
if passphraseStr != "" {
Debug("Using passphrase from environment", "unlocker_id", p.GetID())
return passphraseStr, nil
// Convert to secure buffer
secureBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
return secureBuffer, nil
}
Debug("No passphrase in environment, prompting user")
// Prompt for passphrase
passphraseStr, err := ReadPassphrase("Enter unlock passphrase: ")
secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ")
if err != nil {
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID())
return "", fmt.Errorf("failed to read passphrase: %w", err)
return nil, fmt.Errorf("failed to read passphrase: %w", err)
}
return passphraseStr, nil
return secureBuffer, nil
}
// GetIdentity implements Unlocker interface for passphrase-based unlockers
@@ -55,10 +59,11 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
slog.String("unlocker_type", p.GetType()),
)
passphraseStr, err := p.getPassphrase()
passphraseBuffer, err := p.getPassphrase()
if err != nil {
return nil, err
}
defer passphraseBuffer.Destroy()
// Read encrypted private key of unlocker
unlockerPrivPath := filepath.Join(p.Directory, "priv.age")
@@ -79,7 +84,7 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
Debug("Decrypting unlocker private key with passphrase", "unlocker_id", p.GetID())
// Decrypt the unlocker private key with passphrase
privKeyData, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseStr)
privKeyData, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer)
if err != nil {
Debug("Failed to decrypt unlocker private key", "error", err, "unlocker_id", p.GetID())
@@ -93,7 +98,17 @@ func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
// Parse the decrypted private key
Debug("Parsing decrypted unlocker identity", "unlocker_id", p.GetID())
identity, err := age.ParseX25519Identity(string(privKeyData))
// Create a secure buffer for the private key data
privKeyBuffer := memguard.NewBufferFromBytes(privKeyData)
defer privKeyBuffer.Destroy()
// Clear the original private key data
for i := range privKeyData {
privKeyData[i] = 0
}
identity, err := age.ParseX25519Identity(privKeyBuffer.String())
if err != nil {
Debug("Failed to parse unlocker private key", "error", err, "unlocker_id", p.GetID())
@@ -133,6 +148,11 @@ func (p *PassphraseUnlocker) GetID() string {
// Remove implements Unlocker interface - removes the passphrase unlocker
func (p *PassphraseUnlocker) Remove() error {
// Clean up the passphrase from memory if it exists
if p.Passphrase != nil && p.Passphrase.IsAlive() {
p.Passphrase.Destroy()
}
// For passphrase unlockers, we just need to remove the directory
// No external resources (like keychain items) to clean up
if err := p.fs.RemoveAll(p.Directory); err != nil {
@@ -152,7 +172,8 @@ func NewPassphraseUnlocker(fs afero.Fs, directory string, metadata UnlockerMetad
}
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
func CreatePassphraseUnlocker(fs afero.Fs, stateDir string, passphrase string) (*PassphraseUnlocker, error) {
// The passphrase must be provided as a LockedBuffer for security
func CreatePassphraseUnlocker(fs afero.Fs, stateDir string, passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error) {
// Get current vault
currentVault, err := GetCurrentVault(fs, stateDir)
if err != nil {

View File

@@ -16,6 +16,7 @@ import (
"git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
@@ -270,7 +271,9 @@ Passphrase: ` + testPassphrase + `
vlt.Unlock(ltIdentity)
// Create a passphrase unlocker first (to have current unlocker)
passUnlocker, err := vlt.CreatePassphraseUnlocker("test-passphrase")
passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
defer passphraseBuffer.Destroy()
passUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil {
t.Fatalf("Failed to create passphrase unlocker: %v", err)
}

View File

@@ -11,6 +11,7 @@ import (
"filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
@@ -21,7 +22,7 @@ type VaultInterface interface {
GetName() string
GetFilesystem() afero.Fs
GetCurrentUnlocker() (Unlocker, error)
CreatePassphraseUnlocker(passphrase string) (*PassphraseUnlocker, error)
CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*PassphraseUnlocker, error)
}
// Secret represents a secret in a vault

View File

@@ -9,6 +9,7 @@ import (
"filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/require"
)
@@ -120,7 +121,7 @@ func (m *MockVault) GetCurrentUnlocker() (Unlocker, error) {
return nil, nil
}
func (m *MockVault) CreatePassphraseUnlocker(_ string) (*PassphraseUnlocker, error) {
func (m *MockVault) CreatePassphraseUnlocker(_ *memguard.LockedBuffer) (*PassphraseUnlocker, error) {
return nil, nil
}

View File

@@ -41,6 +41,7 @@ import (
"time"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -74,7 +75,7 @@ func (m *MockVersionVault) GetCurrentUnlocker() (Unlocker, error) {
return nil, fmt.Errorf("not implemented in mock")
}
func (m *MockVersionVault) CreatePassphraseUnlocker(_ string) (*PassphraseUnlocker, error) {
func (m *MockVersionVault) CreatePassphraseUnlocker(_ *memguard.LockedBuffer) (*PassphraseUnlocker, error) {
return nil, fmt.Errorf("not implemented in mock")
}