Files
secret/internal/secret/passphraseunlocker.go
T
clawbot 63928ea745
check / check (push) Waiting to run
Read secret environment variables once per command, then unset them (closes #60)
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.
Nothing below the command reads the environment. README warns against
both variables.

Model: opus-5-5
2026-10-04 12:28:18 +00:00

176 lines
5.2 KiB
Go

package secret
import (
"fmt"
"log/slog"
"path/filepath"
"filippo.io/age"
"github.com/awnumar/memguard"
"github.com/spf13/afero"
)
// PassphraseUnlocker represents a passphrase-protected unlocker
type PassphraseUnlocker struct {
Directory string
Metadata UnlockerMetadata
fs afero.Fs
Passphrase *memguard.LockedBuffer // Secure buffer for passphrase
}
// NewPassphraseUnlocker creates a new PassphraseUnlocker instance
func NewPassphraseUnlocker(
fs afero.Fs, directory string, metadata UnlockerMetadata,
) *PassphraseUnlocker {
return &PassphraseUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// GetIdentity implements Unlocker interface for passphrase-based unlockers
func (p *PassphraseUnlocker) GetIdentity() (*age.X25519Identity, error) {
DebugWith("Getting passphrase unlocker identity",
slog.String("unlocker_id", p.GetID()),
slog.String("unlocker_type", p.GetType()),
)
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")
Debug("Reading encrypted passphrase unlocker", "path", unlockerPrivPath)
encryptedPrivKeyData, err := afero.ReadFile(p.fs, unlockerPrivPath)
if err != nil {
Debug("Failed to read passphrase unlocker private key",
"error", err, "path", unlockerPrivPath)
return nil, fmt.Errorf("failed to read unlocker private key: %w", err)
}
DebugWith("Read encrypted passphrase unlocker",
slog.String("unlocker_id", p.GetID()),
slog.Int("encrypted_length", len(encryptedPrivKeyData)),
)
Debug("Decrypting unlocker private key with passphrase", "unlocker_id", p.GetID())
// Decrypt the unlocker private key with passphrase
privKeyBuffer, err := DecryptWithPassphrase(encryptedPrivKeyData, passphraseBuffer)
if err != nil {
Debug("Failed to decrypt unlocker private key",
"error", err, "unlocker_id", p.GetID())
return nil, fmt.Errorf("failed to decrypt unlocker private key: %w", err)
}
defer privKeyBuffer.Destroy()
DebugWith("Successfully decrypted unlocker private key",
slog.String("unlocker_id", p.GetID()),
slog.Int("decrypted_length", privKeyBuffer.Size()),
)
// Parse the decrypted private key
Debug("Parsing decrypted unlocker identity", "unlocker_id", p.GetID())
identity, err := age.ParseX25519Identity(privKeyBuffer.String())
if err != nil {
Debug("Failed to parse unlocker private key", "error", err, "unlocker_id", p.GetID())
return nil, fmt.Errorf("failed to parse unlocker private key: %w", err)
}
DebugWith("Successfully parsed passphrase unlocker identity",
slog.String("unlocker_id", p.GetID()),
slog.String("public_key", identity.Recipient().String()),
)
return identity, nil
}
// GetType implements Unlocker interface
func (p *PassphraseUnlocker) GetType() string {
return "passphrase"
}
// GetMetadata implements Unlocker interface
func (p *PassphraseUnlocker) GetMetadata() UnlockerMetadata {
return p.Metadata
}
// GetDirectory implements Unlocker interface
func (p *PassphraseUnlocker) GetDirectory() string {
return p.Directory
}
// GetID implements Unlocker interface - generates ID from creation timestamp
func (p *PassphraseUnlocker) GetID() string {
// Generate ID using creation timestamp: YYYY-MM-DD.HH.mm-passphrase
createdAt := p.Metadata.CreatedAt
return createdAt.Format("2006-01-02.15.04") + "-passphrase"
}
// 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
err := RemoveDirAtomic(p.fs, p.Directory)
if err != nil {
return fmt.Errorf("failed to remove passphrase unlocker directory: %w", err)
}
return nil
}
// 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) {
if p.Passphrase != nil && p.Passphrase.IsAlive() {
Debug("Using in-memory passphrase", "unlocker_id", p.GetID())
// 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, prompting user")
// Prompt for passphrase
secureBuffer, err := ReadPassphrase("Enter unlock passphrase: ")
if err != nil {
Debug("Failed to read passphrase", "error", err, "unlocker_id", p.GetID())
return nil, fmt.Errorf("failed to read passphrase: %w", err)
}
return secureBuffer, nil
}
// CreatePassphraseUnlocker creates a new passphrase-protected unlocker
// 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 {
return nil, fmt.Errorf("failed to get current vault: %w", err)
}
return currentVault.CreatePassphraseUnlocker(passphrase)
}