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:
Jeffrey Paul 2025-07-15 07:22:41 +02:00
parent f9938135c6
commit c9774e89e0
18 changed files with 194 additions and 65 deletions

View File

@ -22,7 +22,8 @@
"Bash(git checkout:*)", "Bash(git checkout:*)",
"Bash(ls:*)", "Bash(ls:*)",
"WebFetch(domain:golangci-lint.run)", "WebFetch(domain:golangci-lint.run)",
"Bash(go:*)" "Bash(go:*)",
"WebFetch(domain:pkg.go.dev)"
], ],
"deny": [] "deny": []
} }

2
go.mod
View File

@ -4,6 +4,7 @@ go 1.24.1
require ( require (
filippo.io/age v1.2.1 filippo.io/age v1.2.1
github.com/awnumar/memguard v0.22.5
github.com/btcsuite/btcd v0.24.2 github.com/btcsuite/btcd v0.24.2
github.com/btcsuite/btcd/btcec/v2 v2.1.3 github.com/btcsuite/btcd/btcec/v2 v2.1.3
github.com/btcsuite/btcd/btcutil v1.1.6 github.com/btcsuite/btcd/btcutil v1.1.6
@ -18,6 +19,7 @@ require (
) )
require ( require (
github.com/awnumar/memcall v0.2.0 // indirect
github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect github.com/btcsuite/btcd/chaincfg/chainhash v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.0.1 // indirect

5
go.sum
View File

@ -3,6 +3,10 @@ c2sp.org/CCTV/age v0.0.0-20240306222714-3ec4d716e805/go.mod h1:FomMrUJ2Lxt5jCLmZ
filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o= filippo.io/age v1.2.1 h1:X0TZjehAZylOIj4DubWYU1vWQxv9bJpo+Uu2/LGhi1o=
filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004= filippo.io/age v1.2.1/go.mod h1:JL9ew2lTN+Pyft4RiNGguFfOpewKwSHm5ayKD/A4004=
github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII= github.com/aead/siphash v1.0.1/go.mod h1:Nywa3cDsYNNK3gaciGTWPwHt0wlpNV15vwmswBAUSII=
github.com/awnumar/memcall v0.2.0 h1:sRaogqExTOOkkNwO9pzJsL8jrOV29UuUW7teRMfbqtI=
github.com/awnumar/memcall v0.2.0/go.mod h1:S911igBPR9CThzd/hYQQmTc9SWNu3ZHIlCGaWsWsoJo=
github.com/awnumar/memguard v0.22.5 h1:PH7sbUVERS5DdXh3+mLo8FDcl1eIeVjJVYMnyuYpvuI=
github.com/awnumar/memguard v0.22.5/go.mod h1:+APmZGThMBWjnMlKiSM1X7MVpbIVewen2MTkqWkA/zE=
github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ= github.com/btcsuite/btcd v0.20.1-beta/go.mod h1:wVuoA8VJLEcwgqHBwHmzLRazpKxTv13Px/pDuV7OomQ=
github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M= github.com/btcsuite/btcd v0.22.0-beta.0.20220111032746-97732e52810c/go.mod h1:tjmYdS6MLJ5/s0Fj4DbLgSbDHbEqLJrtnHecBFkdz5M=
github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A= github.com/btcsuite/btcd v0.23.5-0.20231215221805-96c9fd8078fd/go.mod h1:nm3Bko6zh6bWP60UxwoT5LzdGJsQJaPo6HjduXq9p6A=
@ -107,6 +111,7 @@ golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

View File

@ -8,6 +8,7 @@ import (
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -83,8 +84,11 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
ageSecretKey = identity.String() ageSecretKey = identity.String()
// Store the generated key as a secret // Store the generated key as a secret using secure buffer
err = vlt.AddSecret(secretName, []byte(ageSecretKey), false) secureBuffer := memguard.NewBufferFromBytes([]byte(ageSecretKey))
defer secureBuffer.Destroy()
err = vlt.AddSecret(secretName, secureBuffer.Bytes(), false)
if err != nil { if err != nil {
return fmt.Errorf("failed to store age key: %w", err) return fmt.Errorf("failed to store age key: %w", err)
} }
@ -95,7 +99,16 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
return fmt.Errorf("failed to get secret value: %w", err) return fmt.Errorf("failed to get secret value: %w", err)
} }
ageSecretKey = string(secretValue) // Create secure buffer for the secret value
secureBuffer := memguard.NewBufferFromBytes(secretValue)
defer secureBuffer.Destroy()
// Clear the original secret value
for i := range secretValue {
secretValue[i] = 0
}
ageSecretKey = secureBuffer.String()
// Validate that it's a valid age secret key // Validate that it's a valid age secret key
if !isValidAgeSecretKey(ageSecretKey) { if !isValidAgeSecretKey(ageSecretKey) {
@ -103,8 +116,11 @@ func (cli *Instance) Encrypt(secretName, inputFile, outputFile string) error {
} }
} }
// Parse the secret key // Parse the secret key using secure buffer
identity, err := age.ParseX25519Identity(ageSecretKey) finalSecureBuffer := memguard.NewBufferFromBytes([]byte(ageSecretKey))
defer finalSecureBuffer.Destroy()
identity, err := age.ParseX25519Identity(finalSecureBuffer.String())
if err != nil { if err != nil {
return fmt.Errorf("failed to parse age secret key: %w", err) return fmt.Errorf("failed to parse age secret key: %w", err)
} }
@ -185,15 +201,22 @@ func (cli *Instance) Decrypt(secretName, inputFile, outputFile string) error {
return fmt.Errorf("failed to get secret value: %w", err) return fmt.Errorf("failed to get secret value: %w", err)
} }
ageSecretKey := string(secretValue) // Create secure buffer for the secret value
secureBuffer := memguard.NewBufferFromBytes(secretValue)
defer secureBuffer.Destroy()
// Clear the original secret value
for i := range secretValue {
secretValue[i] = 0
}
// Validate that it's a valid age secret key // Validate that it's a valid age secret key
if !isValidAgeSecretKey(ageSecretKey) { if !isValidAgeSecretKey(secureBuffer.String()) {
return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName) return fmt.Errorf("secret '%s' does not contain a valid age secret key", secretName)
} }
// Parse the age secret key to get the identity // Parse the age secret key to get the identity
identity, err := age.ParseX25519Identity(ageSecretKey) identity, err := age.ParseX25519Identity(secureBuffer.String())
if err != nil { if err != nil {
return fmt.Errorf("failed to parse age secret key: %w", err) return fmt.Errorf("failed to parse age secret key: %w", err)
} }

