check / check (push) Failing after 3s
secret.IdentityToLockedBuffer replaces the eight places that converted an age identity's String() to bytes for a locked buffer and left the string, which holds the private key, in ordinary memory. It moves the string's own bytes into the buffer, which overwrites them. The copies age makes while encoding the key remain; the function's comment says so. TODO.md drops these places from the 1.0 memory-security entry, along with its stale version.go reference. Model: opus-5-5
484 lines
14 KiB
Go
484 lines
14 KiB
Go
package secret
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
|
|
"filippo.io/age"
|
|
"github.com/awnumar/memguard"
|
|
"github.com/spf13/afero"
|
|
)
|
|
|
|
var (
|
|
errGPGKeyIDEmpty = errors.New("GPG key ID cannot be empty")
|
|
errInvalidGPGKeyID = errors.New("invalid GPG key ID format")
|
|
errNoGPGFingerprint = errors.New("could not find fingerprint for GPG key")
|
|
errNilDataBuffer = errors.New("data buffer is nil")
|
|
)
|
|
|
|
// Variables to allow overriding in tests
|
|
var (
|
|
// GPGEncryptFunc is the function used for GPG encryption
|
|
// Can be overridden in tests to provide a non-interactive implementation
|
|
//nolint:gochecknoglobals // Required for test mocking
|
|
GPGEncryptFunc func(
|
|
data *memguard.LockedBuffer, keyID string,
|
|
) ([]byte, error) = gpgEncryptDefault
|
|
|
|
// GPGDecryptFunc is the function used for GPG decryption
|
|
// Can be overridden in tests to provide a non-interactive implementation
|
|
//nolint:gochecknoglobals // Required for test mocking
|
|
GPGDecryptFunc func(
|
|
encryptedData []byte,
|
|
) (*memguard.LockedBuffer, error) = gpgDecryptDefault
|
|
|
|
// gpgKeyIDRegex validates GPG key IDs
|
|
// Allows either:
|
|
// 1. Email addresses (user@domain.tld format)
|
|
// 2. Short key IDs (8 hex characters)
|
|
// 3. Long key IDs (16 hex characters)
|
|
// 4. Full fingerprints (40 hex characters)
|
|
gpgKeyIDRegex = regexp.MustCompile(
|
|
`^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$|` +
|
|
`^[A-Fa-f0-9]{8}$|` +
|
|
`^[A-Fa-f0-9]{16}$|` +
|
|
`^[A-Fa-f0-9]{40}$`,
|
|
)
|
|
)
|
|
|
|
// PGPUnlockerMetadata extends UnlockerMetadata with PGP-specific data
|
|
type PGPUnlockerMetadata struct {
|
|
UnlockerMetadata
|
|
|
|
// GPG key ID used for encryption
|
|
GPGKeyID string `json:"gpgKeyId"`
|
|
}
|
|
|
|
// PGPUnlocker represents a PGP-protected unlocker
|
|
type PGPUnlocker struct {
|
|
Directory string
|
|
Metadata UnlockerMetadata
|
|
fs afero.Fs
|
|
}
|
|
|
|
// NewPGPUnlocker creates a new PGPUnlocker instance
|
|
func NewPGPUnlocker(
|
|
fs afero.Fs, directory string, metadata UnlockerMetadata,
|
|
) *PGPUnlocker {
|
|
return &PGPUnlocker{
|
|
Directory: directory,
|
|
Metadata: metadata,
|
|
fs: fs,
|
|
}
|
|
}
|
|
|
|
// GetIdentity implements Unlocker interface for PGP-based unlockers
|
|
func (p *PGPUnlocker) GetIdentity() (*age.X25519Identity, error) {
|
|
DebugWith("Getting PGP unlocker identity",
|
|
slog.String("unlocker_id", p.GetID()),
|
|
slog.String("unlocker_type", p.GetType()),
|
|
)
|
|
|
|
// Step 1: Read the encrypted age private key from filesystem
|
|
agePrivKeyPath := filepath.Join(p.Directory, "priv.age.gpg")
|
|
Debug("Reading PGP-encrypted age private key", "path", agePrivKeyPath)
|
|
|
|
encryptedAgePrivKeyData, err := afero.ReadFile(p.fs, agePrivKeyPath)
|
|
if err != nil {
|
|
Debug("Failed to read PGP-encrypted age private key",
|
|
"error", err, "path", agePrivKeyPath)
|
|
|
|
return nil, fmt.Errorf("failed to read encrypted age private key: %w", err)
|
|
}
|
|
|
|
DebugWith("Read PGP-encrypted age private key",
|
|
slog.String("unlocker_id", p.GetID()),
|
|
slog.Int("encrypted_length", len(encryptedAgePrivKeyData)),
|
|
)
|
|
|
|
// Step 2: Decrypt the age private key using GPG
|
|
Debug("Decrypting age private key with GPG", "unlocker_id", p.GetID())
|
|
|
|
agePrivKeyBuffer, err := GPGDecryptFunc(encryptedAgePrivKeyData)
|
|
if err != nil {
|
|
Debug("Failed to decrypt age private key with GPG",
|
|
"error", err, "unlocker_id", p.GetID())
|
|
|
|
return nil, fmt.Errorf("failed to decrypt age private key with GPG: %w", err)
|
|
}
|
|
defer agePrivKeyBuffer.Destroy()
|
|
|
|
DebugWith("Successfully decrypted age private key with GPG",
|
|
slog.String("unlocker_id", p.GetID()),
|
|
slog.Int("decrypted_length", agePrivKeyBuffer.Size()),
|
|
)
|
|
|
|
// Step 3: Parse the decrypted age private key
|
|
Debug("Parsing decrypted age private key", "unlocker_id", p.GetID())
|
|
|
|
ageIdentity, err := age.ParseX25519Identity(agePrivKeyBuffer.String())
|
|
if err != nil {
|
|
Debug("Failed to parse age private key", "error", err, "unlocker_id", p.GetID())
|
|
|
|
return nil, fmt.Errorf("failed to parse age private key: %w", err)
|
|
}
|
|
|
|
DebugWith("Successfully parsed PGP age identity",
|
|
slog.String("unlocker_id", p.GetID()),
|
|
slog.String("public_key", ageIdentity.Recipient().String()),
|
|
)
|
|
|
|
return ageIdentity, nil
|
|
}
|
|
|
|
// GetType implements Unlocker interface
|
|
func (p *PGPUnlocker) GetType() string {
|
|
return "pgp"
|
|
}
|
|
|
|
// GetMetadata implements Unlocker interface
|
|
func (p *PGPUnlocker) GetMetadata() UnlockerMetadata {
|
|
return p.Metadata
|
|
}
|
|
|
|
// GetDirectory implements Unlocker interface
|
|
func (p *PGPUnlocker) GetDirectory() string {
|
|
return p.Directory
|
|
}
|
|
|
|
// GetID implements Unlocker interface - generates ID from GPG key ID.
|
|
// If the metadata has no usable GPG key ID, it warns with the unlocker's
|
|
// directory and returns "pgp-unknown", so listing the other unlockers
|
|
// still works.
|
|
func (p *PGPUnlocker) GetID() string {
|
|
// Generate ID using GPG key ID: pgp-<keyid>
|
|
gpgKeyID, err := p.GetGPGKeyID()
|
|
if err != nil {
|
|
Warn("PGP unlocker metadata is corrupt or missing its GPG key ID",
|
|
"directory", p.Directory, "error", err)
|
|
|
|
return "pgp-unknown"
|
|
}
|
|
|
|
return "pgp-" + gpgKeyID
|
|
}
|
|
|
|
// Remove implements Unlocker interface - removes the PGP unlocker
|
|
func (p *PGPUnlocker) Remove() error {
|
|
// For PGP 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 PGP unlocker directory: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GetGPGKeyID returns the GPG key ID from metadata
|
|
func (p *PGPUnlocker) GetGPGKeyID() (string, error) {
|
|
// Load the metadata
|
|
metadataPath := filepath.Join(p.Directory, "unlocker-metadata.json")
|
|
|
|
metadataData, err := afero.ReadFile(p.fs, metadataPath)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to read PGP metadata: %w", err)
|
|
}
|
|
|
|
var pgpMetadata PGPUnlockerMetadata
|
|
|
|
err = json.Unmarshal(metadataData, &pgpMetadata)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
|
|
}
|
|
|
|
if pgpMetadata.GPGKeyID == "" {
|
|
return "", fmt.Errorf("PGP metadata: %w", errGPGKeyIDEmpty)
|
|
}
|
|
|
|
return pgpMetadata.GPGKeyID, nil
|
|
}
|
|
|
|
// generatePGPUnlockerName generates a unique name for the PGP unlocker
|
|
// based on hostname and time
|
|
func generatePGPUnlockerName() (string, error) {
|
|
hostname, err := os.Hostname()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get hostname: %w", err)
|
|
}
|
|
|
|
enrollmentTime := time.Now().UTC().Format(UnlockerTimeFormat)
|
|
|
|
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentTime), nil
|
|
}
|
|
|
|
// pgpUnlockerDir returns the current vault and the directory in it for a
|
|
// new PGP unlocker, named after the host and the time.
|
|
//
|
|
//nolint:ireturn // the vault is only available behind VaultInterface
|
|
func pgpUnlockerDir(
|
|
fs afero.Fs, stateDir string,
|
|
) (VaultInterface, string, error) {
|
|
// Get current vault
|
|
vault, err := GetCurrentVault(fs, stateDir)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to get current vault: %w", err)
|
|
}
|
|
|
|
// Generate the unlocker name based on hostname and time
|
|
unlockerName, err := generatePGPUnlockerName()
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to generate unlocker name: %w", err)
|
|
}
|
|
|
|
vaultDir, err := vault.GetDirectory()
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to get vault directory: %w", err)
|
|
}
|
|
|
|
return vault, filepath.Join(vaultDir, "unlockers.d", unlockerName), nil
|
|
}
|
|
|
|
// CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault.
|
|
// It encrypts to the GPG key gpgKeyID and records fingerprint, that key's
|
|
// 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. 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 {
|
|
return nil, err
|
|
}
|
|
|
|
vault, unlockerDir, err := pgpUnlockerDir(fs, stateDir)
|
|
if err != nil {
|
|
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 {
|
|
return nil, fmt.Errorf("failed to generate age keypair: %w", err)
|
|
}
|
|
|
|
// Step 2: Encrypt the long-term private key to the new keypair, and the
|
|
// keypair's private key to the GPG key
|
|
encryptedLtPrivKey, encryptedAgePrivKey, err := encryptPGPUnlockerKeys(
|
|
vault, ageIdentity, gpgKeyID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
pgpMetadata := PGPUnlockerMetadata{
|
|
UnlockerMetadata: UnlockerMetadata{
|
|
Type: "pgp",
|
|
CreatedAt: time.Now(),
|
|
Flags: []string{"gpg", "encrypted"},
|
|
},
|
|
GPGKeyID: fingerprint,
|
|
}
|
|
|
|
metadataBytes, err := json.MarshalIndent(pgpMetadata, "", " ")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal unlocker metadata: %w", err)
|
|
}
|
|
|
|
// Step 3: Write the unlocker's files, the metadata last
|
|
err = WriteDir(fs, unlockerDir, func(dir string) error {
|
|
return writePGPUnlockerFiles(fs, dir, ageIdentity.Recipient(),
|
|
encryptedLtPrivKey, encryptedAgePrivKey, metadataBytes)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &PGPUnlocker{
|
|
Directory: unlockerDir,
|
|
Metadata: pgpMetadata.UnlockerMetadata,
|
|
fs: fs,
|
|
}, nil
|
|
}
|
|
|
|
// encryptPGPUnlockerKeys returns the vault's long-term private key encrypted
|
|
// to the new PGP unlocker's age keypair, and that keypair's private key
|
|
// encrypted to the GPG key gpgKeyID.
|
|
func encryptPGPUnlockerKeys(
|
|
vault VaultInterface, ageIdentity *age.X25519Identity, gpgKeyID string,
|
|
) ([]byte, []byte, error) {
|
|
// From the mnemonic or the current unlocker, as for a passphrase unlocker
|
|
ltIdentity, err := vault.GetOrDeriveLongTermKey()
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to get long-term key: %w", err)
|
|
}
|
|
|
|
ltPrivKeyData := IdentityToLockedBuffer(ltIdentity)
|
|
defer ltPrivKeyData.Destroy()
|
|
|
|
encryptedLtPrivKey, err := EncryptToRecipient(
|
|
ltPrivKeyData, ageIdentity.Recipient())
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"failed to encrypt long-term private key to age unlocker: %w", err)
|
|
}
|
|
|
|
agePrivateKeyBuffer := IdentityToLockedBuffer(ageIdentity)
|
|
defer agePrivateKeyBuffer.Destroy()
|
|
|
|
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer, gpgKeyID)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf(
|
|
"failed to encrypt age private key with GPG: %w", err)
|
|
}
|
|
|
|
return encryptedLtPrivKey, encryptedAgePrivKey, nil
|
|
}
|
|
|
|
// writePGPUnlockerFiles writes the files of a PGP unlocker into dir, the
|
|
// metadata last.
|
|
func writePGPUnlockerFiles(
|
|
fs afero.Fs, dir string, ageRecipient *age.X25519Recipient,
|
|
encryptedLtPrivKey, encryptedAgePrivKey, metadataBytes []byte,
|
|
) error {
|
|
err := WriteFileAtomic(fs, filepath.Join(dir, "pub.txt"),
|
|
[]byte(ageRecipient.String()))
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write age recipient: %w", err)
|
|
}
|
|
|
|
err = WriteFileAtomic(fs, filepath.Join(dir, "longterm.age"), encryptedLtPrivKey)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write encrypted long-term private key: %w", err)
|
|
}
|
|
|
|
err = WriteFileAtomic(fs, filepath.Join(dir, "priv.age.gpg"), encryptedAgePrivKey)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write encrypted age private key: %w", err)
|
|
}
|
|
|
|
err = WriteFileAtomic(fs,
|
|
filepath.Join(dir, "unlocker-metadata.json"), metadataBytes)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to write unlocker metadata: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// validateGPGKeyID validates that a GPG key ID is safe for command execution
|
|
func validateGPGKeyID(keyID string) error {
|
|
if keyID == "" {
|
|
return errGPGKeyIDEmpty
|
|
}
|
|
|
|
if !gpgKeyIDRegex.MatchString(keyID) {
|
|
return fmt.Errorf("%w: %s", errInvalidGPGKeyID, keyID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ResolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
|
|
func ResolveGPGKeyFingerprint(keyID string) (string, error) {
|
|
err := validateGPGKeyID(keyID)
|
|
if err != nil {
|
|
return "", fmt.Errorf("invalid GPG key ID: %w", err)
|
|
}
|
|
|
|
// Use GPG to get the full fingerprint for the key
|
|
cmd := exec.CommandContext( //nolint:gosec // G204: keyID validated above
|
|
context.Background(),
|
|
"gpg", "--list-keys", "--with-colons", "--fingerprint", keyID,
|
|
)
|
|
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
|
|
}
|
|
|
|
// Parse the output to extract the fingerprint
|
|
for line := range strings.SplitSeq(string(output), "\n") {
|
|
if strings.HasPrefix(line, "fpr:") {
|
|
fields := strings.Split(line, ":")
|
|
if len(fields) >= 10 && fields[9] != "" {
|
|
return fields[9], nil
|
|
}
|
|
}
|
|
}
|
|
|
|
return "", fmt.Errorf("%w: %s", errNoGPGFingerprint, keyID)
|
|
}
|
|
|
|
// checkGPGAvailable verifies that GPG is available
|
|
func checkGPGAvailable() error {
|
|
cmd := exec.CommandContext(context.Background(), "gpg", "--version")
|
|
|
|
err := cmd.Run()
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"GPG not available: %w (make sure 'gpg' command is installed and in PATH)",
|
|
err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// gpgEncryptDefault is the default implementation of GPG encryption
|
|
func gpgEncryptDefault(data *memguard.LockedBuffer, keyID string) ([]byte, error) {
|
|
if data == nil {
|
|
return nil, errNilDataBuffer
|
|
}
|
|
|
|
err := validateGPGKeyID(keyID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid GPG key ID: %w", err)
|
|
}
|
|
|
|
cmd := exec.CommandContext( //nolint:gosec // G204: keyID validated above
|
|
context.Background(),
|
|
"gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID,
|
|
)
|
|
cmd.Stdin = strings.NewReader(data.String())
|
|
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GPG encryption failed: %w", err)
|
|
}
|
|
|
|
return output, nil
|
|
}
|
|
|
|
// gpgDecryptDefault is the default implementation of GPG decryption
|
|
func gpgDecryptDefault(encryptedData []byte) (*memguard.LockedBuffer, error) {
|
|
cmd := exec.CommandContext(context.Background(), "gpg", "--quiet", "--decrypt")
|
|
cmd.Stdin = strings.NewReader(string(encryptedData))
|
|
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("GPG decryption failed: %w", err)
|
|
}
|
|
|
|
// Create a secure buffer for the decrypted data
|
|
outputBuffer := memguard.NewBufferFromBytes(output)
|
|
|
|
return outputBuffer, nil
|
|
}
|