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: the name of the unlocker's directory func (p *PGPUnlocker) GetID() string { return filepath.Base(p.Directory) } // 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 } // 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 }