View File

@ -11,6 +11,7 @@ import (
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/tyler-smith/go-bip39" "github.com/tyler-smith/go-bip39"
@ -125,24 +126,25 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
vlt.Unlock(ltIdentity) vlt.Unlock(ltIdentity)
// Prompt for passphrase for unlocker // Prompt for passphrase for unlocker
var passphraseStr string var passphraseBuffer *memguard.LockedBuffer
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" { if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
secret.Debug("Using unlock passphrase from environment variable") secret.Debug("Using unlock passphrase from environment variable")
passphraseStr = envPassphrase passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
} else { } else {
secret.Debug("Prompting user for unlock passphrase") secret.Debug("Prompting user for unlock passphrase")
// Use secure passphrase input with confirmation // Use secure passphrase input with confirmation
passphraseStr, err = readSecurePassphrase("Enter passphrase for unlocker: ") passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
if err != nil { if err != nil {
secret.Debug("Failed to read unlock passphrase", "error", err) secret.Debug("Failed to read unlock passphrase", "error", err)
return fmt.Errorf("failed to read passphrase: %w", err) return fmt.Errorf("failed to read passphrase: %w", err)
} }
} }
defer passphraseBuffer.Destroy()
// Create passphrase-protected unlocker // Create passphrase-protected unlocker
secret.Debug("Creating passphrase-protected unlocker") secret.Debug("Creating passphrase-protected unlocker")
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseStr) passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil { if err != nil {
secret.Debug("Failed to create unlocker", "error", err) secret.Debug("Failed to create unlocker", "error", err)
@ -190,23 +192,27 @@ func (cli *Instance) Init(cmd *cobra.Command) error {
// readSecurePassphrase reads a passphrase securely from the terminal without echoing // readSecurePassphrase reads a passphrase securely from the terminal without echoing
// This version adds confirmation (read twice) for creating new unlockers // This version adds confirmation (read twice) for creating new unlockers
func readSecurePassphrase(prompt string) (string, error) { // Returns a LockedBuffer containing the passphrase
func readSecurePassphrase(prompt string) (*memguard.LockedBuffer, error) {
// Get the first passphrase // Get the first passphrase
passphrase1, err := secret.ReadPassphrase(prompt) passphraseBuffer1, err := secret.ReadPassphrase(prompt)
if err != nil { if err != nil {
return "", err return nil, err
} }
defer passphraseBuffer1.Destroy()
// Read confirmation passphrase // Read confirmation passphrase
passphrase2, err := secret.ReadPassphrase("Confirm passphrase: ") passphraseBuffer2, err := secret.ReadPassphrase("Confirm passphrase: ")
if err != nil { if err != nil {
return "", fmt.Errorf("failed to read passphrase confirmation: %w", err) return nil, fmt.Errorf("failed to read passphrase confirmation: %w", err)
} }
defer passphraseBuffer2.Destroy()
// Compare passphrases // Compare passphrases
if passphrase1 != passphrase2 { if passphraseBuffer1.String() != passphraseBuffer2.String() {
return "", fmt.Errorf("passphrases do not match") return nil, fmt.Errorf("passphrases do not match")
} }
return passphrase1, nil // Create a new buffer with the confirmed passphrase
return memguard.NewBufferFromBytes(passphraseBuffer1.Bytes()), nil
} }

View File

@ -10,6 +10,7 @@ import (
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
@ -254,18 +255,19 @@ func (cli *Instance) UnlockersAdd(unlockerType string, cmd *cobra.Command) error
// The CreatePassphraseUnlocker method will handle getting the long-term key // The CreatePassphraseUnlocker method will handle getting the long-term key
// Check if passphrase is set in environment variable // Check if passphrase is set in environment variable
var passphraseStr string var passphraseBuffer *memguard.LockedBuffer
if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" { if envPassphrase := os.Getenv(secret.EnvUnlockPassphrase); envPassphrase != "" {
passphraseStr = envPassphrase passphraseBuffer = memguard.NewBufferFromBytes([]byte(envPassphrase))
} else { } else {
// Use secure passphrase input with confirmation // Use secure passphrase input with confirmation
passphraseStr, err = readSecurePassphrase("Enter passphrase for unlocker: ") passphraseBuffer, err = readSecurePassphrase("Enter passphrase for unlocker: ")
if err != nil { if err != nil {
return fmt.Errorf("failed to read passphrase: %w", err) return fmt.Errorf("failed to read passphrase: %w", err)
} }
} }
defer passphraseBuffer.Destroy()
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseStr) passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil { if err != nil {
return err return err
} }

View File

@ -10,6 +10,7 @@ import (
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/tyler-smith/go-bip39" "github.com/tyler-smith/go-bip39"
@ -272,12 +273,16 @@ func (cli *Instance) VaultImport(cmd *cobra.Command, vaultName string) error {
secret.Debug("Using unlock passphrase from environment variable") secret.Debug("Using unlock passphrase from environment variable")
// Create secure buffer for passphrase
passphraseBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
defer passphraseBuffer.Destroy()
// Unlock the vault with the derived long-term key // Unlock the vault with the derived long-term key
vlt.Unlock(ltIdentity) vlt.Unlock(ltIdentity)
// Create passphrase-protected unlocker // Create passphrase-protected unlocker
secret.Debug("Creating passphrase-protected unlocker") secret.Debug("Creating passphrase-protected unlocker")
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseStr) passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil { if err != nil {
secret.Debug("Failed to create unlocker", "error", err) secret.Debug("Failed to create unlocker", "error", err)

View File

@ -8,6 +8,7 @@ import (
"syscall" "syscall"
"filippo.io/age" "filippo.io/age"
"github.com/awnumar/memguard"
"golang.org/x/term" "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 // EncryptWithPassphrase encrypts data using a passphrase with age's scrypt-based encryption
func EncryptWithPassphrase(data []byte, passphrase string) ([]byte, error) { // The passphrase parameter should be a LockedBuffer for secure memory handling
recipient, err := age.NewScryptRecipient(passphrase) 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 { if err != nil {
return nil, fmt.Errorf("failed to create scrypt recipient: %w", err) 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 // DecryptWithPassphrase decrypts data using a passphrase with age's scrypt-based decryption
func DecryptWithPassphrase(encryptedData []byte, passphrase string) ([]byte, error) { // The passphrase parameter should be a LockedBuffer for secure memory handling
identity, err := age.NewScryptIdentity(passphrase) 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 { if err != nil {
return nil, fmt.Errorf("failed to create scrypt identity: %w", err) 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 // ReadPassphrase reads a passphrase securely from the terminal without echoing
// This version is for unlocking and doesn't require confirmation // 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 // Check if stdin is a terminal
if !term.IsTerminal(syscall.Stdin) { if !term.IsTerminal(syscall.Stdin) {
// Not a terminal - never read passphrases from piped input for security reasons // 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 " + "(piped input or script). Please set the SB_UNLOCK_PASSPHRASE " +
"environment variable or run interactively") "environment variable or run interactively")
} }
// stdin is a terminal, check if stderr is also a terminal for interactive prompting // stdin is a terminal, check if stderr is also a terminal for interactive prompting
if !term.IsTerminal(syscall.Stderr) { 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 " + "(running in non-interactive mode). Please set the SB_UNLOCK_PASSPHRASE " +
"environment variable") "environment variable")
} }
@ -104,13 +120,22 @@ func ReadPassphrase(prompt string) (string, error) {
fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout fmt.Fprint(os.Stderr, prompt) // Write prompt to stderr, not stdout
passphrase, err := term.ReadPassword(syscall.Stdin) passphrase, err := term.ReadPassword(syscall.Stdin)
if err != nil { 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 fmt.Fprintln(os.Stderr) // Print newline to stderr since ReadPassword doesn't echo
if len(passphrase) == 0 { 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" "filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero" "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 // Step 5: Decrypt the age private key using the passphrase from keychain
Debug("Decrypting age private key with keychain passphrase", "unlocker_id", k.GetID()) 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 { if err != nil {
Debug("Failed to decrypt age private key with keychain passphrase", "error", err, "unlocker_id", k.GetID()) 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 // Step 6: Parse the decrypted age private key
Debug("Parsing decrypted age private key", "unlocker_id", k.GetID()) 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 { if err != nil {
Debug("Failed to parse age private key", "error", err, "unlocker_id", k.GetID()) 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 // Step 4: Encrypt age private key with the generated passphrase and store on disk
agePrivateKeyBytes := []byte(ageIdentity.String()) // Create secure buffers for both the private key and passphrase
encryptedAgePrivKey, err := EncryptWithPassphrase(agePrivateKeyBytes, agePrivKeyPassphrase) 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 { if err != nil {
return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err) return nil, fmt.Errorf("failed to encrypt age private key with passphrase: %w", err)
} }

View File

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

View File

@ -7,6 +7,7 @@ import (
"path/filepath" "path/filepath"
"filippo.io/age" "filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@ -15,16 +16,17 @@ type PassphraseUnlocker struct {
Directory string Directory string
Metadata UnlockerMetadata Metadata UnlockerMetadata
fs afero.Fs fs afero.Fs
Passphrase string Passphrase *memguard.LockedBuffer // Secure buffer for passphrase
} }
// getPassphrase retrieves the passphrase from memory, environment, or user input // 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 // 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()) Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
// Return a copy of the passphrase buffer
return p.Passphrase, nil return memguard.NewBufferFromBytes(p.Passphrase.Bytes()), nil
} }
Debug("No passphrase in memory, checking environment") Debug("No passphrase in memory, checking environment")
@ -32,20 +34,22 @@ func (p *PassphraseUnlocker) getPassphrase() (string, error) {
passphraseStr := os.Getenv(EnvUnlockPassphrase) passphraseStr := os.Getenv(EnvUnlockPassphrase)
if passphraseStr != "" { if passphraseStr != "" {
Debug("Using passphrase from environment", "unlocker_id", p.GetID()) Debug("Using passphrase from environment", "unlocker_id", p.GetID())
// Convert to secure buffer
secureBuffer := memguard.NewBufferFromBytes([]byte(passphraseStr))
return passphraseStr, nil return secureBuffer, nil
} }
Debug("No passphrase in environment, prompting user") Debug("No passphrase in environment, prompting user")
// Prompt for passphrase // Prompt for passphrase
passphraseStr, err := ReadPassphrase("Enter unlock passphrase: ") secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ")
if err != nil { if err != nil {
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID()) 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 // 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()), slog.String("unlocker_type", p.GetType()),
) )
passphraseStr, err := p.getPassphrase() passphraseBuffer, err := p.getPassphrase()
if err != nil { if err != nil {
return nil, err return nil, err
} }
defer passphraseBuffer.Destroy()
// Read encrypted private key of unlocker // Read encrypted private key of unlocker
unlockerPrivPath := filepath.Join(p.Directory, "priv.age") 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()) Debug("Decrypting unlocker private key with passphrase", "unlocker_id", p.GetID())
// Decrypt the unlocker private key with passphrase // Decrypt the unlocker private key with passphrase
privKeyData, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseStr) privKeyData, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer)
if err != nil { if err != nil {
Debug("Failed to decrypt unlocker private key", "error", err, "unlocker_id", p.GetID()) 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 // Parse the decrypted private key
Debug("Parsing decrypted unlocker identity", "unlocker_id", p.GetID()) 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 { if err != nil {
Debug("Failed to parse unlocker private key", "error", err, "unlocker_id", p.GetID()) 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 // Remove implements Unlocker interface - removes the passphrase unlocker
func (p *PassphraseUnlocker) Remove() error { 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 // For passphrase unlockers, we just need to remove the directory
// No external resources (like keychain items) to clean up // No external resources (like keychain items) to clean up
if err := p.fs.RemoveAll(p.Directory); err != nil { 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 // 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 // Get current vault
currentVault, err := GetCurrentVault(fs, stateDir) currentVault, err := GetCurrentVault(fs, stateDir)
if err != nil { if err != nil {

View File

@ -16,6 +16,7 @@ import (
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/internal/vault" "git.eeqj.de/sneak/secret/internal/vault"
"git.eeqj.de/sneak/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@ -270,7 +271,9 @@ Passphrase: ` + testPassphrase + `
vlt.Unlock(ltIdentity) vlt.Unlock(ltIdentity)
// Create a passphrase unlocker first (to have current unlocker) // 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 { if err != nil {
t.Fatalf("Failed to create passphrase unlocker: %v", err) t.Fatalf("Failed to create passphrase unlocker: %v", err)
} }

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@ import (
"filippo.io/age" "filippo.io/age"
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@ -316,7 +317,8 @@ func (v *Vault) SelectUnlocker(unlockerID string) error {
} }
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker // CreatePassphraseUnlocker creates a new passphrase-protected unlocker
func (v *Vault) CreatePassphraseUnlocker(passphrase string) (*secret.PassphraseUnlocker, error) { // The passphrase must be provided as a LockedBuffer for security
func (v *Vault) CreatePassphraseUnlocker(passphrase *memguard.LockedBuffer) (*secret.PassphraseUnlocker, error) {
vaultDir, err := v.GetDirectory() vaultDir, err := v.GetDirectory()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err) return nil, fmt.Errorf("failed to get vault directory: %w", err)
@ -343,8 +345,11 @@ func (v *Vault) CreatePassphraseUnlocker(passphrase string) (*secret.PassphraseU
} }
// Encrypt private key with passphrase // Encrypt private key with passphrase
privKeyData := []byte(unlockerIdentity.String()) privKeyStr := unlockerIdentity.String()
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyData, passphrase) privKeyBuffer := memguard.NewBufferFromBytes([]byte(privKeyStr))
defer privKeyBuffer.Destroy()
encryptedPrivKey, err := secret.EncryptWithPassphrase(privKeyBuffer.Bytes(), passphrase)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to encrypt unlocker private key: %w", err) return nil, fmt.Errorf("failed to encrypt unlocker private key: %w", err)
} }

View File

@ -6,6 +6,7 @@ import (
"git.eeqj.de/sneak/secret/internal/secret" "git.eeqj.de/sneak/secret/internal/secret"
"git.eeqj.de/sneak/secret/pkg/agehd" "git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/awnumar/memguard"
"github.com/spf13/afero" "github.com/spf13/afero"
) )
@ -172,7 +173,9 @@ func TestVaultOperations(t *testing.T) {
} }
// Create a passphrase unlocker // Create a passphrase unlocker
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker("test-passphrase") passphraseBuffer := memguard.NewBufferFromBytes([]byte("test-passphrase"))
defer passphraseBuffer.Destroy()
passphraseUnlocker, err := vlt.CreatePassphraseUnlocker(passphraseBuffer)
if err != nil { if err != nil {
t.Fatalf("Failed to create passphrase unlocker: %v", err) t.Fatalf("Failed to create passphrase unlocker: %v", err)
} }

View File

@ -33,7 +33,7 @@ const (
// AppHDWIF is the application number for WIF (Wallet Import Format) for Bitcoin Core // AppHDWIF is the application number for WIF (Wallet Import Format) for Bitcoin Core
AppHDWIF = 2 AppHDWIF = 2
// AppXPRV is the application number for extended private key // AppXPRV is the application number for extended private key
AppXPRV = 32 AppXPRV = 32
APP_HEX = 128169 //nolint:revive // ALL_CAPS used for BIP85 constants APP_HEX = 128169 //nolint:revive // ALL_CAPS used for BIP85 constants
APP_PWD64 = 707764 // Base64 passwords //nolint:revive // ALL_CAPS used for BIP85 constants APP_PWD64 = 707764 // Base64 passwords //nolint:revive // ALL_CAPS used for BIP85 constants
AppPWD85 = 707785 // Base85 passwords AppPWD85 = 707785 // Base85 passwords