diff --git a/backend/backend.go b/backend/backend.go index ee1599e..2018e31 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -82,6 +82,9 @@ type Attachment struct { IsSMIMESignature bool SMIMEVerified bool IsSMIMEEncrypted bool + IsPGPSignature bool + PGPVerified bool + IsPGPEncrypted bool } // Folder represents a mailbox/folder. @@ -105,6 +108,8 @@ type OutgoingEmail struct { References []string SignSMIME bool EncryptSMIME bool + SignPGP bool + EncryptPGP bool } // NotifyType indicates the kind of notification event. diff --git a/backend/imap/imap.go b/backend/imap/imap.go index 32e4d09..cce7fc3 100644 --- a/backend/imap/imap.go +++ b/backend/imap/imap.go @@ -70,6 +70,7 @@ func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) erro msg.Images, msg.Attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME, + msg.SignPGP, msg.EncryptPGP, ) } diff --git a/backend/pop3/pop3.go b/backend/pop3/pop3.go index 053ddb6..ff76b1e 100644 --- a/backend/pop3/pop3.go +++ b/backend/pop3/pop3.go @@ -212,6 +212,7 @@ func (p *Provider) SendEmail(_ context.Context, msg *backend.OutgoingEmail) erro msg.Images, msg.Attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME, + msg.SignPGP, msg.EncryptPGP, ) } diff --git a/config/config.go b/config/config.go index 9971335..0d5e106 100644 --- a/config/config.go +++ b/config/config.go @@ -34,6 +34,14 @@ type Account struct { SMIMEKey string `json:"smime_key,omitempty"` // Path to the private key PEM SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"` // Whether to enable S/MIME signing by default + // PGP settings + PGPPublicKey string `json:"pgp_public_key,omitempty"` // Path to public key (.asc or .gpg) + PGPPrivateKey string `json:"pgp_private_key,omitempty"` // Path to private key (.asc or .gpg) + PGPKeySource string `json:"pgp_key_source,omitempty"` // "file" (default) or "yubikey" for hardware key + PGPPIN string `json:"-"` // YubiKey PIN (stored in keyring, not JSON) + PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"` // Auto-sign outgoing emails + PGPEncryptByDefault bool `json:"pgp_encrypt_by_default,omitempty"` // Auto-encrypt when recipient keys available + // OAuth2 settings AuthMethod string `json:"auth_method,omitempty"` // "password" (default) or "oauth2" @@ -159,13 +167,17 @@ func configFile() (string, error) { // SaveConfig saves the given configuration to the config file and passwords to the keyring. func SaveConfig(config *Config) error { - // Save passwords to the OS keyring before writing the JSON file + // Save passwords and PGP PINs to the OS keyring before writing the JSON file for _, acc := range config.Accounts { if acc.Password != "" { // We ignore the error here because some environments (like headless CI) // might not have a keyring service, but we still want to save the rest of the config. _ = keyring.Set(keyringServiceName, acc.Email, acc.Password) } + // Save YubiKey PIN if present + if acc.PGPPIN != "" && acc.PGPKeySource == "yubikey" { + _ = keyring.Set(keyringServiceName, acc.Email+":pgp-pin", acc.PGPPIN) + } } path, err := configFile() @@ -198,25 +210,30 @@ func LoadConfig() (*Config, error) { var needsMigration bool type rawAccount struct { - ID string `json:"id"` - Name string `json:"name"` - Email string `json:"email"` - Password string `json:"password,omitempty"` - ServiceProvider string `json:"service_provider"` - FetchEmail string `json:"fetch_email,omitempty"` - IMAPServer string `json:"imap_server,omitempty"` - IMAPPort int `json:"imap_port,omitempty"` - SMTPServer string `json:"smtp_server,omitempty"` - SMTPPort int `json:"smtp_port,omitempty"` - Insecure bool `json:"insecure,omitempty"` - SMIMECert string `json:"smime_cert,omitempty"` - SMIMEKey string `json:"smime_key,omitempty"` - SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"` - AuthMethod string `json:"auth_method,omitempty"` - Protocol string `json:"protocol,omitempty"` - JMAPEndpoint string `json:"jmap_endpoint,omitempty"` - POP3Server string `json:"pop3_server,omitempty"` - POP3Port int `json:"pop3_port,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password,omitempty"` + ServiceProvider string `json:"service_provider"` + FetchEmail string `json:"fetch_email,omitempty"` + IMAPServer string `json:"imap_server,omitempty"` + IMAPPort int `json:"imap_port,omitempty"` + SMTPServer string `json:"smtp_server,omitempty"` + SMTPPort int `json:"smtp_port,omitempty"` + Insecure bool `json:"insecure,omitempty"` + SMIMECert string `json:"smime_cert,omitempty"` + SMIMEKey string `json:"smime_key,omitempty"` + SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"` + PGPPublicKey string `json:"pgp_public_key,omitempty"` + PGPPrivateKey string `json:"pgp_private_key,omitempty"` + PGPKeySource string `json:"pgp_key_source,omitempty"` + PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"` + PGPEncryptByDefault bool `json:"pgp_encrypt_by_default,omitempty"` + AuthMethod string `json:"auth_method,omitempty"` + Protocol string `json:"protocol,omitempty"` + JMAPEndpoint string `json:"jmap_endpoint,omitempty"` + POP3Server string `json:"pop3_server,omitempty"` + POP3Port int `json:"pop3_port,omitempty"` } type diskConfig struct { Accounts []rawAccount `json:"accounts"` @@ -259,24 +276,29 @@ func LoadConfig() (*Config, error) { config.MailingLists = raw.MailingLists for _, rawAcc := range raw.Accounts { acc := Account{ - ID: rawAcc.ID, - Name: rawAcc.Name, - Email: rawAcc.Email, - ServiceProvider: rawAcc.ServiceProvider, - FetchEmail: rawAcc.FetchEmail, - IMAPServer: rawAcc.IMAPServer, - IMAPPort: rawAcc.IMAPPort, - SMTPServer: rawAcc.SMTPServer, - SMTPPort: rawAcc.SMTPPort, - Insecure: rawAcc.Insecure, - SMIMECert: rawAcc.SMIMECert, - SMIMEKey: rawAcc.SMIMEKey, - SMIMESignByDefault: rawAcc.SMIMESignByDefault, - AuthMethod: rawAcc.AuthMethod, - Protocol: rawAcc.Protocol, - JMAPEndpoint: rawAcc.JMAPEndpoint, - POP3Server: rawAcc.POP3Server, - POP3Port: rawAcc.POP3Port, + ID: rawAcc.ID, + Name: rawAcc.Name, + Email: rawAcc.Email, + ServiceProvider: rawAcc.ServiceProvider, + FetchEmail: rawAcc.FetchEmail, + IMAPServer: rawAcc.IMAPServer, + IMAPPort: rawAcc.IMAPPort, + SMTPServer: rawAcc.SMTPServer, + SMTPPort: rawAcc.SMTPPort, + Insecure: rawAcc.Insecure, + SMIMECert: rawAcc.SMIMECert, + SMIMEKey: rawAcc.SMIMEKey, + SMIMESignByDefault: rawAcc.SMIMESignByDefault, + PGPPublicKey: rawAcc.PGPPublicKey, + PGPPrivateKey: rawAcc.PGPPrivateKey, + PGPKeySource: rawAcc.PGPKeySource, + PGPSignByDefault: rawAcc.PGPSignByDefault, + PGPEncryptByDefault: rawAcc.PGPEncryptByDefault, + AuthMethod: rawAcc.AuthMethod, + Protocol: rawAcc.Protocol, + JMAPEndpoint: rawAcc.JMAPEndpoint, + POP3Server: rawAcc.POP3Server, + POP3Port: rawAcc.POP3Port, } if rawAcc.Password != "" { @@ -291,6 +313,13 @@ func LoadConfig() (*Config, error) { } } + // Load YubiKey PIN from keyring if using YubiKey + if acc.PGPKeySource == "yubikey" { + if pin, err := keyring.Get(keyringServiceName, acc.Email+":pgp-pin"); err == nil { + acc.PGPPIN = pin + } + } + config.Accounts = append(config.Accounts, acc) } @@ -329,6 +358,8 @@ func (c *Config) RemoveAccount(id string) bool { if acc.ID == id { // Delete password from OS Keyring when account is removed _ = keyring.Delete(keyringServiceName, acc.Email) + // Delete PGP PIN from OS Keyring if present + _ = keyring.Delete(keyringServiceName, acc.Email+":pgp-pin") c.Accounts = append(c.Accounts[:i], c.Accounts[i+1:]...) return true @@ -369,3 +400,13 @@ func (c *Config) GetFirstAccount() *Account { } return nil } + +// EnsurePGPDir creates the PGP keys directory if it doesn't exist. +func EnsurePGPDir() error { + dir, err := configDir() + if err != nil { + return err + } + pgpDir := filepath.Join(dir, "pgp") + return os.MkdirAll(pgpDir, 0700) +} diff --git a/docs/docs/Features/PGP.md b/docs/docs/Features/PGP.md new file mode 100644 index 0000000..095f4ab --- /dev/null +++ b/docs/docs/Features/PGP.md @@ -0,0 +1,322 @@ +# PGP Email Security + +Matcha supports PGP (Pretty Good Privacy) for signing and encrypting your emails. PGP is widely used in open-source communities, journalism, and privacy-focused environments. + +## Features + +- **Digital Signing**: Cryptographically sign outgoing emails so recipients can verify they came from you. +- **Encryption**: Encrypt emails so only the intended recipients can read them. +- **Signature Verification**: Automatically verify PGP signatures on incoming emails. +- **Encrypted Email Decryption**: Decrypt incoming PGP-encrypted emails using your private key. +- **Per-Account Configuration**: Configure separate keys for each email account. +- **Sign by Default**: Optionally enable automatic signing for all outgoing emails. +- **Encrypt by Default**: Optionally encrypt all outgoing emails when recipient keys are available. +- **YubiKey Support**: Sign emails using a YubiKey or other OpenPGP smartcard. + +## Setting Up PGP (File-Based Keys) + +### 1. Generate a PGP Key Pair + +If you don't already have a PGP key, generate one with GnuPG: + +```bash +gpg --full-generate-key +``` + +Follow the prompts to select: +- Key type: **RSA and RSA** (default) +- Key size: **4096** bits (recommended) +- Expiration: your preference +- Name and email address + +### 2. Export Your Keys + +Export your public and private keys to files: + +```bash +# Create a directory for your keys +mkdir -p ~/.config/matcha/pgp +chmod 700 ~/.config/matcha/pgp + +# Export your public key +gpg --export --armor your@email.com > ~/.config/matcha/pgp/public.asc + +# Export your private key +gpg --export-secret-keys --armor your@email.com > ~/.config/matcha/pgp/private.asc + +# Protect the private key +chmod 600 ~/.config/matcha/pgp/private.asc +``` + +### 3. Configure in Matcha + +Open **Settings**, select an account, and configure the PGP section: + +| Field | Description | +|-------|-------------| +| **Public Key Path** | Path to your public key file (e.g. `~/.config/matcha/pgp/public.asc`) | +| **Private Key Path** | Path to your private key file (e.g. `~/.config/matcha/pgp/private.asc`) | +| **Key Source** | `File` for file-based keys, `YubiKey` for hardware keys | +| **Sign by Default** | Toggle to automatically sign all outgoing emails | +| **Encrypt by Default** | Toggle to encrypt when recipient keys are available | + +Your configuration is stored per-account in `~/.config/matcha/config.json`: + +```json +{ + "accounts": [ + { + "email": "you@example.com", + "pgp_public_key": "/home/you/.config/matcha/pgp/public.asc", + "pgp_private_key": "/home/you/.config/matcha/pgp/private.asc", + "pgp_sign_by_default": true, + "pgp_encrypt_by_default": false + } + ] +} +``` + +### 4. Sending Signed Emails + +When **Sign by Default** is enabled, all outgoing emails are automatically signed with your PGP key. Recipients with PGP-capable email clients will see a verification indicator. + +### 5. Sending Encrypted Emails + +To encrypt an email, toggle the **Encrypt Email (PGP)** checkbox in the composer. For encryption to work, you need the recipient's public key stored in: + +``` +~/.config/matcha/pgp/.asc +``` + +For example, to encrypt an email to `alice@example.com`, place her public key at: + +``` +~/.config/matcha/pgp/alice@example.com.asc +``` + +You can obtain someone's public key from: +- A keyserver: `gpg --recv-keys && gpg --export --armor alice@example.com > ~/.config/matcha/pgp/alice@example.com.asc` +- Their website or email signature +- Direct exchange + +Matcha automatically includes your own public key when encrypting, so you can still read the email in your Sent folder. + +### Supported Key Formats + +Matcha supports both common OpenPGP key formats: + +| Format | Extension | Description | +|--------|-----------|-------------| +| ASCII-armored | `.asc` | Text-based format, starts with `-----BEGIN PGP PUBLIC KEY BLOCK-----` | +| Binary | `.gpg` | Compact binary format | + +## Setting Up PGP with YubiKey + +Matcha supports signing emails directly on a YubiKey or other OpenPGP-compatible smartcard. The private key never leaves the hardware device. + +### Prerequisites + +1. **A YubiKey with an OpenPGP key**: Your YubiKey must have a signing key loaded. You can either generate a key on-device or import an existing one using `gpg --edit-key`. + +2. **PC/SC daemon**: The `pcscd` service must be running. This is the middleware that communicates with smartcards. + +3. **CCID driver**: Required for USB smartcard communication. + +#### Install on Arch Linux + +```bash +sudo pacman -S pcsclite ccid +sudo systemctl enable --now pcscd.socket +``` + +#### Install on Debian/Ubuntu + +```bash +sudo apt install pcscd libccid +sudo systemctl enable --now pcscd.socket +``` + +#### Install on Fedora + +```bash +sudo dnf install pcsc-lite ccid +sudo systemctl enable --now pcscd.socket +``` + +#### Install on macOS + +PC/SC is built into macOS. No additional installation needed. + +### Configure in Matcha + +1. Open **Settings** and select your account. +2. In the PGP section, set **Key Source** to `YubiKey`. +3. Enter your YubiKey PIN (stored securely in your OS keyring, never written to disk). +4. Enable **Sign by Default** if desired. + +Your configuration: + +```json +{ + "accounts": [ + { + "email": "you@example.com", + "pgp_key_source": "yubikey", + "pgp_sign_by_default": true + } + ] +} +``` + +Note: The YubiKey PIN is stored in your OS keyring (e.g. GNOME Keyring, KDE Wallet, macOS Keychain) and is never saved to `config.json`. + +### Moving Your GPG Key to a YubiKey + +If you have an existing GPG key and want to move it to your YubiKey: + +```bash +# Find your key ID +gpg --list-secret-keys --keyid-format long + +# Edit the key +gpg --edit-key + +# In the GPG prompt, move the signing subkey to the card +gpg> keytocard + +# Select slot 1 (Signature key) +# Save and quit +gpg> save +``` + +### Generating a Key Directly on YubiKey + +```bash +# Edit the card +gpg --card-edit + +# Enter admin mode +gpg/card> admin + +# Generate keys on-device (private key never leaves the YubiKey) +gpg/card> generate +``` + +## Status Indicators + +When viewing an email, Matcha shows the PGP status in the header: + +| Badge | Meaning | +|-------|---------| +| `[PGP: Verified]` | The PGP signature was verified successfully | +| `[PGP: Unverified]` | A PGP signature is present but could not be verified | +| `[PGP: Encrypted]` | The email was PGP-encrypted and decrypted successfully | + +## PGP vs S/MIME + +Matcha supports both PGP and S/MIME. They are mutually exclusive per message: you cannot sign the same email with both. Choose based on your needs: + +| | PGP | S/MIME | +|---|-----|--------| +| **Key management** | Manual (keyservers, direct exchange) | Certificate Authorities | +| **Trust model** | Web of Trust / TOFU | Hierarchical (CA-based) | +| **Popular with** | Open-source, privacy communities | Enterprise, corporate | +| **Hardware keys** | YubiKey, smartcards | Smart cards, USB tokens | +| **Setup effort** | Lower (self-managed keys) | Higher (need CA-issued certificate) | + +## Troubleshooting + +### "Failed to connect to PC/SC daemon" + +The `pcscd` service is not running. + +```bash +# Start pcscd +sudo systemctl enable --now pcscd.socket + +# Verify it's running +systemctl status pcscd.socket +``` + +### "No OpenPGP smartcard found" + +The YubiKey is not detected. Check: + +1. **Is the YubiKey plugged in?** + ```bash + lsusb | grep -i yubi + ``` + +2. **Is the CCID driver installed?** + ```bash + # Arch Linux + sudo pacman -S ccid + + # Debian/Ubuntu + sudo apt install libccid + ``` + +3. **Can pcscd see the card readers?** + ```bash + systemctl status pcscd + ``` + Look for `LIBUSB_ERROR_ACCESS` or `LIBUSB_ERROR_BUSY` in the logs. + +### "LIBUSB_ERROR_ACCESS" in pcscd logs + +The `pcscd` user doesn't have permission to access the USB device. Add a udev rule: + +```bash +# Create udev rule (adjust idProduct if needed) +echo 'ACTION=="add", SUBSYSTEM=="usb", ATTR{idVendor}=="1050", MODE="0666"' | \ + sudo tee /etc/udev/rules.d/70-yubikey.rules + +# Reload rules and replug YubiKey +sudo udevadm control --reload-rules +sudo udevadm trigger +``` + +Then restart pcscd: + +```bash +sudo systemctl restart pcscd.socket pcscd.service +``` + +### "LIBUSB_ERROR_BUSY" in pcscd logs + +Another process (usually GnuPG's `scdaemon`) has an exclusive lock on the YubiKey. Configure `scdaemon` to share access through `pcscd`: + +```bash +# Add to ~/.gnupg/scdaemon.conf +echo -e "disable-ccid\npcsc-shared" >> ~/.gnupg/scdaemon.conf + +# Restart scdaemon +gpgconf --kill scdaemon +``` + +This tells `scdaemon` to use `pcscd` as its backend instead of grabbing the USB device directly, allowing both GnuPG and Matcha to share the YubiKey. + +### "PIN verification failed" + +- The default YubiKey PIN is `123456` (change it with `gpg --card-edit` then `passwd`). +- After 3 wrong PIN attempts, the PIN is locked. Reset it with the Admin PIN (default `12345678`) using `gpg --card-edit` then `admin` then `passwd`. + +### "No PGP keys found in keyring" + +Your exported key file may be empty or corrupted. Verify it: + +```bash +# Check the public key +gpg --show-keys ~/.config/matcha/pgp/public.asc + +# Check the private key +gpg --show-keys ~/.config/matcha/pgp/private.asc +``` + +### Signature shows as "Unverified" + +This happens when the sender's public key is not available to verify the signature. To verify signatures from a contact, store their public key at: + +``` +~/.config/matcha/pgp/.asc +``` diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index 97304be..4336df3 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -17,9 +17,11 @@ import ( "strings" "time" + "github.com/ProtonMail/go-crypto/openpgp" "github.com/emersion/go-imap" "github.com/emersion/go-imap/client" "github.com/emersion/go-message/mail" + "github.com/emersion/go-pgpmail" "github.com/floatpane/matcha/config" "go.mozilla.org/pkcs7" "golang.org/x/text/encoding/ianaindex" @@ -38,6 +40,9 @@ type Attachment struct { IsSMIMESignature bool // True if this attachment is an S/MIME signature SMIMEVerified bool // True if the S/MIME signature was verified successfully IsSMIMEEncrypted bool // True if the S/MIME content was successfully decrypted + IsPGPSignature bool // True if this attachment is a PGP signature + PGPVerified bool // True if the PGP signature was verified successfully + IsPGPEncrypted bool // True if the PGP content was successfully decrypted } type Email struct { @@ -729,6 +734,122 @@ func FetchEmailBodyFromMailbox(account *config.Account, mailbox string, uid uint } } attachments = append(attachments, att) + } + + // === PGP ENCRYPTED MESSAGE DETECTION === + if mimeType == "application/pgp-encrypted" || (mimeType == "multipart/encrypted" && strings.Contains(part.MIMESubType, "pgp")) { + // PGP encrypted messages typically have two parts: + // 1. Version info (application/pgp-encrypted) + // 2. Encrypted data (application/octet-stream) + // We'll handle decryption when we find the encrypted data part + // Skip this part and continue processing + } + + // Detect encrypted data part of PGP message + if strings.Contains(filename, ".asc") || (mimeType == "application/octet-stream" && part.Encoding == "7bit") { + // This might be PGP encrypted data + data, err := fetchInlinePart(partID, part.Encoding) + if err == nil && bytes.Contains(data, []byte("-----BEGIN PGP MESSAGE-----")) { + // This is PGP encrypted content + if account.PGPPrivateKey != "" { + decrypted, err := decryptPGPMessage(data, account) + if err == nil { + // Parse the decrypted MIME content + mr, err := mail.CreateReader(bytes.NewReader(decrypted)) + if err == nil { + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + break + } + + switch h := p.Header.(type) { + case *mail.InlineHeader: + ct, _, _ := h.ContentType() + if strings.HasPrefix(ct, "text/html") { + body, _ := io.ReadAll(p.Body) + extractedBody = string(body) + htmlPartID = "decrypted" + } else if strings.HasPrefix(ct, "text/plain") && extractedBody == "" { + body, _ := io.ReadAll(p.Body) + extractedBody = string(body) + htmlPartID = "decrypted" + } + } + } + + // Add status marker + attachments = append(attachments, Attachment{ + Filename: "pgp-status.internal", + IsPGPEncrypted: true, + PGPVerified: true, // Decryption succeeded + }) + } + } else { + extractedBody = fmt.Sprintf("**PGP Decryption Failed:** %s\n", err) + htmlPartID = "extracted" + } + } else { + extractedBody = "**PGP Encrypted:** Private key not configured\n" + htmlPartID = "extracted" + } + } + } + + // === PGP DETACHED SIGNATURE VERIFICATION === + if filename == "signature.asc" || mimeType == "application/pgp-signature" { + att := Attachment{ + Filename: filename, + PartID: partID, + Encoding: part.Encoding, + MIMEType: mimeType, + ContentID: contentID, + Inline: isInline, + IsPGPSignature: true, + } + + if data, err := fetchInlinePart(partID, part.Encoding); err == nil { + att.Data = data + + // Try to verify the signature + boundary := msg.BodyStructure.Params["boundary"] + if boundary != "" { + rawEmail, err := fetchWholeMessage() + if err == nil { + // Extract signed content (similar to S/MIME) + fullBoundary := []byte("--" + boundary) + firstIdx := bytes.Index(rawEmail, fullBoundary) + if firstIdx != -1 { + startIdx := firstIdx + len(fullBoundary) + if startIdx < len(rawEmail) && rawEmail[startIdx] == '\r' { + startIdx++ + } + if startIdx < len(rawEmail) && rawEmail[startIdx] == '\n' { + startIdx++ + } + secondIdx := bytes.Index(rawEmail[startIdx:], fullBoundary) + if secondIdx != -1 { + endIdx := startIdx + secondIdx + if endIdx > 0 && rawEmail[endIdx-1] == '\n' { + endIdx-- + } + if endIdx > 0 && rawEmail[endIdx-1] == '\r' { + endIdx-- + } + signedData := rawEmail[startIdx:endIdx] + + // Verify PGP signature + verified := verifyPGPSignature(signedData, data, account) + att.PGPVerified = verified + } + } + } + } + } + attachments = append(attachments, att) } else if (filename != "" || isCID) && (part.Disposition == "attachment" || isInline || part.MIMEType != "text") { att := Attachment{ Filename: filename, @@ -1336,3 +1457,127 @@ func DeleteFolderEmail(account *config.Account, folder string, uid uint32) error func ArchiveFolderEmail(account *config.Account, folder string, uid uint32) error { return ArchiveEmailFromMailbox(account, folder, uid) } + +// decryptPGPMessage decrypts a PGP-encrypted message using the account's private key. +func decryptPGPMessage(encryptedData []byte, account *config.Account) ([]byte, error) { + if account.PGPPrivateKey == "" { + return nil, errors.New("PGP private key not configured") + } + + // Load private key + keyFile, err := os.ReadFile(account.PGPPrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to read PGP private key: %w", err) + } + + // Try armored format first + entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyFile)) + if err != nil { + // Try binary format + entityList, err = openpgp.ReadKeyRing(bytes.NewReader(keyFile)) + if err != nil { + return nil, fmt.Errorf("failed to parse PGP private key: %w", err) + } + } + + if len(entityList) == 0 { + return nil, errors.New("no PGP keys found in private keyring") + } + + // Decrypt using go-pgpmail + mr, err := pgpmail.Read(bytes.NewReader(encryptedData), openpgp.EntityList{entityList[0]}, nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to decrypt PGP message: %w", err) + } + + // Read decrypted content from UnverifiedBody + if mr.MessageDetails == nil || mr.MessageDetails.UnverifiedBody == nil { + return nil, errors.New("no decrypted content available") + } + + var decrypted bytes.Buffer + if _, err := io.Copy(&decrypted, mr.MessageDetails.UnverifiedBody); err != nil { + return nil, fmt.Errorf("failed to read decrypted content: %w", err) + } + + return decrypted.Bytes(), nil +} + +// loadPGPKeyring builds an openpgp.EntityList from the account's public key +// and any keys stored in the pgp/ config directory. +func loadPGPKeyring(account *config.Account) openpgp.EntityList { + var keyring openpgp.EntityList + + readKeys := func(path string) { + data, err := os.ReadFile(path) + if err != nil { + return + } + entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(data)) + if err != nil { + entities, err = openpgp.ReadKeyRing(bytes.NewReader(data)) + if err != nil { + return + } + } + keyring = append(keyring, entities...) + } + + // Load account's own public key + if account.PGPPublicKey != "" { + readKeys(account.PGPPublicKey) + } + + // Load all keys from the pgp/ config directory + cfgDir, err := config.GetConfigDir() + if err == nil { + pgpDir := cfgDir + "/pgp" + entries, err := os.ReadDir(pgpDir) + if err == nil { + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if strings.HasSuffix(name, ".asc") || strings.HasSuffix(name, ".gpg") { + readKeys(pgpDir + "/" + name) + } + } + } + } + + return keyring +} + +// verifyPGPSignature verifies a PGP detached signature against signed content. +func verifyPGPSignature(signedContent, signatureData []byte, account *config.Account) bool { + keyring := loadPGPKeyring(account) + if len(keyring) == 0 { + return false + } + + // Build a complete multipart/signed message for go-pgpmail + boundary := "pgp-verify-boundary" + var msg bytes.Buffer + msg.WriteString("Content-Type: multipart/signed; boundary=\"" + boundary + "\"; micalg=pgp-sha256; protocol=\"application/pgp-signature\"\r\n\r\n") + msg.WriteString("--" + boundary + "\r\n") + msg.Write(signedContent) + msg.WriteString("\r\n--" + boundary + "\r\n") + msg.WriteString("Content-Type: application/pgp-signature\r\n\r\n") + msg.Write(signatureData) + msg.WriteString("\r\n--" + boundary + "--\r\n") + + mr, err := pgpmail.Read(&msg, keyring, nil, nil) + if err != nil { + return false + } + + if mr.MessageDetails == nil { + return false + } + + // Must read UnverifiedBody to EOF to trigger signature verification + _, _ = io.ReadAll(mr.MessageDetails.UnverifiedBody) + + return mr.MessageDetails.SignatureError == nil +} diff --git a/go.mod b/go.mod index e346162..123e216 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,10 @@ require ( ) require ( + cunicu.li/go-iso7816 v0.8.8 // indirect + cunicu.li/go-openpgp-card v0.3.11 // indirect + git.sr.ht/~rockorager/go-jmap v0.5.3 // indirect + github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c // indirect github.com/andybalholm/cascadia v1.3.3 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect @@ -32,7 +36,11 @@ require ( github.com/charmbracelet/x/windows v0.2.2 // indirect github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/cloudflare/circl v1.3.7 // indirect github.com/danieljoos/wincred v1.2.3 // indirect + github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25 // indirect + github.com/emersion/go-pgpmail v0.2.2 // indirect + github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect @@ -41,6 +49,7 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/crypto v0.49.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.4.0 // indirect golang.org/x/sync v0.20.0 // indirect diff --git a/go.sum b/go.sum index 012bb3a..3124780 100644 --- a/go.sum +++ b/go.sum @@ -4,10 +4,16 @@ charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0= charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ= charm.land/lipgloss/v2 v2.0.2 h1:xFolbF8JdpNkM2cEPTfXEcW1p6NRzOWTSamRfYEw8cs= charm.land/lipgloss/v2 v2.0.2/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +cunicu.li/go-iso7816 v0.8.8 h1:Srk2XLxWvS5GI1Hu5XJfrmW1R0mf3ghJnipo/aKJiZM= +cunicu.li/go-iso7816 v0.8.8/go.mod h1:tiWdoe9DcrVlHVRrNoQ2sn/QDbfiL7OIcKuTVzJqf0I= +cunicu.li/go-openpgp-card v0.3.11 h1:UjOon8J3kbYav9282MlbjSqxLgDHzkKQp5rq5Z0knBw= +cunicu.li/go-openpgp-card v0.3.11/go.mod h1:SHrDSmwX/wMWUugtEJgkDxDy6LuCnXHxLL/OnxlL2SY= git.sr.ht/~rockorager/go-jmap v0.5.3 h1:PjnobX0ySPHKG5TiUqLM6PlM3ngYHlLJeWLOeorZ7IY= git.sr.ht/~rockorager/go-jmap v0.5.3/go.mod h1:aOTCtwpZSINpDDSOkLGpHU0Kbbm5lcSDMcobX3ZtOjY= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c h1:kMFnB0vCcX7IL/m9Y5LO+KQYv+t1CQOiFe6+SV2J7bE= +github.com/ProtonMail/go-crypto v0.0.0-20230923063757-afb1ddc0824c/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo= github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= @@ -16,6 +22,7 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o= github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= github.com/charmbracelet/colorprofile v0.4.2 h1:BdSNuMjRbotnxHSfxy+PCSa4xAmz7szw70ktAtWRYrY= github.com/charmbracelet/colorprofile v0.4.2/go.mod h1:0rTi81QpwDElInthtrQ6Ni7cG0sDtwAd4C4le060fT8= github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA= @@ -34,15 +41,23 @@ github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSE github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0= github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= +github.com/cloudflare/circl v1.3.7 h1:qlCDlTPz2n9fu58M0Nh1J/JzcFpfgkFHHX3O35r5vcU= +github.com/cloudflare/circl v1.3.7/go.mod h1:sRTcRWXGLrKw6yIGJ+l7amYJFfAXbZG0kBSc8r4zxgA= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25 h1:vXmXuiy1tgifTqWAAaU+ESu1goRp4B3fdhemWMMrS4g= +github.com/ebfe/scard v0.0.0-20241214075232-7af069cabc25/go.mod h1:BkYEeWL6FbT4Ek+TcOBnPzEKnL7kOq2g19tTQXkorHY= github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA= github.com/emersion/go-imap v1.2.1/go.mod h1:Qlx1FSx2FTxjnjWpIlVNEuX+ylerZQNFE5NsmKFSejY= github.com/emersion/go-message v0.15.0/go.mod h1:wQUEfE+38+7EW8p8aZ96ptg6bAb1iwdgej19uXASlE4= +github.com/emersion/go-message v0.17.0/go.mod h1:/9Bazlb1jwUNB0npYYBsdJ2EMOiiyN3m5UVHbY7GoNw= github.com/emersion/go-message v0.18.2 h1:rl55SQdjd9oJcIoQNhubD2Acs1E6IzlZISRTK7x/Lpg= github.com/emersion/go-message v0.18.2/go.mod h1:XpJyL70LwRvq2a8rVbHXikPgKj8+aI0kGdHlg16ibYA= +github.com/emersion/go-pgpmail v0.2.2 h1:cO2jwsE0gb8aDdCcVH5Dfe1XV3Rhhw2GVWsmQd3CbaI= +github.com/emersion/go-pgpmail v0.2.2/go.mod h1:mRB5P7QKiAuOvcT36tdRZvm7nSt7V+f6jbzzup3HuvU= github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= @@ -91,10 +106,15 @@ go.mozilla.org/pkcs7 v0.9.0 h1:yM4/HS9dYv7ri2biPtxt8ikvB37a980dg69/pKmS+eI= go.mozilla.org/pkcs7 v0.9.0/go.mod h1:SNgMg+EgDFwmvSmLRTNKC5fegJjB7v23qTQ0XLGUNHk= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= +golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= +golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= @@ -106,7 +126,9 @@ golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= @@ -130,9 +152,13 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= @@ -141,9 +167,12 @@ golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= @@ -152,8 +181,11 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= diff --git a/main.go b/main.go index cd8ae38..050debd 100644 --- a/main.go +++ b/main.go @@ -1000,8 +1000,6 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if composer, ok := m.current.(*tui.Composer); ok { draftID = composer.GetDraftID() } - m.current = tui.NewStatus("Sending email...") - // Get the account to send from var account *config.Account if msg.AccountID != "" && m.config != nil { @@ -1011,6 +1009,12 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { account = m.config.GetFirstAccount() } + statusText := "Sending email..." + if msg.SignPGP && account != nil && account.PGPKeySource == "yubikey" { + statusText = "Touch your YubiKey to sign..." + } + m.current = tui.NewStatus(statusText) + // Save contact and delete draft in background go func() { // Save the recipient as a contact @@ -1658,7 +1662,7 @@ func sendEmail(account *config.Account, msg tui.SendEmailMsg) tea.Cmd { attachments[filename] = fileData } - err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME) + err := sender.SendEmail(account, recipients, cc, bcc, msg.Subject, body, string(htmlBody), images, attachments, msg.InReplyTo, msg.References, msg.SignSMIME, msg.EncryptSMIME, msg.SignPGP, msg.EncryptPGP) if err != nil { log.Printf("Failed to send email: %v", err) return tui.EmailResultMsg{Err: err} @@ -2514,6 +2518,9 @@ func main() { } tui.RebuildStyles() + // Ensure PGP keys directory exists + _ = config.EnsurePGPDir() + var initialModel *mainModel if err != nil { initialModel = newInitialModel(nil) diff --git a/pgp/yubikey.go b/pgp/yubikey.go new file mode 100644 index 0000000..6573724 --- /dev/null +++ b/pgp/yubikey.go @@ -0,0 +1,490 @@ +package pgp + +import ( + "bytes" + "crypto" + "encoding/binary" + "fmt" + "io" + "math/big" + "os" + "time" + + pgpcrypto "github.com/ProtonMail/go-crypto/openpgp" + "github.com/ProtonMail/go-crypto/openpgp/armor" + "github.com/ProtonMail/go-crypto/openpgp/packet" + "github.com/ebfe/scard" + + iso "cunicu.li/go-iso7816" + "cunicu.li/go-iso7816/drivers/pcsc" + "cunicu.li/go-iso7816/filter" + + openpgp "cunicu.li/go-openpgp-card" +) + +// openCard connects to the first available OpenPGP smartcard via PC/SC. +func openCard() (*openpgp.Card, error) { + ctx, err := scard.EstablishContext() + if err != nil { + return nil, fmt.Errorf( + "failed to connect to PC/SC daemon: %w\n"+ + "Make sure pcscd is running:\n"+ + " sudo systemctl enable --now pcscd.socket\n"+ + "You may also need the ccid package for USB smartcard support.", + err, + ) + } + + pcscCard, err := pcsc.OpenFirstCard(ctx, filter.HasApplet(iso.AidOpenPGP), true) + if err != nil { + ctx.Release() + return nil, fmt.Errorf( + "no OpenPGP smartcard found: %w\n"+ + "Make sure your YubiKey is plugged in and has an OpenPGP key configured.", + err, + ) + } + + isoCard := iso.NewCard(pcscCard) + card, err := openpgp.NewCard(isoCard) + if err != nil { + pcscCard.Close() + ctx.Release() + return nil, fmt.Errorf("failed to initialize OpenPGP card: %w", err) + } + + return card, nil +} + +// BuildPGPSignedMessage creates a multipart/signed MIME message using a YubiKey. +// publicKeyPath is the path to the account's PGP public key file, used to read +// key metadata (fingerprint, key ID, algorithm) for building a valid OpenPGP +// signature packet. +func BuildPGPSignedMessage(payload []byte, pin string, publicKeyPath string) ([]byte, error) { + card, err := openCard() + if err != nil { + return nil, err + } + defer card.Close() + + // Verify PIN (PW1 for signing operations) + if err := card.VerifyPassword(openpgp.PW1, pin); err != nil { + return nil, fmt.Errorf("PIN verification failed: %w", err) + } + + // Get the signing private key from the card. + privKey, err := card.PrivateKey(openpgp.KeySign, nil) + if err != nil { + return nil, fmt.Errorf("failed to get signing key from card: %w", err) + } + + signer, ok := privKey.(crypto.Signer) + if !ok { + return nil, fmt.Errorf("signing key does not implement crypto.Signer") + } + + // Load the public key entity to get metadata for the signature packet + signingKey, err := loadSigningPublicKey(publicKeyPath) + if err != nil { + return nil, fmt.Errorf("failed to load public key: %w", err) + } + + // Split payload into headers and body for MIME structure + headers, body := splitPayload(payload) + + // Build the signed body part (this is what gets hashed) + boundary := fmt.Sprintf("----=_Part_%d", time.Now().Unix()) + signedPart := buildSignedPart(headers, body, boundary) + + // Build the OpenPGP signature packet + sigPacket, err := buildSignaturePacket(signedPart, signer, signingKey) + if err != nil { + return nil, fmt.Errorf("failed to build signature: %w", err) + } + + // Armor the signature + armoredSig, err := armorSignature(sigPacket) + if err != nil { + return nil, fmt.Errorf("failed to armor signature: %w", err) + } + + return buildMultipartSigned(headers, body, boundary, armoredSig), nil +} + +// loadSigningPublicKey reads a PGP public key file and returns the signing +// subkey's PublicKey (or the primary key if no signing subkey exists). +func loadSigningPublicKey(path string) (*packet.PublicKey, error) { + keyData, err := os.ReadFile(path) + if err != nil { + return nil, err + } + + entities, err := pgpcrypto.ReadArmoredKeyRing(bytes.NewReader(keyData)) + if err != nil { + entities, err = pgpcrypto.ReadKeyRing(bytes.NewReader(keyData)) + if err != nil { + return nil, fmt.Errorf("failed to parse PGP key: %w", err) + } + } + if len(entities) == 0 { + return nil, fmt.Errorf("no keys found in keyring") + } + + entity := entities[0] + + // Look for a signing subkey first + now := time.Now() + for _, subkey := range entity.Subkeys { + if subkey.Sig != nil && subkey.Sig.FlagsValid && subkey.Sig.FlagSign && !subkey.PublicKey.KeyExpired(subkey.Sig, now) { + return subkey.PublicKey, nil + } + } + + // Fall back to primary key + return entity.PrimaryKey, nil +} + +// buildSignaturePacket creates a valid OpenPGP v4 signature packet. +func buildSignaturePacket(signedContent []byte, signer crypto.Signer, pubKey *packet.PublicKey) ([]byte, error) { + now := time.Now() + hashAlgo := crypto.SHA256 + hashAlgoID := byte(8) // SHA-256 in OpenPGP + + // Build hashed subpackets + var hashedSubpackets bytes.Buffer + + // Subpacket: signature creation time (type 2) + writeSubpacket(&hashedSubpackets, 2, func(buf *bytes.Buffer) { + ts := make([]byte, 4) + binary.BigEndian.PutUint32(ts, uint32(now.Unix())) + buf.Write(ts) + }) + + // Subpacket: issuer key ID (type 16) + writeSubpacket(&hashedSubpackets, 16, func(buf *bytes.Buffer) { + kid := make([]byte, 8) + binary.BigEndian.PutUint64(kid, pubKey.KeyId) + buf.Write(kid) + }) + + // Subpacket: issuer fingerprint (type 33) + writeSubpacket(&hashedSubpackets, 33, func(buf *bytes.Buffer) { + buf.WriteByte(byte(pubKey.Version)) + buf.Write(pubKey.Fingerprint) + }) + + // Build hash suffix (RFC 4880, Section 5.2.4) + var hashSuffix bytes.Buffer + hashSuffix.WriteByte(4) // version + hashSuffix.WriteByte(0x00) // signature type: binary + hashSuffix.WriteByte(byte(pubKey.PubKeyAlgo)) // public key algorithm + hashSuffix.WriteByte(hashAlgoID) // hash algorithm + hsLen := hashedSubpackets.Len() + hashSuffix.WriteByte(byte(hsLen >> 8)) + hashSuffix.WriteByte(byte(hsLen)) + hashSuffix.Write(hashedSubpackets.Bytes()) + + // V4 hash trailer + trailer := hashSuffix.Bytes() + var hashTrailer bytes.Buffer + hashTrailer.WriteByte(4) // version + hashTrailer.WriteByte(0xff) // marker + tLen := make([]byte, 4) + binary.BigEndian.PutUint32(tLen, uint32(len(trailer))) + hashTrailer.Write(tLen) + + // Hash the signed content + hash suffix + trailer + hasher := hashAlgo.New() + hasher.Write(signedContent) + hasher.Write(trailer) + hasher.Write(hashTrailer.Bytes()) + digest := hasher.Sum(nil) + + // Sign with the YubiKey + rawSig, err := signer.Sign(nil, digest, hashAlgo) + if err != nil { + return nil, fmt.Errorf("signing failed: %w", err) + } + + // Build the complete signature packet body + var body bytes.Buffer + body.Write(trailer) // version + sig type + algo + hash algo + hashed subpackets + + // Unhashed subpackets (empty) + body.WriteByte(0) + body.WriteByte(0) + + // Hash tag (first 2 bytes of digest) + body.WriteByte(digest[0]) + body.WriteByte(digest[1]) + + // Encode the signature MPIs based on algorithm + switch pubKey.PubKeyAlgo { + case packet.PubKeyAlgoEdDSA: + // EdDSA: raw signature is r || s, 32 bytes each + if len(rawSig) != 64 { + return nil, fmt.Errorf("unexpected EdDSA signature length: %d", len(rawSig)) + } + writeMPI(&body, rawSig[:32]) // r + writeMPI(&body, rawSig[32:]) // s + + case packet.PubKeyAlgoRSA, packet.PubKeyAlgoRSASignOnly: + // RSA: single MPI + writeMPI(&body, rawSig) + + case packet.PubKeyAlgoECDSA: + // ECDSA: card returns ASN.1 DER encoded (R, S) + r, s, err := parseASN1Signature(rawSig) + if err != nil { + return nil, fmt.Errorf("failed to parse ECDSA signature: %w", err) + } + writeMPI(&body, r) + writeMPI(&body, s) + + default: + return nil, fmt.Errorf("unsupported key algorithm: %d", pubKey.PubKeyAlgo) + } + + // Wrap in an OpenPGP packet (new-format header) + var pkt bytes.Buffer + bodyBytes := body.Bytes() + pkt.WriteByte(0xC2) // new-format packet tag for signature (type 2) + writeNewFormatLength(&pkt, len(bodyBytes)) + pkt.Write(bodyBytes) + + return pkt.Bytes(), nil +} + +// armorSignature wraps a binary OpenPGP signature in ASCII armor. +func armorSignature(sigPacket []byte) ([]byte, error) { + var buf bytes.Buffer + w, err := armor.Encode(&buf, "PGP SIGNATURE", nil) + if err != nil { + return nil, err + } + if _, err := w.Write(sigPacket); err != nil { + return nil, err + } + if err := w.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +// splitPayload splits a MIME message into headers and body. +func splitPayload(payload []byte) (headers, body []byte) { + if idx := bytes.Index(payload, []byte("\r\n\r\n")); idx >= 0 { + return payload[:idx], payload[idx+4:] + } + return nil, payload +} + +// buildSignedPart constructs the first MIME part content that gets hashed. +// This must exactly match what appears between the boundary markers. +func buildSignedPart(headers, body []byte, boundary string) []byte { + var originalContentType []byte + if len(headers) > 0 { + for _, line := range bytes.Split(headers, []byte("\r\n")) { + upper := bytes.ToUpper(line) + if bytes.HasPrefix(upper, []byte("CONTENT-TYPE:")) { + originalContentType = line + break + } + } + } + + var part bytes.Buffer + if len(originalContentType) > 0 { + part.Write(originalContentType) + part.WriteString("\r\n\r\n") + } + part.Write(body) + return part.Bytes() +} + +// buildMultipartSigned assembles the complete multipart/signed MIME message. +func buildMultipartSigned(headers, body []byte, boundary string, armoredSig []byte) []byte { + var result bytes.Buffer + + // Write transport headers (From, To, Subject, etc.) excluding Content-Type and MIME-Version + var originalContentType []byte + if len(headers) > 0 { + for _, line := range bytes.Split(headers, []byte("\r\n")) { + upper := bytes.ToUpper(line) + if bytes.HasPrefix(upper, []byte("CONTENT-TYPE:")) { + originalContentType = line + continue + } + if bytes.HasPrefix(upper, []byte("MIME-VERSION:")) { + continue + } + if len(line) > 0 { + result.Write(line) + result.WriteString("\r\n") + } + } + } + + // Write the new top-level Content-Type for multipart/signed + result.WriteString("MIME-Version: 1.0\r\n") + result.WriteString("Content-Type: multipart/signed; ") + result.WriteString("boundary=\"" + boundary + "\"; ") + result.WriteString("micalg=pgp-sha256; ") + result.WriteString("protocol=\"application/pgp-signature\"\r\n") + result.WriteString("\r\n") + + // Write first part (original body with its original Content-Type) + result.WriteString("--" + boundary + "\r\n") + if len(originalContentType) > 0 { + result.Write(originalContentType) + result.WriteString("\r\n\r\n") + } + result.Write(body) + result.WriteString("\r\n") + + // Write second part (signature) + result.WriteString("--" + boundary + "\r\n") + result.WriteString("Content-Type: application/pgp-signature; name=\"signature.asc\"\r\n") + result.WriteString("Content-Description: OpenPGP digital signature\r\n") + result.WriteString("Content-Disposition: attachment; filename=\"signature.asc\"\r\n\r\n") + result.Write(armoredSig) + result.WriteString("\r\n") + result.WriteString("--" + boundary + "--\r\n") + + return result.Bytes() +} + +// writeSubpacket writes a single OpenPGP subpacket. +func writeSubpacket(w *bytes.Buffer, typ byte, writeContent func(*bytes.Buffer)) { + var content bytes.Buffer + writeContent(&content) + length := content.Len() + 1 // +1 for type byte + if length < 192 { + w.WriteByte(byte(length)) + } else { + // Two-octet length + length -= 192 + w.WriteByte(byte(length>>8) + 192) + w.WriteByte(byte(length)) + } + w.WriteByte(typ) + w.Write(content.Bytes()) +} + +// writeMPI writes a big-endian integer as an OpenPGP MPI (2-byte bit count + data). +func writeMPI(w io.Writer, data []byte) { + // Strip leading zero bytes + for len(data) > 0 && data[0] == 0 { + data = data[1:] + } + if len(data) == 0 { + data = []byte{0} + } + bitLen := uint16((len(data)-1)*8 + bitLength(data[0])) + buf := make([]byte, 2) + binary.BigEndian.PutUint16(buf, bitLen) + w.Write(buf) //nolint:errcheck + w.Write(data) //nolint:errcheck +} + +// bitLength returns the number of significant bits in a byte. +func bitLength(b byte) int { + n := 0 + for b > 0 { + n++ + b >>= 1 + } + return n +} + +// writeNewFormatLength writes an OpenPGP new-format packet body length. +func writeNewFormatLength(w *bytes.Buffer, length int) { + if length < 192 { + w.WriteByte(byte(length)) + } else if length < 8384 { + length -= 192 + w.WriteByte(byte(length>>8) + 192) + w.WriteByte(byte(length)) + } else { + w.WriteByte(255) + buf := make([]byte, 4) + binary.BigEndian.PutUint32(buf, uint32(length)) + w.Write(buf) + } +} + +// parseASN1Signature extracts r and s from an ASN.1 DER encoded ECDSA signature. +func parseASN1Signature(der []byte) (r, s []byte, err error) { + // ASN.1 SEQUENCE { INTEGER r, INTEGER s } + if len(der) < 6 || der[0] != 0x30 { + return nil, nil, fmt.Errorf("invalid ASN.1 signature") + } + + pos := 2 // skip SEQUENCE tag and length + + // Parse R + if der[pos] != 0x02 { + return nil, nil, fmt.Errorf("expected INTEGER tag for R") + } + pos++ + rLen := int(der[pos]) + pos++ + rVal := new(big.Int).SetBytes(der[pos : pos+rLen]) + pos += rLen + + // Parse S + if der[pos] != 0x02 { + return nil, nil, fmt.Errorf("expected INTEGER tag for S") + } + pos++ + sLen := int(der[pos]) + pos++ + sVal := new(big.Int).SetBytes(der[pos : pos+sLen]) + + return rVal.Bytes(), sVal.Bytes(), nil +} + +// VerifyYubiKeyAvailable checks if a YubiKey with OpenPGP support is connected. +func VerifyYubiKeyAvailable() error { + card, err := openCard() + if err != nil { + return err + } + card.Close() + return nil +} + +// GetYubiKeyInfo returns human-readable information about the connected card. +func GetYubiKeyInfo() (string, error) { + card, err := openCard() + if err != nil { + return "", err + } + defer card.Close() + + var info string + + aid := card.ApplicationRelated.AID + info += fmt.Sprintf("Manufacturer: %s\n", aid.Manufacturer) + info += fmt.Sprintf("Serial: %X\n", aid.Serial) + info += fmt.Sprintf("Version: %s\n", aid.Version) + + ch, err := card.GetCardholder() + if err == nil && ch.Name != "" { + info += fmt.Sprintf("Cardholder: %s\n", ch.Name) + } + + if keys := card.ApplicationRelated.Keys; keys != nil { + if ki, ok := keys[openpgp.KeySign]; ok { + info += fmt.Sprintf("Sign Key: %s", ki.AlgAttrs) + if ki.Status == openpgp.KeyGenerated { + info += " (generated)" + } else if ki.Status == openpgp.KeyImported { + info += " (imported)" + } + info += "\n" + } + } + + return info, nil +} diff --git a/sender/sender.go b/sender/sender.go index 67ceb30..c8c199f 100644 --- a/sender/sender.go +++ b/sender/sender.go @@ -19,8 +19,12 @@ import ( "strings" "time" + "github.com/ProtonMail/go-crypto/openpgp" + messagetextproto "github.com/emersion/go-message/textproto" + "github.com/emersion/go-pgpmail" "github.com/floatpane/matcha/clib" "github.com/floatpane/matcha/config" + "github.com/floatpane/matcha/pgp" "github.com/yuin/goldmark" "github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/text" @@ -151,7 +155,7 @@ func detectPlaintextOnly(body string, images, attachments map[string][]byte) boo } // SendEmail constructs a multipart message with plain text, HTML, embedded images, and attachments. -func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME bool, encryptSMIME bool) error { +func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody, htmlBody string, images map[string][]byte, attachments map[string][]byte, inReplyTo string, references []string, signSMIME bool, encryptSMIME bool, signPGP bool, encryptPGP bool) error { smtpServer := account.GetSMTPServer() smtpPort := account.GetSMTPPort() @@ -561,6 +565,61 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody msg.WriteString(clib.WrapBase64(base64.StdEncoding.EncodeToString(encryptedDer))) } + // Handle PGP Signing (if enabled and not already signed with S/MIME) + var pgpPayload []byte + if signPGP && !signSMIME { + // Determine what to sign + var toSign []byte + if len(payloadToEncrypt) > 0 { + // We have content prepared for encryption + toSign = payloadToEncrypt + } else { + // Use what we've built so far + toSign = msg.Bytes() + } + + signed, err := signEmailPGP(toSign, account) + if err != nil { + return fmt.Errorf("PGP signing failed: %w", err) + } + + if encryptPGP { + // Will encrypt the signed message + pgpPayload = signed + } else { + // Not encrypting, so write signed message now + msg.Reset() + msg.Write(signed) + } + } + + // Handle PGP Encryption (if enabled and not already encrypted with S/MIME) + if encryptPGP && !encryptSMIME { + allRecipients := append([]string{}, to...) + allRecipients = append(allRecipients, cc...) + allRecipients = append(allRecipients, bcc...) + + var toEncrypt []byte + if len(pgpPayload) > 0 { + // Encrypt the signed message + toEncrypt = pgpPayload + } else if len(payloadToEncrypt) > 0 { + // Encrypt pre-prepared payload + toEncrypt = payloadToEncrypt + } else { + // Encrypt what we've built so far + toEncrypt = msg.Bytes() + } + + encrypted, err := encryptEmailPGP(toEncrypt, allRecipients, account) + if err != nil { + return fmt.Errorf("PGP encryption failed: %w", err) + } + + msg.Reset() + msg.Write(encrypted) + } + // Combine all recipients for the envelope allRecipients := append([]string{}, to...) allRecipients = append(allRecipients, cc...) @@ -660,3 +719,213 @@ func SendEmail(account *config.Account, to, cc, bcc []string, subject, plainBody return c.Quit() } + +// signEmailPGP signs the message payload with PGP and returns a multipart/signed message. +// Supports both file-based keys and YubiKey hardware tokens. +func signEmailPGP(payload []byte, account *config.Account) ([]byte, error) { + // Check if using YubiKey + if account.PGPKeySource == "yubikey" { + return signEmailPGPWithYubiKey(payload, account) + } + + // Default to file-based signing + if account.PGPPrivateKey == "" { + return nil, errors.New("PGP private key path is missing") + } + + // Load private key + keyFile, err := os.ReadFile(account.PGPPrivateKey) + if err != nil { + return nil, fmt.Errorf("failed to read PGP private key: %w", err) + } + + // Try to parse as armored keyring first + entityList, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyFile)) + if err != nil { + // Try binary format + entityList, err = openpgp.ReadKeyRing(bytes.NewReader(keyFile)) + if err != nil { + return nil, fmt.Errorf("failed to parse PGP key: %w", err) + } + } + + if len(entityList) == 0 { + return nil, errors.New("no PGP keys found in keyring") + } + + // Decrypt the private key if it's encrypted + entity := entityList[0] + if entity.PrivateKey != nil && entity.PrivateKey.Encrypted { + passphrase := []byte(account.PGPPIN) // reuse PIN field for passphrase + if err := entity.DecryptPrivateKeys(passphrase); err != nil { + return nil, fmt.Errorf("failed to decrypt PGP private key: %w", err) + } + } + + // Split payload into transport headers (From, To, Subject, etc.) and body. + // pgpmail.Sign needs the transport headers in its header param so they + // appear at the top level of the output, not inside the signed part. + // Content headers (Content-Type, etc.) stay with the body as the signed part. + var header messagetextproto.Header + var bodyPayload []byte + if idx := bytes.Index(payload, []byte("\r\n\r\n")); idx >= 0 { + headerBytes := payload[:idx] + rawBody := payload[idx+4:] + + var contentHeaders bytes.Buffer + for _, line := range bytes.Split(headerBytes, []byte("\r\n")) { + if len(line) == 0 { + continue + } + parts := bytes.SplitN(line, []byte(": "), 2) + if len(parts) != 2 { + continue + } + key := string(parts[0]) + val := string(parts[1]) + upper := strings.ToUpper(key) + if strings.HasPrefix(upper, "CONTENT-") || upper == "MIME-VERSION" { + // Keep content headers with the body for the signed part + contentHeaders.Write(line) + contentHeaders.WriteString("\r\n") + } else { + // Transport headers go to the top-level message + header.Set(key, val) + } + } + + // Reconstruct body payload: content headers + blank line + body + contentHeaders.WriteString("\r\n") + contentHeaders.Write(rawBody) + bodyPayload = contentHeaders.Bytes() + } else { + bodyPayload = payload + } + + // Create multipart/signed message using go-pgpmail + var signed bytes.Buffer + + mw, err := pgpmail.Sign(&signed, header, entity, nil) + if err != nil { + return nil, fmt.Errorf("failed to create PGP signer: %w", err) + } + + // Write the body (content headers + body) to be signed + if _, err := mw.Write(bodyPayload); err != nil { + return nil, fmt.Errorf("failed to write message for signing: %w", err) + } + + if err := mw.Close(); err != nil { + return nil, fmt.Errorf("failed to finalize PGP signature: %w", err) + } + + return signed.Bytes(), nil +} + +// signEmailPGPWithYubiKey signs the message payload using a YubiKey hardware token. +func signEmailPGPWithYubiKey(payload []byte, account *config.Account) ([]byte, error) { + // Get PIN from account (loaded from keyring) + pin := account.PGPPIN + if pin == "" { + return nil, fmt.Errorf("YubiKey PIN not configured - please set it in account settings") + } + + if account.PGPPublicKey == "" { + return nil, fmt.Errorf("PGP public key path is required for YubiKey signing") + } + + // Use the pgp package to sign with YubiKey + signed, err := pgp.BuildPGPSignedMessage(payload, pin, account.PGPPublicKey) + if err != nil { + return nil, fmt.Errorf("YubiKey signing failed: %w", err) + } + return signed, nil +} + +// encryptEmailPGP encrypts the message payload with PGP and returns a multipart/encrypted message. +func encryptEmailPGP(payload []byte, recipients []string, account *config.Account) ([]byte, error) { + var entityList openpgp.EntityList + + cfgDir, err := config.GetConfigDir() + if err != nil { + return nil, err + } + pgpDir := filepath.Join(cfgDir, "pgp") + + // Add recipient keys + for _, recipient := range recipients { + // Extract email address from "Name " format + email := strings.TrimSpace(recipient) + if strings.Contains(email, "<") { + parts := strings.Split(email, "<") + if len(parts) == 2 { + email = strings.TrimSuffix(parts[1], ">") + } + } + + // Try .asc (armored) first, then .gpg (binary) + var keyData []byte + keyPath := filepath.Join(pgpDir, email+".asc") + keyData, err = os.ReadFile(keyPath) + if err != nil { + keyPath = filepath.Join(pgpDir, email+".gpg") + keyData, err = os.ReadFile(keyPath) + if err != nil { + return nil, fmt.Errorf("missing PGP key for %s (tried .asc and .gpg): %w", email, err) + } + } + + // Try armored format first + entities, err := openpgp.ReadArmoredKeyRing(bytes.NewReader(keyData)) + if err != nil { + // Try binary format + entities, err = openpgp.ReadKeyRing(bytes.NewReader(keyData)) + if err != nil { + return nil, fmt.Errorf("failed to parse PGP key for %s: %w", email, err) + } + } + + if len(entities) > 0 { + entityList = append(entityList, entities[0]) + } + } + + // Add sender's own key (to read in Sent folder) + if account.PGPPublicKey != "" { + senderKey, err := os.ReadFile(account.PGPPublicKey) + if err == nil { + entities, _ := openpgp.ReadArmoredKeyRing(bytes.NewReader(senderKey)) + if entities == nil { + entities, _ = openpgp.ReadKeyRing(bytes.NewReader(senderKey)) + } + if entities != nil && len(entities) > 0 { + entityList = append(entityList, entities[0]) + } + } + } + + if len(entityList) == 0 { + return nil, errors.New("cannot encrypt: no valid PGP public keys found for recipients") + } + + // Encrypt using go-pgpmail + var encrypted bytes.Buffer + + // Create a minimal header for the encrypted content + var header messagetextproto.Header + + mw, err := pgpmail.Encrypt(&encrypted, header, entityList, nil, nil) + if err != nil { + return nil, fmt.Errorf("failed to create PGP encryptor: %w", err) + } + + if _, err := mw.Write(payload); err != nil { + return nil, fmt.Errorf("failed to write message for encryption: %w", err) + } + + if err := mw.Close(); err != nil { + return nil, fmt.Errorf("failed to finalize PGP encryption: %w", err) + } + + return encrypted.Bytes(), nil +} diff --git a/tui/composer.go b/tui/composer.go index 15d13e2..82444a0 100644 --- a/tui/composer.go +++ b/tui/composer.go @@ -43,6 +43,7 @@ const ( focusSignature focusAttachment focusEncryptSMIME + focusEncryptPGP focusSend ) @@ -57,6 +58,7 @@ type Composer struct { signatureInput textarea.Model attachmentPaths []string encryptSMIME bool + encryptPGP bool width int height int confirmingExit bool @@ -402,6 +404,11 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.encryptSMIME = !m.encryptSMIME } return m, nil + case focusEncryptPGP: + if msg.String() == "enter" || msg.String() == " " { + m.encryptPGP = !m.encryptPGP + } + return m, nil case focusSend: if msg.String() == "enter" { acc := m.getSelectedAccount() @@ -424,6 +431,8 @@ func (m *Composer) Update(msg tea.Msg) (tea.Model, tea.Cmd) { Signature: m.signatureInput.Value(), SignSMIME: acc != nil && acc.SMIMESignByDefault, EncryptSMIME: m.encryptSMIME, + SignPGP: acc != nil && acc.PGPSignByDefault, + EncryptPGP: m.encryptPGP, } } } @@ -529,6 +538,15 @@ func (m *Composer) View() tea.View { encField = focusedStyle.Render(fmt.Sprintf("> Encrypt Email (S/MIME): %s", encToggle)) } + pgpEncToggle := "[ ]" + if m.encryptPGP { + pgpEncToggle = "[x]" + } + pgpEncField := blurredStyle.Render(fmt.Sprintf(" Encrypt Email (PGP): %s", pgpEncToggle)) + if m.focusIndex == focusEncryptPGP { + pgpEncField = focusedStyle.Render(fmt.Sprintf("> Encrypt Email (PGP): %s", pgpEncToggle)) + } + // Build To field with suggestions toFieldView := m.toInput.View() if m.showSuggestions && len(m.suggestions) > 0 { @@ -575,6 +593,8 @@ func (m *Composer) View() tea.View { tip = "Enter: add file • backspace/d: remove last attachment" case focusEncryptSMIME: tip = "Press Space or Enter to toggle S/MIME encryption on or off." + case focusEncryptPGP: + tip = "Press Space or Enter to toggle PGP encryption on or off." case focusSend: tip = "Press Enter to send the email." } @@ -591,6 +611,7 @@ func (m *Composer) View() tea.View { m.signatureInput.View(), attachmentStyle.Render(attachmentField), smimeToggleStyle.Render(encField), + smimeToggleStyle.Render(pgpEncField), button, "", } diff --git a/tui/composer_test.go b/tui/composer_test.go index ba011d8..5229c72 100644 --- a/tui/composer_test.go +++ b/tui/composer_test.go @@ -71,11 +71,18 @@ func TestComposerUpdate(t *testing.T) { t.Errorf("After seven Tabs, focusIndex should be %d (focusEncryptSMIME), got %d", focusEncryptSMIME, composer.focusIndex) } + // Simulate pressing Tab again to move to the 'EncryptPGP' toggle. + model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) + composer = model.(*Composer) + if composer.focusIndex != focusEncryptPGP { + t.Errorf("After eight Tabs, focusIndex should be %d (focusEncryptPGP), got %d", focusEncryptPGP, composer.focusIndex) + } + // Simulate pressing Tab again to move to the 'Send' button. model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) composer = model.(*Composer) if composer.focusIndex != focusSend { - t.Errorf("After eight Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex) + t.Errorf("After nine Tabs, focusIndex should be %d (focusSend), got %d", focusSend, composer.focusIndex) } // Simulate one more Tab to wrap around. @@ -83,7 +90,7 @@ func TestComposerUpdate(t *testing.T) { model, _ = composer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) composer = model.(*Composer) if composer.focusIndex != focusTo { - t.Errorf("After nine Tabs, focusIndex should wrap to %d (focusTo) since single account skips From, got %d", focusTo, composer.focusIndex) + t.Errorf("After ten Tabs, focusIndex should wrap to %d (focusTo) since single account skips From, got %d", focusTo, composer.focusIndex) } }) @@ -211,7 +218,9 @@ func TestComposerUpdate(t *testing.T) { multiComposer = model.(*Composer) model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Attachment -> EncryptSMIME multiComposer = model.(*Composer) - model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // EncryptSMIME -> Send + model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // EncryptSMIME -> EncryptPGP + multiComposer = model.(*Composer) + model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // EncryptPGP -> Send multiComposer = model.(*Composer) model, _ = multiComposer.Update(tea.KeyPressMsg{Code: tea.KeyTab}) // Send -> From (wrap) multiComposer = model.(*Composer) @@ -220,7 +229,7 @@ func TestComposerUpdate(t *testing.T) { // With multiple accounts, From field should be included in tab order if multiComposer.focusIndex != focusTo { - t.Errorf("After nine Tabs with multi-account, focusIndex should wrap to %d (focusTo), got %d", focusTo, multiComposer.focusIndex) + t.Errorf("After ten Tabs with multi-account, focusIndex should wrap to %d (focusTo), got %d", focusTo, multiComposer.focusIndex) } }) } diff --git a/tui/email_view.go b/tui/email_view.go index dfbd9ea..308a9d0 100644 --- a/tui/email_view.go +++ b/tui/email_view.go @@ -39,6 +39,9 @@ type EmailView struct { isSMIME bool smimeTrusted bool isEncrypted bool + isPGP bool + pgpTrusted bool + isPGPEncrypted bool imagePlacements []view.ImagePlacement pluginStatus string } @@ -47,6 +50,9 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma isSMIME := false smimeTrusted := false isEncrypted := false + isPGP := false + pgpTrusted := false + isPGPEncrypted := false var filteredAtts []fetcher.Attachment for _, att := range email.Attachments { @@ -61,6 +67,17 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma smimeTrusted = att.SMIMEVerified } // Skip UI rendering + } else if att.Filename == "pgp-status.internal" { + isPGP = att.IsPGPSignature || att.IsPGPEncrypted + pgpTrusted = att.PGPVerified + isPGPEncrypted = att.IsPGPEncrypted + } else if att.IsPGPSignature || att.Filename == "signature.asc" || att.MIMEType == "application/pgp-signature" || att.MIMEType == "application/pgp-encrypted" { + // Extract PGP status from detached signature attachments + if att.IsPGPSignature && !isPGP { + isPGP = true + pgpTrusted = att.PGPVerified + } + // Skip UI rendering } else { filteredAtts = append(filteredAtts, att) } @@ -105,6 +122,9 @@ func NewEmailView(email fetcher.Email, emailIndex, width, height int, mailbox Ma isSMIME: isSMIME, smimeTrusted: smimeTrusted, isEncrypted: isEncrypted, + isPGP: isPGP, + pgpTrusted: pgpTrusted, + isPGPEncrypted: isPGPEncrypted, imagePlacements: placements, } } @@ -243,18 +263,27 @@ func (m *EmailView) View() tea.View { os.Stdout.WriteString("\x1b_Ga=d,d=a\x1b\\") os.Stdout.Sync() - smimeStatus := "" + cryptoStatus := "" if m.isEncrypted { - smimeStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: 🔒 Encrypted]") + cryptoStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: 🔒 Encrypted]") } else if m.isSMIME { if m.smimeTrusted { - smimeStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: ✅ Trusted]") + cryptoStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [S/MIME: ✅ Trusted]") + } else { + cryptoStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(" [S/MIME: ❌ Untrusted]") + } + } + if m.isPGPEncrypted { + cryptoStatus += lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [PGP: 🔒 Encrypted]") + } else if m.isPGP { + if m.pgpTrusted { + cryptoStatus += lipgloss.NewStyle().Foreground(theme.ActiveTheme.Accent).Render(" [PGP: ✅ Verified]") } else { - smimeStatus = lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(" [S/MIME: ❌ Untrusted]") + cryptoStatus += lipgloss.NewStyle().Foreground(theme.ActiveTheme.Danger).Render(" [PGP: ⚠️ Unverified]") } } - header := fmt.Sprintf("To: %s | From: %s | Subject: %s%s", strings.Join(m.email.To, ", "), m.email.From, m.email.Subject, smimeStatus) + header := fmt.Sprintf("To: %s | From: %s | Subject: %s%s", strings.Join(m.email.To, ", "), m.email.From, m.email.Subject, cryptoStatus) styledHeader := emailHeaderStyle.Width(m.viewport.Width()).Render(header) var help string diff --git a/tui/messages.go b/tui/messages.go index fd9ad3c..c241038 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -35,6 +35,8 @@ type SendEmailMsg struct { Signature string // Signature to append to email body SignSMIME bool // Whether to sign the email using S/MIME EncryptSMIME bool // Whether to encrypt the email using S/MIME + SignPGP bool // Whether to sign the email using PGP + EncryptPGP bool // Whether to encrypt the email using PGP } type Credentials struct { diff --git a/tui/settings.go b/tui/settings.go index de674e6..c063855 100644 --- a/tui/settings.go +++ b/tui/settings.go @@ -45,6 +45,12 @@ type Settings struct { focusIndex int smimeCertInput textinput.Model smimeKeyInput textinput.Model + + // PGP Config fields + pgpPublicKeyInput textinput.Model + pgpPrivateKeyInput textinput.Model + pgpKeySource string // "file" or "yubikey" + pgpPINInput textinput.Model } // NewSettings creates a new settings model. @@ -67,12 +73,36 @@ func NewSettings(cfg *config.Config) *Settings { keyInput.CharLimit = 256 keyInput.SetStyles(tiStyles) + pgpPubInput := textinput.New() + pgpPubInput.Placeholder = "/path/to/public_key.asc" + pgpPubInput.Prompt = "> " + pgpPubInput.CharLimit = 256 + pgpPubInput.SetStyles(tiStyles) + + pgpPrivInput := textinput.New() + pgpPrivInput.Placeholder = "/path/to/private_key.asc" + pgpPrivInput.Prompt = "> " + pgpPrivInput.CharLimit = 256 + pgpPrivInput.SetStyles(tiStyles) + + pgpPINInput := textinput.New() + pgpPINInput.Placeholder = "YubiKey PIN (6-8 digits)" + pgpPINInput.Prompt = "> " + pgpPINInput.CharLimit = 16 + pgpPINInput.EchoMode = textinput.EchoPassword + pgpPINInput.EchoCharacter = '*' + pgpPINInput.SetStyles(tiStyles) + return &Settings{ - cfg: cfg, - state: SettingsMain, - cursor: 0, - smimeCertInput: certInput, - smimeKeyInput: keyInput, + cfg: cfg, + state: SettingsMain, + cursor: 0, + smimeCertInput: certInput, + smimeKeyInput: keyInput, + pgpPublicKeyInput: pgpPubInput, + pgpPrivateKeyInput: pgpPrivInput, + pgpKeySource: "file", // Default to file-based keys + pgpPINInput: pgpPINInput, } } @@ -92,6 +122,9 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.height = msg.Height m.smimeCertInput.SetWidth(m.width - 6) m.smimeKeyInput.SetWidth(m.width - 6) + m.pgpPublicKeyInput.SetWidth(m.width - 6) + m.pgpPrivateKeyInput.SetWidth(m.width - 6) + m.pgpPINInput.SetWidth(m.width - 6) return m, nil case tea.KeyPressMsg: @@ -116,6 +149,12 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, cmd) m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg) cmds = append(cmds, cmd) + m.pgpPublicKeyInput, cmd = m.pgpPublicKeyInput.Update(msg) + cmds = append(cmds, cmd) + m.pgpPrivateKeyInput, cmd = m.pgpPrivateKeyInput.Update(msg) + cmds = append(cmds, cmd) + m.pgpPINInput, cmd = m.pgpPINInput.Update(msg) + cmds = append(cmds, cmd) } return m, tea.Batch(cmds...) @@ -239,9 +278,21 @@ func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.state = SettingsSMIMEConfig m.smimeCertInput.SetValue(m.cfg.Accounts[m.cursor].SMIMECert) m.smimeKeyInput.SetValue(m.cfg.Accounts[m.cursor].SMIMEKey) + m.pgpPublicKeyInput.SetValue(m.cfg.Accounts[m.cursor].PGPPublicKey) + m.pgpPrivateKeyInput.SetValue(m.cfg.Accounts[m.cursor].PGPPrivateKey) + // Initialize PGP key source + if m.cfg.Accounts[m.cursor].PGPKeySource == "" { + m.pgpKeySource = "file" + } else { + m.pgpKeySource = m.cfg.Accounts[m.cursor].PGPKeySource + } + m.pgpPINInput.SetValue(m.cfg.Accounts[m.cursor].PGPPIN) m.focusIndex = 0 m.smimeCertInput.Focus() m.smimeKeyInput.Blur() + m.pgpPublicKeyInput.Blur() + m.pgpPrivateKeyInput.Blur() + m.pgpPINInput.Blur() return m, textinput.Blink } case "esc": @@ -252,6 +303,20 @@ func (m *Settings) updateAccounts(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { return m, nil } +// Focus indices for the crypto config screen: +// 0: S/MIME Certificate Path +// 1: S/MIME Private Key Path +// 2: S/MIME Sign By Default toggle +// 3: PGP Public Key Path +// 4: PGP Private Key Path +// 5: PGP Key Source toggle (file/yubikey) +// 6: PGP PIN input (only shown if yubikey) +// 7: PGP Sign By Default toggle +// 8: PGP Encrypt By Default toggle +// 9: Save button +// 10: Cancel button +const cryptoConfigMaxFocus = 10 + func (m *Settings) updateSMIMEConfig(msg tea.KeyPressMsg) (*Settings, tea.Cmd) { var cmds []tea.Cmd var cmd tea.Cmd @@ -264,57 +329,128 @@ func (m *Settings) updateSMIMEConfig(msg tea.KeyPressMsg) (*Settings, tea.Cmd) { if msg.String() == "shift+tab" || msg.String() == "up" { m.focusIndex-- if m.focusIndex < 0 { - m.focusIndex = 4 + m.focusIndex = cryptoConfigMaxFocus } } else { m.focusIndex++ - if m.focusIndex > 4 { + if m.focusIndex > cryptoConfigMaxFocus { m.focusIndex = 0 } } m.smimeCertInput.Blur() m.smimeKeyInput.Blur() + m.pgpPublicKeyInput.Blur() + m.pgpPrivateKeyInput.Blur() + m.pgpPINInput.Blur() - if m.focusIndex == 0 { + switch m.focusIndex { + case 0: cmds = append(cmds, m.smimeCertInput.Focus()) - } else if m.focusIndex == 1 { + case 1: cmds = append(cmds, m.smimeKeyInput.Focus()) + case 3: + cmds = append(cmds, m.pgpPublicKeyInput.Focus()) + case 4: + cmds = append(cmds, m.pgpPrivateKeyInput.Focus()) + case 6: + cmds = append(cmds, m.pgpPINInput.Focus()) } return m, tea.Batch(cmds...) case "enter", " ": - if m.focusIndex == 0 && msg.String() == "enter" { - m.focusIndex = 1 - m.smimeCertInput.Blur() - cmds = append(cmds, m.smimeKeyInput.Focus()) - return m, tea.Batch(cmds...) - } else if m.focusIndex == 1 && msg.String() == "enter" { - m.focusIndex = 2 - m.smimeKeyInput.Blur() - return m, nil - } else if m.focusIndex == 2 { + switch m.focusIndex { + case 0: // S/MIME cert - enter advances to next field + if msg.String() == "enter" { + m.focusIndex = 1 + m.smimeCertInput.Blur() + cmds = append(cmds, m.smimeKeyInput.Focus()) + return m, tea.Batch(cmds...) + } + case 1: // S/MIME key - enter advances + if msg.String() == "enter" { + m.focusIndex = 2 + m.smimeKeyInput.Blur() + return m, nil + } + case 2: // S/MIME sign toggle if msg.String() == "enter" || msg.String() == " " { m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault = !m.cfg.Accounts[m.editingAccountIdx].SMIMESignByDefault } return m, nil - } else if m.focusIndex == 3 && msg.String() == "enter" { - m.cfg.Accounts[m.editingAccountIdx].SMIMECert = m.smimeCertInput.Value() - m.cfg.Accounts[m.editingAccountIdx].SMIMEKey = m.smimeKeyInput.Value() - _ = config.SaveConfig(m.cfg) - m.state = SettingsAccounts + case 3: // PGP public key - enter advances + if msg.String() == "enter" { + m.focusIndex = 4 + m.pgpPublicKeyInput.Blur() + cmds = append(cmds, m.pgpPrivateKeyInput.Focus()) + return m, tea.Batch(cmds...) + } + case 4: // PGP private key - enter advances + if msg.String() == "enter" { + m.focusIndex = 5 + m.pgpPrivateKeyInput.Blur() + return m, nil + } + case 5: // PGP key source toggle (file/yubikey) + if msg.String() == "enter" || msg.String() == " " { + if m.pgpKeySource == "file" { + m.pgpKeySource = "yubikey" + } else { + m.pgpKeySource = "file" + } + } return m, nil - } else if m.focusIndex == 4 && msg.String() == "enter" { - m.state = SettingsAccounts + case 6: // PGP PIN input - enter advances + if msg.String() == "enter" { + m.focusIndex = 7 + m.pgpPINInput.Blur() + return m, nil + } + case 7: // PGP sign toggle + if msg.String() == "enter" || msg.String() == " " { + m.cfg.Accounts[m.editingAccountIdx].PGPSignByDefault = !m.cfg.Accounts[m.editingAccountIdx].PGPSignByDefault + } + return m, nil + case 8: // PGP encrypt toggle + if msg.String() == "enter" || msg.String() == " " { + m.cfg.Accounts[m.editingAccountIdx].PGPEncryptByDefault = !m.cfg.Accounts[m.editingAccountIdx].PGPEncryptByDefault + } return m, nil + case 9: // Save + if msg.String() == "enter" { + m.cfg.Accounts[m.editingAccountIdx].SMIMECert = m.smimeCertInput.Value() + m.cfg.Accounts[m.editingAccountIdx].SMIMEKey = m.smimeKeyInput.Value() + m.cfg.Accounts[m.editingAccountIdx].PGPPublicKey = m.pgpPublicKeyInput.Value() + m.cfg.Accounts[m.editingAccountIdx].PGPPrivateKey = m.pgpPrivateKeyInput.Value() + m.cfg.Accounts[m.editingAccountIdx].PGPKeySource = m.pgpKeySource + m.cfg.Accounts[m.editingAccountIdx].PGPPIN = m.pgpPINInput.Value() + _ = config.SaveConfig(m.cfg) + m.state = SettingsAccounts + return m, nil + } + case 10: // Cancel + if msg.String() == "enter" { + m.state = SettingsAccounts + return m, nil + } } } - if m.focusIndex == 0 { + switch m.focusIndex { + case 0: m.smimeCertInput, cmd = m.smimeCertInput.Update(msg) cmds = append(cmds, cmd) - } else if m.focusIndex == 1 { + case 1: m.smimeKeyInput, cmd = m.smimeKeyInput.Update(msg) cmds = append(cmds, cmd) + case 3: + m.pgpPublicKeyInput, cmd = m.pgpPublicKeyInput.Update(msg) + cmds = append(cmds, cmd) + case 4: + m.pgpPrivateKeyInput, cmd = m.pgpPrivateKeyInput.Update(msg) + cmds = append(cmds, cmd) + case 6: + m.pgpPINInput, cmd = m.pgpPINInput.Update(msg) + cmds = append(cmds, cmd) } return m, tea.Batch(cmds...) @@ -537,6 +673,9 @@ func (m *Settings) viewAccounts() string { if account.SMIMECert != "" && account.SMIMEKey != "" { providerInfo += " [S/MIME Configured]" } + if account.PGPPublicKey != "" && account.PGPPrivateKey != "" { + providerInfo += " [PGP Configured]" + } line := fmt.Sprintf("%s - %s", displayName, accountEmailStyle.Render(providerInfo)) @@ -589,9 +728,12 @@ func (m *Settings) viewSMIMEConfig() string { var b strings.Builder account := m.cfg.Accounts[m.editingAccountIdx] - b.WriteString(titleStyle.Render(fmt.Sprintf("S/MIME Configuration for %s", account.FetchEmail))) + b.WriteString(titleStyle.Render(fmt.Sprintf("Crypto Configuration for %s", account.FetchEmail))) b.WriteString("\n\n") + // --- S/MIME Section --- + b.WriteString(settingsFocusedStyle.Render("S/MIME") + "\n") + if m.focusIndex == 0 { b.WriteString(settingsFocusedStyle.Render("Certificate (PEM) Path:\n")) } else { @@ -606,26 +748,85 @@ func (m *Settings) viewSMIMEConfig() string { } b.WriteString(m.smimeKeyInput.View() + "\n\n") - signStatus := "OFF" + smimeSignStatus := "OFF" if account.SMIMESignByDefault { - signStatus = "ON" + smimeSignStatus = "ON" } if m.focusIndex == 2 { - b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Sign By Default: %s\n\n", signStatus))) + b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Sign By Default: %s\n\n", smimeSignStatus))) + } else { + b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf(" Sign By Default: %s\n\n", smimeSignStatus))) + } + + // --- PGP Section --- + b.WriteString(settingsFocusedStyle.Render("PGP") + "\n") + + if m.focusIndex == 3 { + b.WriteString(settingsFocusedStyle.Render("Public Key Path:\n")) + } else { + b.WriteString(settingsBlurredStyle.Render("Public Key Path:\n")) + } + b.WriteString(m.pgpPublicKeyInput.View() + "\n\n") + + if m.focusIndex == 4 { + b.WriteString(settingsFocusedStyle.Render("Private Key Path:\n")) + } else { + b.WriteString(settingsBlurredStyle.Render("Private Key Path:\n")) + } + b.WriteString(m.pgpPrivateKeyInput.View() + "\n\n") + + // Key source toggle + keySourceDisplay := "File" + if m.pgpKeySource == "yubikey" { + keySourceDisplay = "YubiKey" + } + if m.focusIndex == 5 { + b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Key Source: %s\n\n", keySourceDisplay))) } else { - b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf(" Sign By Default: %s\n\n", signStatus))) + b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf(" Key Source: %s\n\n", keySourceDisplay))) } + // PIN input (only shown if YubiKey is selected) + if m.pgpKeySource == "yubikey" { + if m.focusIndex == 6 { + b.WriteString(settingsFocusedStyle.Render("YubiKey PIN:\n")) + } else { + b.WriteString(settingsBlurredStyle.Render("YubiKey PIN:\n")) + } + b.WriteString(m.pgpPINInput.View() + "\n\n") + } + + pgpSignStatus := "OFF" + if account.PGPSignByDefault { + pgpSignStatus = "ON" + } + if m.focusIndex == 7 { + b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Sign By Default: %s\n\n", pgpSignStatus))) + } else { + b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf(" Sign By Default: %s\n\n", pgpSignStatus))) + } + + pgpEncryptStatus := "OFF" + if account.PGPEncryptByDefault { + pgpEncryptStatus = "ON" + } + if m.focusIndex == 8 { + b.WriteString(settingsFocusedStyle.Render(fmt.Sprintf("> Encrypt By Default: %s\n\n", pgpEncryptStatus))) + } else { + b.WriteString(settingsBlurredStyle.Render(fmt.Sprintf(" Encrypt By Default: %s\n\n", pgpEncryptStatus))) + } + + // --- Buttons --- saveBtn := "[ Save ]" cancelBtn := "[ Cancel ]" - if m.focusIndex == 3 { + if m.focusIndex == 9 { saveBtn = settingsFocusedStyle.Render(saveBtn) } else { saveBtn = settingsBlurredStyle.Render(saveBtn) } - if m.focusIndex == 4 { + if m.focusIndex == 10 { cancelBtn = settingsFocusedStyle.Render(cancelBtn) } else { cancelBtn = settingsBlurredStyle.Render(cancelBtn) @@ -634,7 +835,7 @@ func (m *Settings) viewSMIMEConfig() string { b.WriteString(saveBtn + " " + cancelBtn + "\n\n") mainContent := b.String() - helpView := helpStyle.Render("tab/shift+tab: navigate • enter: save/next • esc: back") + helpView := helpStyle.Render("tab/shift+tab: navigate • enter: save/next • space: toggle • esc: back") if m.height > 0 { currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView))