secret/internal/secret/pgpunlocker.go
sneak bdcddadf90 fix: resolve exported type stuttering issues (revive)
- Rename VaultMetadata to Metadata in internal/vault package to avoid stuttering
- Rename BIP85DRNG to DRNG in pkg/bip85 package to avoid stuttering
- Update all references in code and tests
2025-06-20 12:47:06 -07:00

418 lines
13 KiB
Go

package secret
import (
"encoding/json"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"filippo.io/age"
"git.eeqj.de/sneak/secret/pkg/agehd"
"github.com/spf13/afero"
)
// 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
GPGEncryptFunc = gpgEncryptDefault
// GPGDecryptFunc is the function used for GPG decryption
// Can be overridden in tests to provide a non-interactive implementation
GPGDecryptFunc = 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:"gpg_key_id"`
}
// PGPUnlocker represents a PGP-protected unlocker
type PGPUnlocker struct {
Directory string
Metadata UnlockerMetadata
fs afero.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())
agePrivKeyData, 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)
}
DebugWith("Successfully decrypted age private key with GPG",
slog.String("unlocker_id", p.GetID()),
slog.Int("decrypted_length", len(agePrivKeyData)),
)
// Step 3: Parse the decrypted age private key
Debug("Parsing decrypted age private key", "unlocker_id", p.GetID())
ageIdentity, err := age.ParseX25519Identity(string(agePrivKeyData))
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
func (p *PGPUnlocker) GetID() string {
// Generate ID using GPG key ID: <keyid>-pgp
gpgKeyID, err := p.GetGPGKeyID()
if err != nil {
// The vault metadata is corrupt - this is a fatal error
// We cannot continue with a fallback ID as that would mask data corruption
panic(fmt.Sprintf("PGP unlocker metadata is corrupt or missing GPG key ID: %v", err))
}
return fmt.Sprintf("%s-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
if err := p.fs.RemoveAll(p.Directory); err != nil {
return fmt.Errorf("failed to remove PGP unlocker directory: %w", err)
}
return nil
}
// NewPGPUnlocker creates a new PGPUnlocker instance
func NewPGPUnlocker(fs afero.Fs, directory string, metadata UnlockerMetadata) *PGPUnlocker {
return &PGPUnlocker{
Directory: directory,
Metadata: metadata,
fs: fs,
}
}
// 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
if err := json.Unmarshal(metadataData, &pgpMetadata); err != nil {
return "", fmt.Errorf("failed to parse PGP metadata: %w", err)
}
return pgpMetadata.GPGKeyID, nil
}
// generatePGPUnlockerName generates a unique name for the PGP unlocker based on hostname and date
func generatePGPUnlockerName() (string, error) {
hostname, err := os.Hostname()
if err != nil {
return "", fmt.Errorf("failed to get hostname: %w", err)
}
// Format: hostname-pgp-YYYY-MM-DD
enrollmentDate := time.Now().Format("2006-01-02")
return fmt.Sprintf("%s-pgp-%s", hostname, enrollmentDate), nil
}
// CreatePGPUnlocker creates a new PGP unlocker and stores it in the vault
func CreatePGPUnlocker(fs afero.Fs, stateDir string, gpgKeyID string) (*PGPUnlocker, error) {
// Check if GPG is available
if err := checkGPGAvailable(); err != nil {
return nil, err
}
// 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 date
unlockerName, err := generatePGPUnlockerName()
if err != nil {
return nil, fmt.Errorf("failed to generate unlocker name: %w", err)
}
// Create unlocker directory using the generated name
vaultDir, err := vault.GetDirectory()
if err != nil {
return nil, fmt.Errorf("failed to get vault directory: %w", err)
}
unlockerDir := filepath.Join(vaultDir, "unlockers.d", unlockerName)
if err := fs.MkdirAll(unlockerDir, DirPerms); err != nil {
return nil, fmt.Errorf("failed to create unlocker directory: %w", err)
}
// 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: Store age recipient as plaintext
ageRecipient := ageIdentity.Recipient().String()
recipientPath := filepath.Join(unlockerDir, "pub.txt")
if err := afero.WriteFile(fs, recipientPath, []byte(ageRecipient), FilePerms); err != nil {
return nil, fmt.Errorf("failed to write age recipient: %w", err)
}
// Step 3: Get or derive the long-term private key
var ltPrivKeyData []byte
// Check if mnemonic is available in environment variable
if envMnemonic := os.Getenv(EnvMnemonic); envMnemonic != "" {
// Use mnemonic directly to derive long-term key
ltIdentity, err := agehd.DeriveIdentity(envMnemonic, 0)
if err != nil {
return nil, fmt.Errorf("failed to derive long-term key from mnemonic: %w", err)
}
ltPrivKeyData = []byte(ltIdentity.String())
} else {
// Get the vault to access current unlocker
currentUnlocker, err := vault.GetCurrentUnlocker()
if err != nil {
return nil, fmt.Errorf("failed to get current unlocker: %w", err)
}
// Get the current unlocker identity
currentUnlockerIdentity, err := currentUnlocker.GetIdentity()
if err != nil {
return nil, fmt.Errorf("failed to get current unlocker identity: %w", err)
}
// Get encrypted long-term key from current unlocker, handling different types
var encryptedLtPrivKey []byte
switch currentUnlocker := currentUnlocker.(type) {
case *PassphraseUnlocker:
// Read the encrypted long-term private key from passphrase unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current passphrase unlocker: %w", err)
}
case *PGPUnlocker:
// Read the encrypted long-term private key from PGP unlocker
encryptedLtPrivKey, err = afero.ReadFile(fs, filepath.Join(currentUnlocker.GetDirectory(), "longterm.age"))
if err != nil {
return nil, fmt.Errorf("failed to read encrypted long-term key from current PGP unlocker: %w", err)
}
default:
return nil, fmt.Errorf("unsupported current unlocker type for PGP unlocker creation")
}
// Step 6: Decrypt long-term private key using current unlocker
ltPrivKeyData, err = DecryptWithIdentity(encryptedLtPrivKey, currentUnlockerIdentity)
if err != nil {
return nil, fmt.Errorf("failed to decrypt long-term private key: %w", err)
}
}
// Step 7: Encrypt long-term private key to the new age unlocker
encryptedLtPrivKeyToAge, err := EncryptToRecipient(ltPrivKeyData, ageIdentity.Recipient())
if err != nil {
return nil, fmt.Errorf("failed to encrypt long-term private key to age unlocker: %w", err)
}
// Write encrypted long-term private key
ltPrivKeyPath := filepath.Join(unlockerDir, "longterm.age")
if err := afero.WriteFile(fs, ltPrivKeyPath, encryptedLtPrivKeyToAge, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write encrypted long-term private key: %w", err)
}
// Step 8: Encrypt age private key to the GPG key ID
agePrivateKeyBytes := []byte(ageIdentity.String())
encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBytes, gpgKeyID)
if err != nil {
return nil, fmt.Errorf("failed to encrypt age private key with GPG: %w", err)
}
agePrivKeyPath := filepath.Join(unlockerDir, "priv.age.gpg")
if err := afero.WriteFile(fs, agePrivKeyPath, encryptedAgePrivKey, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write encrypted age private key: %w", err)
}
// Step 9: Resolve the GPG key ID to its full fingerprint
fingerprint, err := resolveGPGKeyFingerprint(gpgKeyID)
if err != nil {
return nil, fmt.Errorf("failed to resolve GPG key fingerprint: %w", err)
}
// Step 10: Create and write enhanced metadata with full fingerprint
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)
}
if err := afero.WriteFile(fs,
filepath.Join(unlockerDir, "unlocker-metadata.json"),
metadataBytes, FilePerms); err != nil {
return nil, fmt.Errorf("failed to write unlocker metadata: %w", err)
}
return &PGPUnlocker{
Directory: unlockerDir,
Metadata: pgpMetadata.UnlockerMetadata,
fs: fs,
}, nil
}
// validateGPGKeyID validates that a GPG key ID is safe for command execution
func validateGPGKeyID(keyID string) error {
if keyID == "" {
return fmt.Errorf("GPG key ID cannot be empty")
}
if !gpgKeyIDRegex.MatchString(keyID) {
return fmt.Errorf("invalid GPG key ID format: %s", keyID)
}
return nil
}
// resolveGPGKeyFingerprint resolves any GPG key identifier to its full fingerprint
func resolveGPGKeyFingerprint(keyID string) (string, error) {
if err := validateGPGKeyID(keyID); err != nil {
return "", fmt.Errorf("invalid GPG key ID: %w", err)
}
// Use GPG to get the full fingerprint for the key
cmd := exec.Command("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
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "fpr:") {
fields := strings.Split(line, ":")
if len(fields) >= 10 && fields[9] != "" {
return fields[9], nil
}
}
}
return "", fmt.Errorf("could not find fingerprint for GPG key: %s", keyID)
}
// checkGPGAvailable verifies that GPG is available
func checkGPGAvailable() error {
cmd := exec.Command("gpg", "--version")
if err := cmd.Run(); 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 []byte, keyID string) ([]byte, error) {
if err := validateGPGKeyID(keyID); err != nil {
return nil, fmt.Errorf("invalid GPG key ID: %w", err)
}
cmd := exec.Command("gpg", "--trust-model", "always", "--armor", "--encrypt", "-r", keyID)
cmd.Stdin = strings.NewReader(string(data))
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) ([]byte, error) {
cmd := exec.Command("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)
}
return output, nil
}