package secret import ( "encoding/json" "fmt" "log/slog" "os" "os/exec" "path/filepath" "regexp" "strings" "time" "filippo.io/age" "github.com/awnumar/memguard" "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 //nolint:gochecknoglobals // Required for test mocking // GPGDecryptFunc is the function used for GPG decryption // Can be overridden in tests to provide a non-interactive implementation GPGDecryptFunc = gpgDecryptDefault //nolint:gochecknoglobals // Required for test mocking // 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 } // 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: -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 ltPrivKeyData, err := getLongTermPrivateKey(fs, vault) if err != nil { return nil, err } defer ltPrivKeyData.Destroy() // 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 // Use memguard to protect the private key in memory agePrivateKeyBuffer := memguard.NewBufferFromBytes([]byte(ageIdentity.String())) defer agePrivateKeyBuffer.Destroy() encryptedAgePrivKey, err := GPGEncryptFunc(agePrivateKeyBuffer.Bytes(), 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 }