package secret import ( "encoding/json" "fmt" "log/slog" "os" "os/exec" "path/filepath" "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 ) // PGPUnlockerMetadata extends UnlockerMetadata with PGP-specific data type PGPUnlockerMetadata struct { UnlockerMetadata // GPG key ID used for encryption GPGKeyID string `json:"gpg_key_id"` // Age keypair information AgePublicKey string `json:"age_public_key"` AgeRecipient string `json:"age_recipient"` } // 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 func (p *PGPUnlocker) GetID() string { return p.Metadata.ID } // ID implements Unlocker interface - generates ID from GPG key ID func (p *PGPUnlocker) ID() string { // Generate ID using GPG key ID: <keyid>-pgp gpgKeyID, err := p.GetGPGKeyID() if err != nil { // Fallback to metadata ID if we can't read the GPG key ID return p.Metadata.ID } 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 public key as plaintext agePublicKeyString := ageIdentity.Recipient().String() agePubKeyPath := filepath.Join(unlockerDir, "pub.age") if err := afero.WriteFile(fs, agePubKeyPath, []byte(agePublicKeyString), FilePerms); err != nil { return nil, fmt.Errorf("failed to write age public key: %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: Create and write enhanced metadata // Generate the key ID directly using the GPG key ID keyID := fmt.Sprintf("%s-pgp", gpgKeyID) pgpMetadata := PGPUnlockerMetadata{ UnlockerMetadata: UnlockerMetadata{ ID: keyID, Type: "pgp", CreatedAt: time.Now(), Flags: []string{"gpg", "encrypted"}, }, GPGKeyID: gpgKeyID, AgePublicKey: agePublicKeyString, AgeRecipient: ageIdentity.Recipient().String(), } 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 } // 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) { 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 }