From 02aeab44dbef11259a88ea83363bdf069e7fdefa Mon Sep 17 00:00:00 2001 From: Qasim Date: Wed, 4 Feb 2026 09:19:33 -0500 Subject: [PATCH] feat(email): add GPG/PGP email encryption and decryption Add support for encrypting outgoing emails and decrypting received PGP/MIME encrypted messages (RFC 3156). Sending encrypted email: - --encrypt flag encrypts with recipient's public key - --recipient-key to specify key ID (auto-fetches from key servers) - --sign --encrypt combines signing and encryption - Auto-fetches keys from keys.openpgp.org, keyserver.ubuntu.com, etc. Reading encrypted email: - --decrypt flag decrypts PGP/MIME messages - --decrypt --verify for sign+encrypt messages - Shows decryption key ID and signature verification New files: - internal/adapters/gpg/encrypt.go - encryption/decryption methods - internal/adapters/mime/encrypted.go - PGP/MIME message builder - internal/cli/email/read_decrypt.go - decrypt command handling - docs/commands/encryption.md - comprehensive documentation Also refactored MIME builder to use shared interface for common header writing, reducing code duplication. --- docs/COMMANDS.md | 20 +- docs/commands/email-signing.md | 15 +- docs/commands/email.md | 35 ++ docs/commands/encryption.md | 501 +++++++++++++++++ internal/adapters/gpg/encrypt.go | 353 ++++++++++++ internal/adapters/gpg/encrypt_test.go | 681 +++++++++++++++++++++++ internal/adapters/gpg/service.go | 45 +- internal/adapters/gpg/types.go | 16 + internal/adapters/mime/builder.go | 77 ++- internal/adapters/mime/encrypted.go | 123 ++++ internal/adapters/mime/encrypted_test.go | 331 +++++++++++ internal/cli/email/read.go | 32 +- internal/cli/email/read_decrypt.go | 234 ++++++++ internal/cli/email/read_decrypt_test.go | 362 ++++++++++++ internal/cli/email/send.go | 39 +- internal/cli/email/send_gpg.go | 307 +++++++--- 16 files changed, 3056 insertions(+), 115 deletions(-) create mode 100644 docs/commands/encryption.md create mode 100644 internal/adapters/gpg/encrypt.go create mode 100644 internal/adapters/gpg/encrypt_test.go create mode 100644 internal/adapters/mime/encrypted.go create mode 100644 internal/adapters/mime/encrypted_test.go create mode 100644 internal/cli/email/read_decrypt.go create mode 100644 internal/cli/email/read_decrypt_test.go diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 0d49731..8bf9b57 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -114,8 +114,13 @@ nylas email list [grant-id] # List emails nylas email read # Read email nylas email read --raw # Show raw body without HTML nylas email read --mime # Show raw RFC822/MIME format +nylas email read --decrypt # Decrypt PGP/MIME encrypted email +nylas email read --verify # Verify GPG signature +nylas email read --decrypt --verify # Decrypt and verify signature nylas email send --to EMAIL --subject SUBJECT --body BODY # Send email -nylas email send --to EMAIL --subject SUBJECT --body BODY --sign # Send GPG-signed email +nylas email send ... --sign # Send GPG-signed email +nylas email send ... --encrypt # Send GPG-encrypted email +nylas email send ... --sign --encrypt # Sign AND encrypt (recommended) nylas email send --list-gpg-keys # List available GPG signing keys nylas email search --query "QUERY" # Search emails nylas email delete # Delete email @@ -129,6 +134,15 @@ nylas email metadata show # Show message me **Filters:** `--unread`, `--starred`, `--from`, `--to`, `--subject`, `--has-attachment`, `--metadata` +**GPG/PGP security:** +```bash +nylas email send --to EMAIL --subject S --body B --sign # Sign with your GPG key +nylas email send --to EMAIL --subject S --body B --encrypt # Encrypt with recipient's key +nylas email send --to EMAIL --subject S --body B --sign --encrypt # Both (recommended) +nylas email read --decrypt # Decrypt encrypted email +nylas email read --decrypt --verify # Decrypt + verify signature +``` + **AI features:** ```bash nylas email ai analyze # AI-powered inbox summary @@ -138,7 +152,7 @@ nylas email ai analyze --provider claude # Use specific AI provider nylas email smart-compose --prompt "..." # AI-powered email generation ``` -**Details:** `docs/commands/email.md`, `docs/commands/email-signing.md`, `docs/commands/ai.md` +**Details:** `docs/commands/email.md`, `docs/commands/email-signing.md`, `docs/commands/encryption.md`, `docs/commands/ai.md` --- @@ -711,6 +725,8 @@ All commands follow consistent pattern: **For detailed documentation on any feature, see:** - Email: `docs/commands/email.md` +- Email Signing: `docs/commands/email-signing.md` +- Email Encryption: `docs/commands/encryption.md` - Calendar: `docs/commands/calendar.md` - Contacts: `docs/commands/contacts.md` - Webhooks: `docs/commands/webhooks.md` diff --git a/docs/commands/email-signing.md b/docs/commands/email-signing.md index 517aaae..8aa5f4a 100644 --- a/docs/commands/email-signing.md +++ b/docs/commands/email-signing.md @@ -302,16 +302,10 @@ gpg --send-keys YOUR_KEY_ID --keyserver keys.openpgp.org ### Current Limitations -- **Signing only**: Emails are not encrypted (content is readable) +- **Signing only**: This document covers signing. For encryption, see [GPG Email Encryption](encryption.md) - **No S/MIME**: Only PGP/MIME format is supported - **Manual verification**: Some email clients don't auto-verify signatures -### Future Enhancements - -Planned features (not yet implemented): -- Email encryption with recipient's public key -- S/MIME certificate support - --- ## Technical Details @@ -393,13 +387,14 @@ nylas email send \ --- -## Related Commands +## Related Documentation +- [GPG Email Encryption](encryption.md) - Encrypt emails for confidentiality +- [GPG Explained](explain-gpg.md) - Understanding GPG concepts - `nylas email send --help` - See all email sending options - `gpg --list-keys` - List all GPG keys - `gpg --gen-key` - Generate new GPG key -- `git config user.signingkey` - Set default signing key --- -**Last Updated:** 2026-02-01 +**Last Updated:** 2026-02-04 diff --git a/docs/commands/email.md b/docs/commands/email.md index 9ba76cc..f8df4fa 100644 --- a/docs/commands/email.md +++ b/docs/commands/email.md @@ -118,6 +118,41 @@ Email preview: Scheduled to send: Mon Dec 16, 2024 4:30 PM PST ``` +### GPG Signing and Encryption + +Sign and/or encrypt emails using GPG/PGP: + +```bash +# Sign email with your GPG key +nylas email send --to "to@example.com" --subject "Signed" --body "..." --sign + +# Encrypt email with recipient's public key +nylas email send --to "to@example.com" --subject "Encrypted" --body "..." --encrypt + +# Sign AND encrypt (recommended for maximum security) +nylas email send --to "to@example.com" --subject "Secure" --body "..." --sign --encrypt + +# List available GPG keys +nylas email send --list-gpg-keys +``` + +**Reading encrypted/signed emails:** + +```bash +# Decrypt an encrypted email +nylas email read --decrypt + +# Verify a signed email +nylas email read --verify + +# Decrypt and verify (for sign+encrypt emails) +nylas email read --decrypt --verify +``` + +**See also:** +- [GPG Email Signing](email-signing.md) - Detailed signing documentation +- [GPG Email Encryption](encryption.md) - Detailed encryption documentation + ### Search Emails ```bash diff --git a/docs/commands/encryption.md b/docs/commands/encryption.md new file mode 100644 index 0000000..7fa9030 --- /dev/null +++ b/docs/commands/encryption.md @@ -0,0 +1,501 @@ +# GPG Email Encryption + +Encrypt outgoing emails with GPG/PGP so only the intended recipient can read them. + +--- + +## Overview + +The Nylas CLI supports encrypting emails with GPG (GNU Privacy Guard) using the OpenPGP standard (RFC 3156). Encrypted emails provide: + +- **Confidentiality**: Only the recipient can decrypt and read the message +- **End-to-end security**: Content is encrypted before leaving your device + +**Encryption vs Signing:** + +| Feature | Signing | Encryption | +|---------|---------|------------| +| Purpose | Verify sender identity | Hide content from others | +| Key used | Your private key | Recipient's public key | +| Who can read | Anyone | Only recipient | +| Verifiable | Yes (authenticity) | No (confidentiality) | + +**Best Practice:** Use both signing AND encryption for maximum security. + +--- + +## Prerequisites + +### 1. Install GPG + +**Linux (Debian/Ubuntu):** +```bash +sudo apt install gnupg +``` + +**macOS:** +```bash +brew install gnupg +``` + +**Windows:** +Download from: https://gnupg.org/download/ + +### 2. Have Recipient's Public Key + +To encrypt for someone, you need their public key. The CLI can: +- Use keys already in your keyring +- **Auto-fetch** keys from public key servers + +**Supported key servers:** +- keys.openpgp.org (primary) +- keyserver.ubuntu.com +- pgp.mit.edu +- keys.gnupg.net + +--- + +## Sending Encrypted Email + +### Basic Encryption + +Encrypt an email to a recipient (auto-fetches their public key if needed): + +```bash +nylas email send \ + --to recipient@example.com \ + --subject "Confidential Information" \ + --body "This message is encrypted and only you can read it." \ + --encrypt +``` + +**What happens:** +1. CLI looks up recipient's public key in your local keyring +2. If not found, searches key servers automatically +3. Encrypts the message with their public key +4. Sends as PGP/MIME encrypted email + +### Encrypt with Specific Key + +If you have multiple keys for a recipient or want to specify exactly which key: + +```bash +nylas email send \ + --to recipient@example.com \ + --subject "Secret Project" \ + --body "Here are the project details..." \ + --encrypt \ + --recipient-key ABCD1234EFGH5678 +``` + +### Encrypt to Multiple Recipients + +When sending to multiple recipients (To, Cc, Bcc), the message is encrypted to all of them: + +```bash +nylas email send \ + --to alice@example.com \ + --cc bob@example.com \ + --subject "Team Secret" \ + --body "Confidential team information." \ + --encrypt +``` + +Each recipient can decrypt the message with their own private key. + +--- + +## Sign AND Encrypt (Recommended) + +For maximum security, sign AND encrypt your emails: + +```bash +nylas email send \ + --to recipient@example.com \ + --subject "Highly Confidential" \ + --body "This message is signed and encrypted." \ + --sign \ + --encrypt +``` + +**Benefits of sign+encrypt:** +- **Confidentiality**: Only recipient can read it +- **Authentication**: Recipient can verify it's from you +- **Integrity**: Content hasn't been tampered with + +**Order of operations:** +1. Sign the content with your private key +2. Encrypt the signed content with recipient's public key +3. Recipient decrypts first, then verifies signature + +--- + +## Reading Encrypted Email + +### Decrypt a Message + +To decrypt an encrypted email you received: + +```bash +nylas email read --decrypt +``` + +**Example output:** +``` +──────────────────────────────────────────────────────────── +EMAIL HEADERS +Message ID: 19c26a739a6a34d0 +──────────────────────────────────────────────────────────── +From: sender@example.com +To: you@example.com +Subject: Confidential Information + +──────────────────────────────────────────────────────────── +✓ Message decrypted successfully +──────────────────────────────────────────────────────────── + Decrypted with: B6F0A2849DC14AA2 + +──────────────────────────────────────────────────────────── +Decrypted Content: +──────────────────────────────────────────────────────────── + +This is the secret message content. +``` + +### Decrypt AND Verify Signature + +If the message was signed and encrypted, use both flags: + +```bash +nylas email read --decrypt --verify +``` + +**Example output:** +``` +──────────────────────────────────────────────────────────── +✓ Message decrypted successfully +──────────────────────────────────────────────────────────── + Decrypted with: B6F0A2849DC14AA2 + + ✓ Signature verified + Signer: Alice + Key ID: FF8780A3D2CDCCF4A5B38D3055023BA972AB76E8 + +──────────────────────────────────────────────────────────── +Decrypted Content: +──────────────────────────────────────────────────────────── + +This is the signed and encrypted message. +``` + +### Verify Only (Signed but Not Encrypted) + +For messages that are signed but not encrypted: + +```bash +nylas email read --verify +``` + +--- + +## Key Management + +### List Public Keys + +See all public keys in your keyring: + +```bash +gpg --list-keys +``` + +### Import a Public Key + +Import a key from a file: + +```bash +gpg --import recipient-key.asc +``` + +### Fetch Key from Server + +Manually fetch a key from key servers: + +```bash +gpg --keyserver keys.openpgp.org --recv-keys KEYID +``` + +Or search by email: + +```bash +gpg --keyserver keys.openpgp.org --search-keys user@example.com +``` + +### Auto-Fetch Behavior + +When you use `--encrypt`, the CLI automatically: + +1. Searches your local keyring for the recipient's email +2. If not found, tries Web Key Directory (WKD) +3. Falls back to key servers in order: + - keys.openpgp.org + - keyserver.ubuntu.com + - pgp.mit.edu + - keys.gnupg.net +4. Imports the key to your keyring for future use + +### Export Your Public Key + +Share your public key so others can encrypt messages to you: + +```bash +# Export to file +gpg --armor --export your@email.com > my-public-key.asc + +# Upload to key server +gpg --keyserver keys.openpgp.org --send-keys YOUR_KEY_ID +``` + +--- + +## Examples + +### Send Encrypted Project Update + +```bash +nylas email send \ + --to manager@company.com \ + --subject "Q4 Financial Data" \ + --body "Attached are the confidential Q4 numbers..." \ + --encrypt +``` + +### Send Signed and Encrypted Contract + +```bash +nylas email send \ + --to legal@partner.com \ + --subject "Contract Draft v2" \ + --body "Please review the attached contract draft." \ + --sign \ + --encrypt +``` + +### Decrypt Email from Inbox + +```bash +# First, find the encrypted message +nylas email list --limit 10 + +# Then decrypt it +nylas email read 19c26a739a6a34d0 --decrypt +``` + +### Decrypt and Verify Important Email + +```bash +nylas email read 19c26a739a6a34d0 --decrypt --verify +``` + +### Send to Multiple Recipients + +```bash +nylas email send \ + --to alice@example.com \ + --cc bob@example.com \ + --bcc charlie@example.com \ + --subject "Board Meeting Notes" \ + --body "Confidential notes from today's meeting." \ + --sign \ + --encrypt +``` + +--- + +## Troubleshooting + +### "No public key found for recipient" + +**Problem:** Recipient's public key not in your keyring and not on key servers. + +**Solution:** +```bash +# Ask recipient to share their public key, then import it +gpg --import their-key.asc + +# Or ask them to upload to a key server +# They run: gpg --keyserver keys.openpgp.org --send-keys THEIR_KEY_ID +``` + +### "Message is not PGP/MIME encrypted" + +**Problem:** Trying to decrypt a message that isn't encrypted. + +**Solution:** Check if the message is actually encrypted: +```bash +# View raw MIME to check Content-Type +nylas email read --mime +``` + +Encrypted messages have: +``` +Content-Type: multipart/encrypted; protocol="application/pgp-encrypted" +``` + +### "No secret key available to decrypt" + +**Problem:** You don't have the private key needed to decrypt. + +**Solution:** The message was encrypted for a different recipient. You can only decrypt messages encrypted to your public key. + +### "GPG decryption failed: Timeout" + +**Problem:** GPG passphrase prompt timed out. + +**Solution:** +```bash +# Start gpg-agent +gpg-agent --daemon + +# Or configure pinentry +echo "pinentry-program /usr/bin/pinentry-tty" >> ~/.gnupg/gpg-agent.conf +gpgconf --kill gpg-agent +``` + +### "Key is expired" + +**Problem:** Recipient's public key has expired. + +**Solution:** +```bash +# Check key expiration +gpg --list-keys recipient@example.com + +# Ask recipient to extend their key or create a new one +# Then re-fetch: gpg --keyserver keys.openpgp.org --recv-keys THEIR_KEY_ID +``` + +--- + +## Security Considerations + +### Key Trust + +When auto-fetching keys, the CLI uses `--trust-model always` for the encryption operation. This means: +- Keys are used even if not explicitly trusted +- You should verify key fingerprints for sensitive communications + +**Verify a key fingerprint:** +```bash +gpg --fingerprint recipient@example.com +``` + +Compare with the recipient through a secure channel (phone, in person). + +### Multiple Recipients + +When encrypting to multiple recipients: +- Each recipient can decrypt independently +- Each recipient sees the full list of To/Cc recipients +- Bcc recipients are hidden but can still decrypt + +### Forward Secrecy + +PGP encryption does NOT provide forward secrecy: +- If a private key is compromised later, past messages can be decrypted +- For highly sensitive data, consider additional protections + +--- + +## Technical Details + +### MIME Structure (Encrypted) + +Encrypted emails use RFC 3156 PGP/MIME format: + +``` +Content-Type: multipart/encrypted; + protocol="application/pgp-encrypted"; + boundary="encrypted_boundary" + +--encrypted_boundary +Content-Type: application/pgp-encrypted + +Version: 1 + +--encrypted_boundary +Content-Type: application/octet-stream + +-----BEGIN PGP MESSAGE----- +[Encrypted data] +-----END PGP MESSAGE----- +--encrypted_boundary-- +``` + +### MIME Structure (Signed + Encrypted) + +When using both `--sign` and `--encrypt`: + +1. Content is signed first (creates signature) +2. Signed content is encrypted +3. Result is PGP/MIME encrypted message containing signed content + +### Encryption Algorithm + +- **Symmetric**: AES-256 (for message encryption) +- **Asymmetric**: RSA/ECDH (for key exchange) +- **Determined by**: Recipient's public key preferences + +--- + +## Command Reference + +### Send Flags + +| Flag | Description | +|------|-------------| +| `--encrypt` | Encrypt email with recipient's public key | +| `--recipient-key ` | Specify recipient's GPG key ID | +| `--sign` | Also sign the email (recommended with encrypt) | +| `--gpg-key ` | Specify signing key (for --sign) | + +### Read Flags + +| Flag | Description | +|------|-------------| +| `--decrypt` | Decrypt PGP/MIME encrypted message | +| `--verify` | Verify GPG signature (use with --decrypt for sign+encrypt) | +| `--mime` | Show raw MIME (to inspect encryption) | + +--- + +## Related Documentation + +- [GPG Email Signing](email-signing.md) - Sign emails without encryption +- [GPG Explained](explain-gpg.md) - Understanding GPG concepts +- `nylas email send --help` - Full command options +- `nylas email read --help` - Full read options + +--- + +## Quick Reference + +```bash +# Encrypt only +nylas email send --to user@example.com --subject "Secret" --body "..." --encrypt + +# Sign + Encrypt (recommended) +nylas email send --to user@example.com --subject "Secret" --body "..." --sign --encrypt + +# Decrypt received email +nylas email read --decrypt + +# Decrypt and verify signature +nylas email read --decrypt --verify + +# Verify signature only (not encrypted) +nylas email read --verify +``` + +--- + +**Last Updated:** 2026-02-04 diff --git a/internal/adapters/gpg/encrypt.go b/internal/adapters/gpg/encrypt.go new file mode 100644 index 0000000..b9d7aa7 --- /dev/null +++ b/internal/adapters/gpg/encrypt.go @@ -0,0 +1,353 @@ +package gpg + +import ( + "bytes" + "context" + "fmt" + "net/mail" + "os/exec" + "regexp" + "strings" + "time" +) + +// ListPublicKeys lists all public keys in the keyring. +func (s *service) ListPublicKeys(ctx context.Context) ([]KeyInfo, error) { + cmd := exec.CommandContext(ctx, "gpg", "--list-keys", "--with-colons", "--with-fingerprint") + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok && len(exitErr.Stderr) > 0 { + return nil, fmt.Errorf("gpg list public keys failed: %s", string(exitErr.Stderr)) + } + // Empty keyring is not an error - return empty slice + return []KeyInfo{}, nil + } + + return parsePublicKeys(string(output)) +} + +// FindPublicKeyByEmail finds a public key by email, auto-fetching from key servers if not found locally. +func (s *service) FindPublicKeyByEmail(ctx context.Context, email string) (*KeyInfo, error) { + // Normalize email for comparison + email = strings.ToLower(strings.TrimSpace(email)) + + // Step 1: Search local keyring first + keys, err := s.ListPublicKeys(ctx) + if err != nil { + return nil, err + } + + for i := range keys { + if keyMatchesEmail(&keys[i], email) { + // Check if key is expired + if keys[i].Expires != nil && keys[i].Expires.Before(time.Now()) { + continue // Skip expired keys + } + return &keys[i], nil + } + } + + // Step 2: Not found locally - try to fetch from key servers + if fetchErr := s.fetchKeyByEmail(ctx, email); fetchErr != nil { + return nil, fmt.Errorf("no public key found for %s (checked local keyring and %d key servers): %w", + email, len(KeyServers), fetchErr) + } + + // Step 3: Retry local search after fetch + keys, err = s.ListPublicKeys(ctx) + if err != nil { + return nil, err + } + + for i := range keys { + if keyMatchesEmail(&keys[i], email) { + return &keys[i], nil + } + } + + return nil, fmt.Errorf("key fetched but not found for %s", email) +} + +// fetchKeyByEmail tries to fetch a public key by email from key servers. +func (s *service) fetchKeyByEmail(ctx context.Context, email string) error { + // Validate email format + if _, err := mail.ParseAddress(email); err != nil { + return fmt.Errorf("invalid email format: %q", email) + } + + var lastErr error + for _, server := range KeyServers { + // Use --auto-key-locate with WKD (Web Key Directory) and keyserver fallback + // #nosec G204 - email is validated by mail.ParseAddress above + cmd := exec.CommandContext(ctx, "gpg", "--auto-key-locate", "wkd,keyserver", "--keyserver", server, "--locate-keys", email) + var stderr bytes.Buffer + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + lastErr = fmt.Errorf("failed to fetch from %s: %w", server, err) + continue + } + // Success + return nil + } + + return fmt.Errorf("failed to fetch key for %s from any server: %w", email, lastErr) +} + +// keyMatchesEmail checks if a key contains the given email in its UIDs. +func keyMatchesEmail(key *KeyInfo, email string) bool { + email = strings.ToLower(email) + for _, uid := range key.UIDs { + uidLower := strings.ToLower(uid) + // Check for email in angle brackets: "Name " + if strings.Contains(uidLower, "<"+email+">") { + return true + } + // Check for bare email + if uidLower == email { + return true + } + } + return false +} + +// EncryptData encrypts data for one or more recipients using their public keys. +func (s *service) EncryptData(ctx context.Context, recipientKeyIDs []string, data []byte) (*EncryptResult, error) { + if len(recipientKeyIDs) == 0 { + return nil, fmt.Errorf("at least one recipient key ID is required") + } + + // Validate all key IDs + for _, keyID := range recipientKeyIDs { + if !isValidGPGKeyID(keyID) { + return nil, fmt.Errorf("invalid GPG key ID format: %q", keyID) + } + } + + // Build GPG arguments + args := []string{ + "--encrypt", + "--armor", + "--trust-model", "always", // Trust the key for this operation + } + + // Add each recipient + for _, keyID := range recipientKeyIDs { + args = append(args, "--recipient", keyID) + } + + args = append(args, "--output", "-") + + // #nosec G204 - recipientKeyIDs are validated above by isValidGPGKeyID + cmd := exec.CommandContext(ctx, "gpg", args...) + + cmd.Stdin = bytes.NewReader(data) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + errMsg := stderr.String() + if strings.Contains(errMsg, "No public key") { + return nil, fmt.Errorf("public key not found for one or more recipients") + } + if strings.Contains(errMsg, "unusable public key") { + return nil, fmt.Errorf("one or more recipient keys are unusable (expired, revoked, or invalid)") + } + return nil, fmt.Errorf("gpg encryption failed: %s", errMsg) + } + + ciphertext := stdout.Bytes() + if len(ciphertext) == 0 { + return nil, fmt.Errorf("gpg produced empty ciphertext") + } + + return &EncryptResult{ + Ciphertext: ciphertext, + RecipientKeys: recipientKeyIDs, + }, nil +} + +// SignAndEncryptData signs data with the sender's private key and encrypts for recipients. +// This provides maximum security: only recipients can decrypt, and they can verify the sender. +func (s *service) SignAndEncryptData(ctx context.Context, signerKeyID string, recipientKeyIDs []string, data []byte, senderEmail string) (*EncryptResult, error) { + if signerKeyID == "" { + return nil, fmt.Errorf("signer key ID is required for sign+encrypt") + } + if len(recipientKeyIDs) == 0 { + return nil, fmt.Errorf("at least one recipient key ID is required") + } + + // Validate signer key ID + if !isValidGPGKeyID(signerKeyID) { + return nil, fmt.Errorf("invalid signer GPG key ID format: %q", signerKeyID) + } + + // Validate all recipient key IDs + for _, keyID := range recipientKeyIDs { + if !isValidGPGKeyID(keyID) { + return nil, fmt.Errorf("invalid recipient GPG key ID format: %q", keyID) + } + } + + // Validate senderEmail if provided + if senderEmail != "" { + if _, err := mail.ParseAddress(senderEmail); err != nil { + return nil, fmt.Errorf("invalid sender email format: %q", senderEmail) + } + } + + // Build GPG arguments for sign+encrypt + args := []string{ + "--sign", + "--encrypt", + "--armor", + "--trust-model", "always", + "--local-user", signerKeyID, + } + + // Add --sender for proper UID embedding + if senderEmail != "" { + args = append(args, "--sender", senderEmail) + } + + // Add each recipient + for _, keyID := range recipientKeyIDs { + args = append(args, "--recipient", keyID) + } + + args = append(args, "--output", "-") + + // #nosec G204 - all key IDs are validated above by isValidGPGKeyID + cmd := exec.CommandContext(ctx, "gpg", args...) + + cmd.Stdin = bytes.NewReader(data) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + errMsg := stderr.String() + if strings.Contains(errMsg, "No secret key") { + return nil, fmt.Errorf("GPG signing key %s not found or not usable", signerKeyID) + } + if strings.Contains(errMsg, "No public key") { + return nil, fmt.Errorf("public key not found for one or more recipients") + } + if strings.Contains(errMsg, "unusable public key") { + return nil, fmt.Errorf("one or more recipient keys are unusable (expired, revoked, or invalid)") + } + if strings.Contains(errMsg, "Timeout") || strings.Contains(errMsg, "timeout") { + return nil, fmt.Errorf("GPG passphrase prompt timed out. Please ensure gpg-agent is running") + } + return nil, fmt.Errorf("gpg sign+encrypt failed: %s", errMsg) + } + + ciphertext := stdout.Bytes() + if len(ciphertext) == 0 { + return nil, fmt.Errorf("gpg produced empty ciphertext") + } + + return &EncryptResult{ + Ciphertext: ciphertext, + RecipientKeys: recipientKeyIDs, + }, nil +} + +// DecryptData decrypts PGP encrypted data using the user's private key. +// It also handles signed+encrypted messages, returning signature verification info. +func (s *service) DecryptData(ctx context.Context, ciphertext []byte) (*DecryptResult, error) { + if len(ciphertext) == 0 { + return nil, fmt.Errorf("ciphertext is empty") + } + + // Build GPG arguments + args := []string{ + "--decrypt", + "--status-fd", "2", // Output status to stderr for parsing + } + + // #nosec G204 - no user input in command + cmd := exec.CommandContext(ctx, "gpg", args...) + + cmd.Stdin = bytes.NewReader(ciphertext) + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + err := cmd.Run() + stderrOutput := stderr.String() + + // Parse the result even if there was an error (bad signature still decrypts) + result := parseDecryptOutput(stderrOutput) + result.Plaintext = stdout.Bytes() + + if err != nil { + // Check for common errors + if strings.Contains(stderrOutput, "No secret key") { + return nil, fmt.Errorf("no secret key available to decrypt this message. The message was encrypted for a different recipient") + } + if strings.Contains(stderrOutput, "decryption failed") { + return nil, fmt.Errorf("decryption failed: %s", stderrOutput) + } + // If we got plaintext despite the error (e.g., bad signature), return the result + if len(result.Plaintext) > 0 { + return result, nil + } + return nil, fmt.Errorf("gpg decryption failed: %s", stderrOutput) + } + + if len(result.Plaintext) == 0 { + return nil, fmt.Errorf("gpg produced empty plaintext") + } + + return result, nil +} + +// parseDecryptOutput parses GPG status output during decryption. +func parseDecryptOutput(stderrOutput string) *DecryptResult { + result := &DecryptResult{} + + // Check for signature status + if strings.Contains(stderrOutput, "GOODSIG") || strings.Contains(stderrOutput, "Good signature") { + result.WasSigned = true + result.SignatureOK = true + } else if strings.Contains(stderrOutput, "BADSIG") || strings.Contains(stderrOutput, "BAD signature") { + result.WasSigned = true + result.SignatureOK = false + } + + // Extract signer key ID using regex + // Pattern: "gpg: Signature made ... using RSA key " + // or "[GNUPG:] GOODSIG " + keyIDPattern := regexp.MustCompile(`using \w+ key ([A-F0-9]+)`) + if matches := keyIDPattern.FindStringSubmatch(stderrOutput); len(matches) > 1 { + result.SignerKeyID = matches[1] + } + + // Also try GOODSIG/BADSIG format: "[GNUPG:] GOODSIG " + if result.SignerKeyID == "" { + goodsigPattern := regexp.MustCompile(`\[GNUPG:\] (?:GOODSIG|BADSIG) ([A-F0-9]+) (.+)`) + if matches := goodsigPattern.FindStringSubmatch(stderrOutput); len(matches) > 2 { + result.SignerKeyID = matches[1] + result.SignerUID = strings.TrimSpace(matches[2]) + } + } + + // Extract signer UID from "Good signature from" pattern + if result.SignerUID == "" { + uidPattern := regexp.MustCompile(`Good signature from "([^"]+)"`) + if matches := uidPattern.FindStringSubmatch(stderrOutput); len(matches) > 1 { + result.SignerUID = matches[1] + } + } + + // Extract decryption key ID + // Pattern: "gpg: encrypted with ... " + decKeyPattern := regexp.MustCompile(`encrypted with.*?([A-F0-9]{8,})`) + if matches := decKeyPattern.FindStringSubmatch(stderrOutput); len(matches) > 1 { + result.DecryptKeyID = matches[1] + } + + return result +} diff --git a/internal/adapters/gpg/encrypt_test.go b/internal/adapters/gpg/encrypt_test.go new file mode 100644 index 0000000..97bb951 --- /dev/null +++ b/internal/adapters/gpg/encrypt_test.go @@ -0,0 +1,681 @@ +package gpg + +import ( + "context" + "strings" + "testing" + "time" +) + +func TestParsePublicKeys(t *testing.T) { + tests := []struct { + name string + input string + want int + wantErr bool + }{ + { + name: "single public key with UID", + input: `pub:u:4096:1:601FEE9B1D60185F:1609459200:::u:::scESC:::+:::23::0: +fpr:::::::::1234567890ABCDEF1234567890ABCDEF12345678: +uid:u::::1609459200::1234567890ABCDEF1234567890ABCDEF12345678::John Doe ::::::::::0: +`, + want: 1, + wantErr: false, + }, + { + name: "multiple public keys", + input: `pub:u:4096:1:601FEE9B1D60185F:1609459200:::u:::scESC:::+:::23::0: +fpr:::::::::AAAA567890ABCDEF1234567890ABCDEF12345678: +uid:u::::1609459200::AAAA567890ABCDEF1234567890ABCDEF12345678::Alice ::::::::::0: +pub:u:2048:1:701FEE9B1D60185G:1609459200:::u:::scESC:::+:::23::0: +fpr:::::::::BBBB567890ABCDEF1234567890ABCDEF12345678: +uid:u::::1609459200::BBBB567890ABCDEF1234567890ABCDEF12345678::Bob ::::::::::0: +`, + want: 2, + wantErr: false, + }, + { + name: "no keys", + input: "", + want: 0, + wantErr: false, // Empty is OK for public keys (unlike secret keys) + }, + { + name: "invalid format", + input: "invalid output", + want: 0, + wantErr: false, // Still returns empty slice, not error + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parsePublicKeys(tt.input) + if (err != nil) != tt.wantErr { + t.Errorf("parsePublicKeys() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !tt.wantErr && len(got) != tt.want { + t.Errorf("parsePublicKeys() got %d keys, want %d", len(got), tt.want) + } + }) + } +} + +func TestKeyMatchesEmail(t *testing.T) { + tests := []struct { + name string + key KeyInfo + email string + want bool + }{ + { + name: "email in angle brackets", + key: KeyInfo{ + UIDs: []string{"John Doe "}, + }, + email: "john@example.com", + want: true, + }, + { + name: "case insensitive match", + key: KeyInfo{ + UIDs: []string{"John Doe "}, + }, + email: "john@example.com", + want: true, + }, + { + name: "bare email match", + key: KeyInfo{ + UIDs: []string{"user@example.com"}, + }, + email: "user@example.com", + want: true, + }, + { + name: "multiple UIDs with match", + key: KeyInfo{ + UIDs: []string{ + "Work ", + "Personal ", + }, + }, + email: "john@example.com", + want: true, + }, + { + name: "no match", + key: KeyInfo{ + UIDs: []string{"Other "}, + }, + email: "john@example.com", + want: false, + }, + { + name: "partial match not accepted", + key: KeyInfo{ + UIDs: []string{"John "}, + }, + email: "john@example.com", + want: false, + }, + { + name: "empty UIDs", + key: KeyInfo{ + UIDs: []string{}, + }, + email: "john@example.com", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := keyMatchesEmail(&tt.key, tt.email) + if got != tt.want { + t.Errorf("keyMatchesEmail() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestEncryptData_Validation(t *testing.T) { + ctx := context.Background() + svc := NewService() + + // Check if GPG is available + if err := svc.CheckGPGAvailable(ctx); err != nil { + t.Skip("GPG not available, skipping test") + } + + tests := []struct { + name string + recipientKeys []string + data []byte + wantErrMsg string + }{ + { + name: "no recipients", + recipientKeys: []string{}, + data: []byte("test"), + wantErrMsg: "at least one recipient key ID is required", + }, + { + name: "invalid key ID format", + recipientKeys: []string{"INVALID; rm -rf /"}, + data: []byte("test"), + wantErrMsg: "invalid GPG key ID format", + }, + { + name: "command injection attempt", + recipientKeys: []string{"KEY`whoami`"}, + data: []byte("test"), + wantErrMsg: "invalid GPG key ID format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := svc.EncryptData(ctx, tt.recipientKeys, tt.data) + if err == nil { + t.Fatal("EncryptData() expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErrMsg) { + t.Errorf("EncryptData() error = %v, want error containing %q", err, tt.wantErrMsg) + } + }) + } +} + +func TestListPublicKeys_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + ctx := context.Background() + svc := NewService() + + // Check if GPG is available + if err := svc.CheckGPGAvailable(ctx); err != nil { + t.Skip("GPG not available, skipping integration test") + } + + keys, err := svc.ListPublicKeys(ctx) + if err != nil { + t.Fatalf("ListPublicKeys() error = %v", err) + } + + // It's OK if no keys exist in the test environment + t.Logf("Found %d public keys", len(keys)) + + // Validate key structure if any keys exist + for _, key := range keys { + if key.KeyID == "" { + t.Error("Key missing KeyID") + } + } +} + +func TestFindPublicKeyByEmail_NotFound(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + ctx := context.Background() + svc := NewService() + + // Check if GPG is available + if err := svc.CheckGPGAvailable(ctx); err != nil { + t.Skip("GPG not available, skipping integration test") + } + + // Try to find a key for a non-existent email (random UUID domain) + _, err := svc.FindPublicKeyByEmail(ctx, "nonexistent@e8f9a2b1-c3d4-5e6f-7g8h-9i0j1k2l3m4n.test") + if err == nil { + t.Error("FindPublicKeyByEmail() expected error for non-existent email, got nil") + } + + // Should mention that key was not found + if !strings.Contains(err.Error(), "no public key found") { + t.Errorf("Error should mention 'no public key found', got: %v", err) + } +} + +func TestEncryptData_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + ctx := context.Background() + svc := NewService() + + // Check if GPG is available + if err := svc.CheckGPGAvailable(ctx); err != nil { + t.Skip("GPG not available, skipping integration test") + } + + // List public keys to find one to encrypt to + keys, err := svc.ListPublicKeys(ctx) + if err != nil || len(keys) == 0 { + t.Skip("No public keys available, skipping test") + } + + // Use the first available key + testKeyID := keys[0].KeyID + + // Encrypt test data + testData := []byte("Hello, this is a secret message!") + result, err := svc.EncryptData(ctx, []string{testKeyID}, testData) + if err != nil { + t.Fatalf("EncryptData() error = %v", err) + } + + if result == nil { + t.Fatal("EncryptData() returned nil result") + } + + // Validate ciphertext + if len(result.Ciphertext) == 0 { + t.Error("EncryptData() returned empty ciphertext") + } + + // Check for PGP message markers + ciphertextStr := string(result.Ciphertext) + if !strings.Contains(ciphertextStr, "-----BEGIN PGP MESSAGE-----") { + t.Error("Ciphertext missing PGP BEGIN marker") + } + if !strings.Contains(ciphertextStr, "-----END PGP MESSAGE-----") { + t.Error("Ciphertext missing PGP END marker") + } + + // Check recipient keys are recorded + if len(result.RecipientKeys) == 0 { + t.Error("EncryptResult missing RecipientKeys") + } + if result.RecipientKeys[0] != testKeyID { + t.Errorf("RecipientKeys[0] = %v, want %v", result.RecipientKeys[0], testKeyID) + } +} + +func TestSignAndEncryptData_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + ctx := context.Background() + svc := NewService() + + // Check if GPG is available + if err := svc.CheckGPGAvailable(ctx); err != nil { + t.Skip("GPG not available, skipping integration test") + } + + // Get signing key + signerKey, err := svc.GetDefaultSigningKey(ctx) + if err != nil { + t.Skip("No default GPG key configured, skipping test") + } + + // List public keys to find one to encrypt to + keys, err := svc.ListPublicKeys(ctx) + if err != nil || len(keys) == 0 { + t.Skip("No public keys available, skipping test") + } + + // Use the first available key (could be same as signer for self-test) + recipientKeyID := keys[0].KeyID + + // Sign and encrypt test data + testData := []byte("Hello, this is a signed and encrypted message!") + result, err := svc.SignAndEncryptData(ctx, signerKey.KeyID, []string{recipientKeyID}, testData, "") + if err != nil { + t.Fatalf("SignAndEncryptData() error = %v", err) + } + + if result == nil { + t.Fatal("SignAndEncryptData() returned nil result") + } + + // Validate ciphertext + if len(result.Ciphertext) == 0 { + t.Error("SignAndEncryptData() returned empty ciphertext") + } + + // Check for PGP message markers + ciphertextStr := string(result.Ciphertext) + if !strings.Contains(ciphertextStr, "-----BEGIN PGP MESSAGE-----") { + t.Error("Ciphertext missing PGP BEGIN marker") + } + if !strings.Contains(ciphertextStr, "-----END PGP MESSAGE-----") { + t.Error("Ciphertext missing PGP END marker") + } +} + +func TestSignAndEncryptData_Validation(t *testing.T) { + ctx := context.Background() + svc := NewService() + + // Check if GPG is available + if err := svc.CheckGPGAvailable(ctx); err != nil { + t.Skip("GPG not available, skipping test") + } + + tests := []struct { + name string + signerKeyID string + recipientKeys []string + data []byte + senderEmail string + wantErrMsg string + }{ + { + name: "no signer key", + signerKeyID: "", + recipientKeys: []string{"ABCD1234"}, + data: []byte("test"), + senderEmail: "", + wantErrMsg: "signer key ID is required", + }, + { + name: "no recipients", + signerKeyID: "ABCD1234", + recipientKeys: []string{}, + data: []byte("test"), + senderEmail: "", + wantErrMsg: "at least one recipient key ID is required", + }, + { + name: "invalid signer key format", + signerKeyID: "INVALID; rm -rf /", + recipientKeys: []string{"ABCD1234"}, + data: []byte("test"), + senderEmail: "", + wantErrMsg: "invalid signer GPG key ID format", + }, + { + name: "invalid recipient key format", + signerKeyID: "ABCD1234", + recipientKeys: []string{"INVALID`whoami`"}, + data: []byte("test"), + senderEmail: "", + wantErrMsg: "invalid recipient GPG key ID format", + }, + { + name: "invalid sender email", + signerKeyID: "ABCD1234", + recipientKeys: []string{"ABCD5678"}, + data: []byte("test"), + senderEmail: "not-an-email", + wantErrMsg: "invalid sender email format", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := svc.SignAndEncryptData(ctx, tt.signerKeyID, tt.recipientKeys, tt.data, tt.senderEmail) + if err == nil { + t.Fatal("SignAndEncryptData() expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErrMsg) { + t.Errorf("SignAndEncryptData() error = %v, want error containing %q", err, tt.wantErrMsg) + } + }) + } +} + +func TestKeyInfo_ExpiredKey(t *testing.T) { + // Test that expired keys are properly detected + pastTime := time.Now().Add(-24 * time.Hour) + futureTime := time.Now().Add(24 * time.Hour) + + tests := []struct { + name string + key KeyInfo + isExpired bool + }{ + { + name: "expired key", + key: KeyInfo{ + KeyID: "EXPIRED1234", + Expires: &pastTime, + }, + isExpired: true, + }, + { + name: "valid key", + key: KeyInfo{ + KeyID: "VALID1234", + Expires: &futureTime, + }, + isExpired: false, + }, + { + name: "no expiration", + key: KeyInfo{ + KeyID: "NOEXPIRE1234", + Expires: nil, + }, + isExpired: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + isExpired := tt.key.Expires != nil && tt.key.Expires.Before(time.Now()) + if isExpired != tt.isExpired { + t.Errorf("Key expired = %v, want %v", isExpired, tt.isExpired) + } + }) + } +} + +func TestDecryptData_Validation(t *testing.T) { + ctx := context.Background() + svc := NewService() + + // Check if GPG is available + if err := svc.CheckGPGAvailable(ctx); err != nil { + t.Skip("GPG not available, skipping test") + } + + tests := []struct { + name string + ciphertext []byte + wantErrMsg string + }{ + { + name: "empty ciphertext", + ciphertext: []byte{}, + wantErrMsg: "ciphertext is empty", + }, + { + name: "invalid PGP data", + ciphertext: []byte("not valid PGP data"), + wantErrMsg: "decryption failed", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := svc.DecryptData(ctx, tt.ciphertext) + if err == nil { + t.Fatal("DecryptData() expected error, got nil") + } + if !strings.Contains(err.Error(), tt.wantErrMsg) { + t.Errorf("DecryptData() error = %v, want error containing %q", err, tt.wantErrMsg) + } + }) + } +} + +func TestParseDecryptOutput(t *testing.T) { + tests := []struct { + name string + stderrOutput string + wantSigned bool + wantSigOK bool + wantKeyID string + wantUID string + }{ + { + name: "encrypted only, no signature", + stderrOutput: `gpg: encrypted with 4096-bit RSA key, ID ABCD1234 +gpg: decryption okay`, + wantSigned: false, + wantSigOK: false, + }, + { + name: "signed and encrypted with good signature", + stderrOutput: `gpg: encrypted with 4096-bit RSA key, ID ABCD1234 +gpg: Signature made Mon 01 Jan 2024 12:00:00 PM EST +gpg: using RSA key DBADDF54A44EB10E9714F386601FEE9B1D60185F +gpg: Good signature from "John Doe " [ultimate]`, + wantSigned: true, + wantSigOK: true, + wantKeyID: "DBADDF54A44EB10E9714F386601FEE9B1D60185F", + wantUID: "John Doe ", + }, + { + name: "signed and encrypted with bad signature", + stderrOutput: `gpg: encrypted with 4096-bit RSA key, ID ABCD1234 +gpg: Signature made Mon 01 Jan 2024 12:00:00 PM EST +gpg: using RSA key DBADDF54A44EB10E9714F386601FEE9B1D60185F +gpg: BAD signature from "John Doe " [ultimate]`, + wantSigned: true, + wantSigOK: false, + wantKeyID: "DBADDF54A44EB10E9714F386601FEE9B1D60185F", + }, + { + name: "GNUPG status format", + stderrOutput: `[GNUPG:] GOODSIG 601FEE9B1D60185F John Doe `, + wantSigned: true, + wantSigOK: true, + wantKeyID: "601FEE9B1D60185F", + wantUID: "John Doe ", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseDecryptOutput(tt.stderrOutput) + + if result.WasSigned != tt.wantSigned { + t.Errorf("WasSigned = %v, want %v", result.WasSigned, tt.wantSigned) + } + if result.SignatureOK != tt.wantSigOK { + t.Errorf("SignatureOK = %v, want %v", result.SignatureOK, tt.wantSigOK) + } + if tt.wantKeyID != "" && result.SignerKeyID != tt.wantKeyID { + t.Errorf("SignerKeyID = %v, want %v", result.SignerKeyID, tt.wantKeyID) + } + if tt.wantUID != "" && result.SignerUID != tt.wantUID { + t.Errorf("SignerUID = %v, want %v", result.SignerUID, tt.wantUID) + } + }) + } +} + +func TestDecryptData_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + ctx := context.Background() + svc := NewService() + + // Check if GPG is available + if err := svc.CheckGPGAvailable(ctx); err != nil { + t.Skip("GPG not available, skipping integration test") + } + + // Get a key to encrypt to (will use for self-decryption test) + keys, err := svc.ListPublicKeys(ctx) + if err != nil || len(keys) == 0 { + t.Skip("No public keys available, skipping test") + } + + // Find a key we have the private key for (secret key) + signingKeys, err := svc.ListSigningKeys(ctx) + if err != nil || len(signingKeys) == 0 { + t.Skip("No secret keys available, skipping test") + } + + // Use first signing key (we definitely have the private key for this) + recipientKeyID := signingKeys[0].KeyID + + // Encrypt test data + testData := []byte("This is a secret message for decryption test!") + encResult, err := svc.EncryptData(ctx, []string{recipientKeyID}, testData) + if err != nil { + t.Fatalf("EncryptData() error = %v", err) + } + + // Decrypt the data + decResult, err := svc.DecryptData(ctx, encResult.Ciphertext) + if err != nil { + t.Fatalf("DecryptData() error = %v", err) + } + + // Verify decrypted content matches original + if string(decResult.Plaintext) != string(testData) { + t.Errorf("Decrypted content mismatch:\ngot: %q\nwant: %q", string(decResult.Plaintext), string(testData)) + } + + // Should not be signed (encrypt-only) + if decResult.WasSigned { + t.Error("Expected WasSigned=false for encrypt-only message") + } +} + +func TestDecryptSignedAndEncryptedData_Integration(t *testing.T) { + if testing.Short() { + t.Skip("Skipping integration test") + } + + ctx := context.Background() + svc := NewService() + + // Check if GPG is available + if err := svc.CheckGPGAvailable(ctx); err != nil { + t.Skip("GPG not available, skipping integration test") + } + + // Get default signing key + signerKey, err := svc.GetDefaultSigningKey(ctx) + if err != nil { + t.Skip("No default GPG key configured, skipping test") + } + + // Use same key for recipient (self-test) + recipientKeyID := signerKey.KeyID + + // Sign and encrypt test data + testData := []byte("This is a signed and encrypted message!") + encResult, err := svc.SignAndEncryptData(ctx, signerKey.KeyID, []string{recipientKeyID}, testData, "") + if err != nil { + t.Fatalf("SignAndEncryptData() error = %v", err) + } + + // Decrypt the data + decResult, err := svc.DecryptData(ctx, encResult.Ciphertext) + if err != nil { + t.Fatalf("DecryptData() error = %v", err) + } + + // Verify decrypted content matches original + if string(decResult.Plaintext) != string(testData) { + t.Errorf("Decrypted content mismatch:\ngot: %q\nwant: %q", string(decResult.Plaintext), string(testData)) + } + + // Should be signed + if !decResult.WasSigned { + t.Error("Expected WasSigned=true for signed+encrypted message") + } + + // Signature should be valid + if !decResult.SignatureOK { + t.Error("Expected SignatureOK=true for valid signature") + } +} diff --git a/internal/adapters/gpg/service.go b/internal/adapters/gpg/service.go index f8352ed..05cba83 100644 --- a/internal/adapters/gpg/service.go +++ b/internal/adapters/gpg/service.go @@ -46,7 +46,7 @@ func isValidGPGKeyID(keyID string) bool { return false } -// Service provides GPG signing and verification operations. +// Service provides GPG signing, verification, and encryption operations. type Service interface { // CheckGPGAvailable verifies GPG is installed and accessible. CheckGPGAvailable(ctx context.Context) error @@ -68,6 +68,23 @@ type Service interface { // VerifyDetachedSignature verifies a detached signature against data. // Returns verification result including signer info and trust level. VerifyDetachedSignature(ctx context.Context, data []byte, signature []byte) (*VerifyResult, error) + + // ListPublicKeys lists all public keys in the keyring. + ListPublicKeys(ctx context.Context) ([]KeyInfo, error) + + // FindPublicKeyByEmail finds a public key by email, auto-fetching from key servers if not found locally. + FindPublicKeyByEmail(ctx context.Context, email string) (*KeyInfo, error) + + // EncryptData encrypts data for one or more recipients using their public keys. + EncryptData(ctx context.Context, recipientKeyIDs []string, data []byte) (*EncryptResult, error) + + // SignAndEncryptData signs data with the sender's private key and encrypts for recipients. + // This provides maximum security: only recipients can decrypt, and they can verify the sender. + SignAndEncryptData(ctx context.Context, signerKeyID string, recipientKeyIDs []string, data []byte, senderEmail string) (*EncryptResult, error) + + // DecryptData decrypts PGP encrypted data using the user's private key. + // Returns the decrypted plaintext along with optional signature verification info. + DecryptData(ctx context.Context, ciphertext []byte) (*DecryptResult, error) } // service implements Service using the system GPG command. @@ -443,9 +460,27 @@ func parseVerifyOutput(statusOutput, stderrOutput string) *VerifyResult { return result } -// parseSecretKeys parses GPG --with-colons output format. +// parseSecretKeys parses GPG --with-colons output format for secret keys. // Format: https://github.com/CSNW/gnupg/blob/master/doc/DETAILS func parseSecretKeys(output string) ([]KeyInfo, error) { + keys, err := parseKeys(output, "sec") + if err != nil { + return nil, err + } + if len(keys) == 0 { + return nil, fmt.Errorf("no GPG secret keys found. Generate one with: gpg --gen-key") + } + return keys, nil +} + +// parsePublicKeys parses GPG --with-colons output format for public keys. +func parsePublicKeys(output string) ([]KeyInfo, error) { + return parseKeys(output, "pub") +} + +// parseKeys parses GPG --with-colons output format. +// recordPrefix is "sec" for secret keys or "pub" for public keys. +func parseKeys(output string, recordPrefix string) ([]KeyInfo, error) { var keys []KeyInfo var currentKey *KeyInfo @@ -459,7 +494,7 @@ func parseSecretKeys(output string) ([]KeyInfo, error) { recordType := fields[0] switch recordType { - case "sec": // Secret key + case recordPrefix: // Primary key (sec or pub) if currentKey != nil { keys = append(keys, *currentKey) } @@ -499,10 +534,6 @@ func parseSecretKeys(output string) ([]KeyInfo, error) { keys = append(keys, *currentKey) } - if len(keys) == 0 { - return nil, fmt.Errorf("no GPG secret keys found. Generate one with: gpg --gen-key") - } - return keys, nil } diff --git a/internal/adapters/gpg/types.go b/internal/adapters/gpg/types.go index 3b87ac3..ee368fe 100644 --- a/internal/adapters/gpg/types.go +++ b/internal/adapters/gpg/types.go @@ -31,3 +31,19 @@ type VerifyResult struct { TrustLevel string // Trust level (ultimate, full, marginal, unknown, undefined) Fingerprint string // Full fingerprint of signing key } + +// EncryptResult contains the result of an encryption operation. +type EncryptResult struct { + Ciphertext []byte // Encrypted data (ASCII armored) + RecipientKeys []string // Key IDs used for encryption +} + +// DecryptResult contains the result of a decryption operation. +type DecryptResult struct { + Plaintext []byte // Decrypted data + WasSigned bool // True if the message was also signed + SignatureOK bool // True if signature verified successfully (only valid if WasSigned) + SignerKeyID string // Key ID that signed the message (if signed) + SignerUID string // UID of signer (e.g., "Name ") + DecryptKeyID string // Key ID used for decryption +} diff --git a/internal/adapters/mime/builder.go b/internal/adapters/mime/builder.go index 0aa713a..59b1673 100644 --- a/internal/adapters/mime/builder.go +++ b/internal/adapters/mime/builder.go @@ -23,6 +23,25 @@ type Builder interface { // Returns the exact bytes that should be signed with GPG. // This includes the part headers and encoded body with CRLF line endings. PrepareContentToSign(body, contentType string, attachments []domain.Attachment) ([]byte, error) + + // BuildEncryptedMessage builds a PGP/MIME encrypted message (RFC 3156 Section 4). + BuildEncryptedMessage(req *EncryptedMessageRequest) ([]byte, error) + + // PrepareContentToEncrypt prepares the MIME content that will be encrypted. + PrepareContentToEncrypt(body, contentType string, attachments []domain.Attachment) ([]byte, error) +} + +// messageRequest is an interface for shared email header fields. +// Both SignedMessageRequest and EncryptedMessageRequest implement this. +type messageRequest interface { + getFrom() []domain.EmailParticipant + getTo() []domain.EmailParticipant + getCc() []domain.EmailParticipant + getReplyTo() []domain.EmailParticipant + getSubject() string + getHeaders() map[string]string + getMessageID() string + getDate() time.Time } // SignedMessageRequest contains all data needed to build a signed email. @@ -53,6 +72,16 @@ type SignedMessageRequest struct { Date time.Time } +// Implement messageRequest interface for SignedMessageRequest. +func (r *SignedMessageRequest) getFrom() []domain.EmailParticipant { return r.From } +func (r *SignedMessageRequest) getTo() []domain.EmailParticipant { return r.To } +func (r *SignedMessageRequest) getCc() []domain.EmailParticipant { return r.Cc } +func (r *SignedMessageRequest) getReplyTo() []domain.EmailParticipant { return r.ReplyTo } +func (r *SignedMessageRequest) getSubject() string { return r.Subject } +func (r *SignedMessageRequest) getHeaders() map[string]string { return r.Headers } +func (r *SignedMessageRequest) getMessageID() string { return r.MessageID } +func (r *SignedMessageRequest) getDate() time.Time { return r.Date } + // builder implements Builder. type builder struct{} @@ -109,53 +138,57 @@ func (b *builder) BuildSignedMessage(req *SignedMessageRequest) ([]byte, error) return buf.Bytes(), nil } -// writeHeaders writes RFC 822 headers. -func (b *builder) writeHeaders(buf *bytes.Buffer, req *SignedMessageRequest) error { +// writeCommonHeaders writes RFC 822 headers common to all message types. +func writeCommonHeaders(buf *bytes.Buffer, req messageRequest) { // MIME-Version (required) buf.WriteString("MIME-Version: 1.0\r\n") // From - if len(req.From) > 0 { - buf.WriteString("From: " + formatAddresses(req.From) + "\r\n") + if len(req.getFrom()) > 0 { + buf.WriteString("From: " + formatAddresses(req.getFrom()) + "\r\n") } // To - buf.WriteString("To: " + formatAddresses(req.To) + "\r\n") + buf.WriteString("To: " + formatAddresses(req.getTo()) + "\r\n") // Cc - if len(req.Cc) > 0 { - buf.WriteString("Cc: " + formatAddresses(req.Cc) + "\r\n") + if len(req.getCc()) > 0 { + buf.WriteString("Cc: " + formatAddresses(req.getCc()) + "\r\n") } // Bcc (Note: typically not included in headers for security) // Omitting Bcc as per RFC 5322 best practices // Reply-To - if len(req.ReplyTo) > 0 { - buf.WriteString("Reply-To: " + formatAddresses(req.ReplyTo) + "\r\n") + if len(req.getReplyTo()) > 0 { + buf.WriteString("Reply-To: " + formatAddresses(req.getReplyTo()) + "\r\n") } // Subject (encode if contains non-ASCII) - subject := encodeHeader(req.Subject) + subject := encodeHeader(req.getSubject()) buf.WriteString("Subject: " + subject + "\r\n") // Date - date := req.Date + date := req.getDate() if date.IsZero() { date = time.Now() } buf.WriteString("Date: " + date.Format(time.RFC1123Z) + "\r\n") // Message-ID - if req.MessageID != "" { - buf.WriteString("Message-ID: <" + req.MessageID + ">\r\n") + if req.getMessageID() != "" { + buf.WriteString("Message-ID: <" + req.getMessageID() + ">\r\n") } // Custom headers - for key, value := range req.Headers { + for key, value := range req.getHeaders() { buf.WriteString(key + ": " + value + "\r\n") } +} +// writeHeaders writes RFC 822 headers for signed messages. +func (b *builder) writeHeaders(buf *bytes.Buffer, req *SignedMessageRequest) error { + writeCommonHeaders(buf, req) return nil } @@ -260,14 +293,22 @@ func (b *builder) writeAttachmentPart(buf *bytes.Buffer, att *domain.Attachment) return nil } -// validateSignedRequest validates the signed message request. -func validateSignedRequest(req *SignedMessageRequest) error { - if len(req.To) == 0 { +// validateBaseRequest validates the common fields of a message request. +func validateBaseRequest(req messageRequest) error { + if len(req.getTo()) == 0 { return fmt.Errorf("recipient (To) is required") } - if req.Subject == "" { + if req.getSubject() == "" { return fmt.Errorf("subject is required") } + return nil +} + +// validateSignedRequest validates the signed message request. +func validateSignedRequest(req *SignedMessageRequest) error { + if err := validateBaseRequest(req); err != nil { + return err + } if req.Body == "" { return fmt.Errorf("body is required") } diff --git a/internal/adapters/mime/encrypted.go b/internal/adapters/mime/encrypted.go new file mode 100644 index 0000000..8e249c9 --- /dev/null +++ b/internal/adapters/mime/encrypted.go @@ -0,0 +1,123 @@ +package mime + +import ( + "bytes" + "fmt" + "time" + + "github.com/nylas/cli/internal/domain" +) + +// EncryptedMessageRequest contains all data needed to build an encrypted email. +type EncryptedMessageRequest struct { + // Standard email fields + From []domain.EmailParticipant + To []domain.EmailParticipant + Cc []domain.EmailParticipant + Bcc []domain.EmailParticipant + ReplyTo []domain.EmailParticipant + Subject string + + // Encrypted content from GPG (ASCII armored PGP message) + Ciphertext []byte + + // Optional + Headers map[string]string + MessageID string + Date time.Time +} + +// Implement messageRequest interface for EncryptedMessageRequest. +func (r *EncryptedMessageRequest) getFrom() []domain.EmailParticipant { return r.From } +func (r *EncryptedMessageRequest) getTo() []domain.EmailParticipant { return r.To } +func (r *EncryptedMessageRequest) getCc() []domain.EmailParticipant { return r.Cc } +func (r *EncryptedMessageRequest) getReplyTo() []domain.EmailParticipant { return r.ReplyTo } +func (r *EncryptedMessageRequest) getSubject() string { return r.Subject } +func (r *EncryptedMessageRequest) getHeaders() map[string]string { return r.Headers } +func (r *EncryptedMessageRequest) getMessageID() string { return r.MessageID } +func (r *EncryptedMessageRequest) getDate() time.Time { return r.Date } + +// BuildEncryptedMessage constructs a PGP/MIME encrypted message per RFC 3156 Section 4. +// Structure: +// +// MIME-Version: 1.0 +// Content-Type: multipart/encrypted; +// protocol="application/pgp-encrypted"; +// boundary="..." +// +// --boundary +// Content-Type: application/pgp-encrypted +// +// Version: 1 +// +// --boundary +// Content-Type: application/octet-stream +// +// -----BEGIN PGP MESSAGE----- +// [Encrypted content] +// -----END PGP MESSAGE----- +// --boundary-- +func (b *builder) BuildEncryptedMessage(req *EncryptedMessageRequest) ([]byte, error) { + if err := validateEncryptedRequest(req); err != nil { + return nil, err + } + + var buf bytes.Buffer + + // Write top-level headers + if err := b.writeEncryptedHeaders(&buf, req); err != nil { + return nil, err + } + + // Create multipart/encrypted boundary + encryptedBoundary := generateBoundary("encrypted") + + // Write Content-Type for multipart/encrypted (RFC 3156 Section 4) + buf.WriteString("Content-Type: multipart/encrypted;\r\n") + buf.WriteString("\tprotocol=\"application/pgp-encrypted\";\r\n") + buf.WriteString(fmt.Sprintf("\tboundary=\"%s\"\r\n", encryptedBoundary)) + buf.WriteString("\r\n") + + // Part 1: Version identification (required by RFC 3156) + buf.WriteString("--" + encryptedBoundary + "\r\n") + buf.WriteString("Content-Type: application/pgp-encrypted\r\n") + buf.WriteString("Content-Description: PGP/MIME version identification\r\n") + buf.WriteString("\r\n") + buf.WriteString("Version: 1\r\n") + + // Part 2: Encrypted data + buf.WriteString("\r\n--" + encryptedBoundary + "\r\n") + buf.WriteString("Content-Type: application/octet-stream; name=\"encrypted.asc\"\r\n") + buf.WriteString("Content-Description: OpenPGP encrypted message\r\n") + buf.WriteString("Content-Disposition: inline; filename=\"encrypted.asc\"\r\n") + buf.WriteString("\r\n") + buf.Write(req.Ciphertext) + buf.WriteString("\r\n--" + encryptedBoundary + "--\r\n") + + return buf.Bytes(), nil +} + +// writeEncryptedHeaders writes RFC 822 headers for encrypted messages. +func (b *builder) writeEncryptedHeaders(buf *bytes.Buffer, req *EncryptedMessageRequest) error { + writeCommonHeaders(buf, req) + return nil +} + +// validateEncryptedRequest validates the encrypted message request. +func validateEncryptedRequest(req *EncryptedMessageRequest) error { + if err := validateBaseRequest(req); err != nil { + return err + } + if len(req.Ciphertext) == 0 { + return fmt.Errorf("ciphertext is required") + } + return nil +} + +// PrepareContentToEncrypt prepares the MIME content that will be encrypted. +// This builds a complete MIME body (with attachments if any) that gets encrypted as a whole. +func (b *builder) PrepareContentToEncrypt(body, contentType string, attachments []domain.Attachment) ([]byte, error) { + // Reuse the PrepareContentToSign logic since the content structure is the same + // The only difference is what we do with the result (encrypt vs sign) + return b.PrepareContentToSign(body, contentType, attachments) +} diff --git a/internal/adapters/mime/encrypted_test.go b/internal/adapters/mime/encrypted_test.go new file mode 100644 index 0000000..3b2e1f5 --- /dev/null +++ b/internal/adapters/mime/encrypted_test.go @@ -0,0 +1,331 @@ +package mime + +import ( + "strings" + "testing" + "time" + + "github.com/nylas/cli/internal/domain" +) + +func TestBuildEncryptedMessage_Simple(t *testing.T) { + builder := NewBuilder() + + req := &EncryptedMessageRequest{ + From: []domain.EmailParticipant{ + {Name: "Alice", Email: "alice@example.com"}, + }, + To: []domain.EmailParticipant{ + {Name: "Bob", Email: "bob@example.com"}, + }, + Subject: "Encrypted Test", + Ciphertext: []byte("-----BEGIN PGP MESSAGE-----\nencrypted content\n-----END PGP MESSAGE-----"), + Date: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), + } + + result, err := builder.BuildEncryptedMessage(req) + if err != nil { + t.Fatalf("BuildEncryptedMessage() error = %v", err) + } + + resultStr := string(result) + + // Validate RFC 3156 multipart/encrypted structure + requiredParts := []string{ + "MIME-Version: 1.0", + "From: Alice ", + "To: Bob ", + "Subject: Encrypted Test", + "Content-Type: multipart/encrypted", + "protocol=\"application/pgp-encrypted\"", + "application/pgp-encrypted", // Version part content-type + "Version: 1", // Version identifier + "application/octet-stream", // Encrypted data content-type + "-----BEGIN PGP MESSAGE-----", // Encrypted content marker + "-----END PGP MESSAGE-----", // Encrypted content marker + } + + for _, part := range requiredParts { + if !strings.Contains(resultStr, part) { + t.Errorf("Missing required part: %s", part) + } + } +} + +func TestBuildEncryptedMessage_WithCcAndReplyTo(t *testing.T) { + builder := NewBuilder() + + req := &EncryptedMessageRequest{ + From: []domain.EmailParticipant{ + {Email: "sender@example.com"}, + }, + To: []domain.EmailParticipant{ + {Email: "to@example.com"}, + }, + Cc: []domain.EmailParticipant{ + {Email: "cc@example.com"}, + }, + ReplyTo: []domain.EmailParticipant{ + {Email: "replyto@example.com"}, + }, + Subject: "Test Cc/ReplyTo", + Ciphertext: []byte("-----BEGIN PGP MESSAGE-----\ntest\n-----END PGP MESSAGE-----"), + } + + result, err := builder.BuildEncryptedMessage(req) + if err != nil { + t.Fatalf("BuildEncryptedMessage() error = %v", err) + } + + resultStr := string(result) + + // Validate Cc header is present + if !strings.Contains(resultStr, "Cc: cc@example.com") { + t.Error("Cc header not found") + } + + // Validate Reply-To header is present + if !strings.Contains(resultStr, "Reply-To: replyto@example.com") { + t.Error("Reply-To header not found") + } +} + +func TestBuildEncryptedMessage_Validation(t *testing.T) { + builder := NewBuilder() + + tests := []struct { + name string + req *EncryptedMessageRequest + wantErr string + }{ + { + name: "missing To", + req: &EncryptedMessageRequest{ + Subject: "Test", + Ciphertext: []byte("encrypted"), + }, + wantErr: "recipient (To) is required", + }, + { + name: "missing Subject", + req: &EncryptedMessageRequest{ + To: []domain.EmailParticipant{ + {Email: "test@example.com"}, + }, + Ciphertext: []byte("encrypted"), + }, + wantErr: "subject is required", + }, + { + name: "missing Ciphertext", + req: &EncryptedMessageRequest{ + To: []domain.EmailParticipant{ + {Email: "test@example.com"}, + }, + Subject: "Test", + }, + wantErr: "ciphertext is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := builder.BuildEncryptedMessage(tt.req) + if err == nil { + t.Error("Expected error but got nil") + return + } + if !strings.Contains(err.Error(), tt.wantErr) { + t.Errorf("Expected error containing %q, got %q", tt.wantErr, err.Error()) + } + }) + } +} + +func TestBuildEncryptedMessage_CustomHeaders(t *testing.T) { + builder := NewBuilder() + + req := &EncryptedMessageRequest{ + From: []domain.EmailParticipant{ + {Email: "sender@example.com"}, + }, + To: []domain.EmailParticipant{ + {Email: "recipient@example.com"}, + }, + Subject: "Custom Headers Test", + Ciphertext: []byte("-----BEGIN PGP MESSAGE-----\ntest\n-----END PGP MESSAGE-----"), + Headers: map[string]string{ + "X-Custom-Header": "custom-value", + "X-Priority": "high", + }, + MessageID: "custom-message-id@example.com", + } + + result, err := builder.BuildEncryptedMessage(req) + if err != nil { + t.Fatalf("BuildEncryptedMessage() error = %v", err) + } + + resultStr := string(result) + + // Validate custom headers + if !strings.Contains(resultStr, "X-Custom-Header: custom-value") { + t.Error("Custom header not found") + } + if !strings.Contains(resultStr, "X-Priority: high") { + t.Error("X-Priority header not found") + } + if !strings.Contains(resultStr, "Message-ID: ") { + t.Error("Message-ID header not found") + } +} + +func TestBuildEncryptedMessage_MIMEStructure(t *testing.T) { + builder := NewBuilder() + + ciphertext := `-----BEGIN PGP MESSAGE----- + +hQEMA8nJ...encrypted content... +=abcd +-----END PGP MESSAGE-----` + + req := &EncryptedMessageRequest{ + From: []domain.EmailParticipant{ + {Email: "sender@example.com"}, + }, + To: []domain.EmailParticipant{ + {Email: "recipient@example.com"}, + }, + Subject: "MIME Structure Test", + Ciphertext: []byte(ciphertext), + } + + result, err := builder.BuildEncryptedMessage(req) + if err != nil { + t.Fatalf("BuildEncryptedMessage() error = %v", err) + } + + resultStr := string(result) + + // Find the boundary + if !strings.Contains(resultStr, "boundary=") { + t.Fatal("Missing boundary in Content-Type") + } + + // Validate two-part structure (RFC 3156 Section 4) + // Part 1: application/pgp-encrypted with "Version: 1" + // Part 2: application/octet-stream with the actual encrypted data + + // Count boundary occurrences (should be 3: start of part 1, start of part 2, end) + boundaryCount := strings.Count(resultStr, "--=_encrypted_") + if boundaryCount < 3 { + t.Errorf("Expected at least 3 boundary markers (2 parts + end), got %d", boundaryCount) + } + + // Verify part 1 comes before part 2 + versionPos := strings.Index(resultStr, "Version: 1") + ciphertextPos := strings.Index(resultStr, "-----BEGIN PGP MESSAGE-----") + + if versionPos == -1 { + t.Error("Version: 1 not found in output") + } + if ciphertextPos == -1 { + t.Error("PGP MESSAGE not found in output") + } + if versionPos > ciphertextPos { + t.Error("Version part should come before encrypted data part") + } +} + +func TestPrepareContentToEncrypt(t *testing.T) { + builder := NewBuilder() + + // Test simple body + content, err := builder.PrepareContentToEncrypt("Hello, World!", "text/plain", nil) + if err != nil { + t.Fatalf("PrepareContentToEncrypt() error = %v", err) + } + + contentStr := string(content) + + // Should have proper content headers + if !strings.Contains(contentStr, "Content-Type: text/plain") { + t.Error("Missing Content-Type header") + } + if !strings.Contains(contentStr, "Content-Transfer-Encoding: quoted-printable") { + t.Error("Missing Content-Transfer-Encoding header") + } + + // Body should be encoded + if !strings.Contains(contentStr, "Hello") { + t.Error("Body content not found") + } +} + +func TestPrepareContentToEncrypt_WithAttachments(t *testing.T) { + builder := NewBuilder() + + attachments := []domain.Attachment{ + { + Filename: "secret.txt", + ContentType: "text/plain", + Content: []byte("Secret content"), + }, + } + + content, err := builder.PrepareContentToEncrypt("Email body", "text/plain", attachments) + if err != nil { + t.Fatalf("PrepareContentToEncrypt() error = %v", err) + } + + contentStr := string(content) + + // Should be multipart/mixed + if !strings.Contains(contentStr, "multipart/mixed") { + t.Error("Expected multipart/mixed for email with attachments") + } + + // Should include attachment + if !strings.Contains(contentStr, "secret.txt") { + t.Error("Attachment filename not found") + } +} + +func TestEncryptedMessageRequest_NonASCIISubject(t *testing.T) { + builder := NewBuilder() + + req := &EncryptedMessageRequest{ + From: []domain.EmailParticipant{ + {Email: "sender@example.com"}, + }, + To: []domain.EmailParticipant{ + {Email: "recipient@example.com"}, + }, + Subject: "Test с кириллицей 日本語", + Ciphertext: []byte("-----BEGIN PGP MESSAGE-----\ntest\n-----END PGP MESSAGE-----"), + } + + result, err := builder.BuildEncryptedMessage(req) + if err != nil { + t.Fatalf("BuildEncryptedMessage() error = %v", err) + } + + resultStr := string(result) + + // Non-ASCII subject should be RFC 2047 encoded + // Should contain "=?" which indicates encoded word + if !strings.Contains(resultStr, "Subject:") { + t.Error("Subject header not found") + } + + // Verify the subject is encoded (RFC 2047) + lines := strings.Split(resultStr, "\r\n") + for _, line := range lines { + if strings.HasPrefix(line, "Subject:") { + if !strings.Contains(line, "=?utf-8?") { + t.Errorf("Non-ASCII subject should be RFC 2047 encoded: %s", line) + } + break + } + } +} diff --git a/internal/cli/email/read.go b/internal/cli/email/read.go index c93fe6a..563cab6 100644 --- a/internal/cli/email/read.go +++ b/internal/cli/email/read.go @@ -19,13 +19,18 @@ func newReadCmd() *cobra.Command { var mimeOutput bool var headersOutput bool var verifySignature bool + var decryptMessage bool cmd := &cobra.Command{ Use: "read [grant-id]", Aliases: []string{"show"}, Short: "Read a specific email", - Long: "Read and display the full content of a specific email message.", - Args: cobra.RangeArgs(1, 2), + Long: `Read and display the full content of a specific email message. + +Supports GPG/PGP encrypted and signed messages: +- --decrypt: Decrypt PGP/MIME encrypted emails +- --verify: Verify GPG/PGP signature of signed emails`, + Args: cobra.RangeArgs(1, 2), RunE: func(cmd *cobra.Command, args []string) error { messageID := args[0] remainingArgs := args[1:] @@ -34,8 +39,8 @@ func newReadCmd() *cobra.Command { // Determine which fields to request var fields string switch { - case mimeOutput, verifySignature: - // Both --mime and --verify need raw MIME data + case mimeOutput, verifySignature, decryptMessage: + // --mime, --verify, and --decrypt need raw MIME data fields = "raw_mime" case headersOutput: fields = "include_headers" @@ -64,6 +69,24 @@ func newReadCmd() *cobra.Command { return struct{}{}, nil } + // Handle --decrypt flag + if decryptMessage { + // Fetch full message for header display + fullMsg, err := client.GetMessage(ctx, grantID, messageID) + if err == nil { + printMessageHeaders(*fullMsg) + } + // Decrypt the message + result, err := decryptGPGEmail(ctx, msg) + if err != nil { + return struct{}{}, fmt.Errorf("GPG decryption failed: %w", err) + } + // Display decryption result (signature info only if --verify also passed) + printDecryptResult(result, verifySignature) + printDecryptedContent(result.Plaintext) + return struct{}{}, nil + } + // Handle --verify flag if verifySignature { // Fetch full message for display (raw_mime request returns minimal fields) @@ -116,6 +139,7 @@ func newReadCmd() *cobra.Command { cmd.Flags().BoolVar(&mimeOutput, "mime", false, "Show raw RFC822/MIME message format") cmd.Flags().BoolVar(&headersOutput, "headers", false, "Show email headers (works with all providers)") cmd.Flags().BoolVar(&verifySignature, "verify", false, "Verify GPG/PGP signature of the message") + cmd.Flags().BoolVar(&decryptMessage, "decrypt", false, "Decrypt PGP/MIME encrypted message") return cmd } diff --git a/internal/cli/email/read_decrypt.go b/internal/cli/email/read_decrypt.go new file mode 100644 index 0000000..b2889f4 --- /dev/null +++ b/internal/cli/email/read_decrypt.go @@ -0,0 +1,234 @@ +package email + +import ( + "bytes" + "context" + "fmt" + "io" + "mime" + "mime/multipart" + "strings" + + "github.com/nylas/cli/internal/adapters/gpg" + "github.com/nylas/cli/internal/cli/common" + "github.com/nylas/cli/internal/domain" +) + +// decryptGPGEmail decrypts a PGP/MIME encrypted message. +func decryptGPGEmail(ctx context.Context, msg *domain.Message) (*gpg.DecryptResult, error) { + if msg.RawMIME == "" { + return nil, fmt.Errorf("no raw MIME data available for decryption") + } + + // Check if this is an encrypted message + contentType := extractFullContentType(msg.RawMIME) + if !isEncryptedMessage(contentType) { + return nil, fmt.Errorf("message is not PGP/MIME encrypted (Content-Type: %s)", contentType) + } + + // Parse the multipart message to extract encrypted content + ciphertext, err := parseEncryptedMIME(msg.RawMIME) + if err != nil { + return nil, fmt.Errorf("failed to parse PGP/MIME encrypted message: %w", err) + } + + // Initialize GPG service + gpgSvc := gpg.NewService() + if err := gpgSvc.CheckGPGAvailable(ctx); err != nil { + return nil, err + } + + // Decrypt the message + spinner := common.NewSpinner("Decrypting message...") + spinner.Start() + result, err := gpgSvc.DecryptData(ctx, ciphertext) + spinner.Stop() + + if err != nil { + return nil, err + } + + return result, nil +} + +// isEncryptedMessage checks if the content type indicates a PGP/MIME encrypted message. +func isEncryptedMessage(contentType string) bool { + return strings.Contains(contentType, "multipart/encrypted") && + strings.Contains(contentType, "application/pgp-encrypted") +} + +// parseEncryptedMIME parses a PGP/MIME encrypted message and extracts the ciphertext. +// RFC 3156 Section 4 defines the structure: +// Part 1: application/pgp-encrypted with "Version: 1" +// Part 2: application/octet-stream with the actual encrypted data +func parseEncryptedMIME(rawMIME string) ([]byte, error) { + // Find the Content-Type header to get the boundary + fullContentType := extractFullContentType(rawMIME) + if fullContentType == "" { + return nil, fmt.Errorf("Content-Type header not found") + } + + // Parse the Content-Type to extract boundary + _, params, err := mime.ParseMediaType(fullContentType) + if err != nil { + return nil, fmt.Errorf("failed to parse Content-Type '%s': %w", fullContentType, err) + } + + boundary := params["boundary"] + if boundary == "" { + return nil, fmt.Errorf("no boundary found in Content-Type") + } + + // Find the body section (after headers) for multipart parsing + headerEnd := findHeaderEnd(rawMIME) + if headerEnd == -1 { + return nil, fmt.Errorf("could not find end of headers") + } + + // Create a reader for the multipart body + bodySection := rawMIME[headerEnd:] + mr := multipart.NewReader(strings.NewReader(bodySection), boundary) + + var ciphertext []byte + partNum := 0 + + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + return nil, fmt.Errorf("failed to read MIME part: %w", err) + } + + partNum++ + + // Part 1: Version identification (application/pgp-encrypted) + // Part 2: Encrypted data (application/octet-stream) + if partNum == 2 { + // This is the encrypted data part + partContent, err := io.ReadAll(part) + if err != nil { + return nil, fmt.Errorf("failed to read encrypted part: %w", err) + } + ciphertext = partContent + } + } + + if ciphertext == nil { + return nil, fmt.Errorf("could not find encrypted content part") + } + + // Trim any surrounding whitespace + ciphertext = bytes.TrimSpace(ciphertext) + + return ciphertext, nil +} + +// printDecryptResult displays the decryption result. +// If showSignature is true and the message was signed, signature verification info is displayed. +func printDecryptResult(result *gpg.DecryptResult, showSignature bool) { + fmt.Println() + fmt.Println(strings.Repeat("─", 60)) + _, _ = common.Green.Println("✓ Message decrypted successfully") + fmt.Println(strings.Repeat("─", 60)) + + if result.DecryptKeyID != "" { + fmt.Printf(" %s %s\n", common.Cyan.Sprint("Decrypted with:"), result.DecryptKeyID) + } + + // Display signature info only if --verify was also passed + if showSignature && result.WasSigned { + fmt.Println() + if result.SignatureOK { + _, _ = common.Green.Println(" ✓ Signature verified") + } else { + _, _ = common.Red.Println(" ✗ BAD signature!") + } + + if result.SignerUID != "" { + fmt.Printf(" %s %s\n", common.Cyan.Sprint("Signer:"), result.SignerUID) + } + if result.SignerKeyID != "" { + fmt.Printf(" %s %s\n", common.Cyan.Sprint("Key ID:"), result.SignerKeyID) + } + } + + fmt.Println() +} + +// printDecryptedContent displays the decrypted message content. +func printDecryptedContent(plaintext []byte) { + content := string(plaintext) + + // Check if the decrypted content is a MIME message itself + if strings.Contains(content, "Content-Type:") { + // Parse and extract the body from the MIME content + body := extractBodyFromMIME(content) + if body != "" { + content = body + } + } + + fmt.Println(strings.Repeat("─", 60)) + _, _ = common.Cyan.Println("Decrypted Content:") + fmt.Println(strings.Repeat("─", 60)) + fmt.Println() + fmt.Println(content) +} + +// extractBodyFromMIME extracts the body text from a MIME message. +// This handles the case where decrypted content is itself a MIME structure. +func extractBodyFromMIME(mimeContent string) string { + // Find the Content-Type to determine if it's multipart + contentType := extractFullContentType(mimeContent) + + // If it's a simple text message, extract after headers + if strings.HasPrefix(contentType, "text/plain") || strings.HasPrefix(contentType, "text/html") { + headerEnd := findHeaderEnd(mimeContent) + if headerEnd != -1 && headerEnd < len(mimeContent) { + return strings.TrimSpace(mimeContent[headerEnd:]) + } + } + + // If multipart/mixed or multipart/alternative, extract the text part + if strings.Contains(contentType, "multipart/") { + _, params, err := mime.ParseMediaType(contentType) + if err != nil { + return mimeContent + } + + boundary := params["boundary"] + if boundary == "" { + return mimeContent + } + + headerEnd := findHeaderEnd(mimeContent) + if headerEnd == -1 { + return mimeContent + } + + bodySection := mimeContent[headerEnd:] + mr := multipart.NewReader(strings.NewReader(bodySection), boundary) + + for { + part, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + return mimeContent + } + + partContentType := part.Header.Get("Content-Type") + if strings.HasPrefix(partContentType, "text/plain") || strings.HasPrefix(partContentType, "text/html") { + partContent, err := io.ReadAll(part) + if err == nil { + return strings.TrimSpace(string(partContent)) + } + } + } + } + + return mimeContent +} diff --git a/internal/cli/email/read_decrypt_test.go b/internal/cli/email/read_decrypt_test.go new file mode 100644 index 0000000..f90d229 --- /dev/null +++ b/internal/cli/email/read_decrypt_test.go @@ -0,0 +1,362 @@ +package email + +import ( + "strings" + "testing" +) + +func TestIsEncryptedMessage(t *testing.T) { + tests := []struct { + name string + contentType string + want bool + }{ + { + name: "valid PGP/MIME encrypted", + contentType: `multipart/encrypted; protocol="application/pgp-encrypted"; boundary="xyz"`, + want: true, + }, + { + name: "valid with different order", + contentType: `multipart/encrypted; boundary="abc"; protocol="application/pgp-encrypted"`, + want: true, + }, + { + name: "multipart/signed not encrypted", + contentType: `multipart/signed; protocol="application/pgp-signature"; boundary="xyz"`, + want: false, + }, + { + name: "plain text", + contentType: "text/plain; charset=utf-8", + want: false, + }, + { + name: "multipart/encrypted without pgp protocol", + contentType: `multipart/encrypted; protocol="application/x-pkcs7-mime"; boundary="xyz"`, + want: false, + }, + { + name: "empty content type", + contentType: "", + want: false, + }, + { + name: "only multipart/encrypted", + contentType: "multipart/encrypted", + want: false, + }, + { + name: "only application/pgp-encrypted", + contentType: "application/pgp-encrypted", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isEncryptedMessage(tt.contentType) + if got != tt.want { + t.Errorf("isEncryptedMessage(%q) = %v, want %v", tt.contentType, got, tt.want) + } + }) + } +} + +func TestParseEncryptedMIME(t *testing.T) { + tests := []struct { + name string + rawMIME string + wantContains string + wantErr bool + wantErrContain string + }{ + { + name: "valid PGP/MIME encrypted message", + rawMIME: `Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; + boundary="encrypted_boundary" + +--encrypted_boundary +Content-Type: application/pgp-encrypted + +Version: 1 + +--encrypted_boundary +Content-Type: application/octet-stream + +-----BEGIN PGP MESSAGE----- + +hQEMA...encrypted...data +-----END PGP MESSAGE----- +--encrypted_boundary--`, + wantContains: "-----BEGIN PGP MESSAGE-----", + wantErr: false, + }, + { + name: "encrypted message with CRLF", + rawMIME: "Content-Type: multipart/encrypted; protocol=\"application/pgp-encrypted\";\r\n\tboundary=\"enc123\"\r\n\r\n--enc123\r\nContent-Type: application/pgp-encrypted\r\n\r\nVersion: 1\r\n\r\n--enc123\r\nContent-Type: application/octet-stream\r\n\r\n-----BEGIN PGP MESSAGE-----\r\nencrypted\r\n-----END PGP MESSAGE-----\r\n--enc123--", + wantContains: "-----BEGIN PGP MESSAGE-----", + wantErr: false, + }, + { + name: "missing Content-Type header", + rawMIME: "From: test@example.com\n\nBody", + wantErr: true, + wantErrContain: "Content-Type header not found", + }, + { + name: "missing boundary", + rawMIME: "Content-Type: multipart/encrypted\n\nBody", + wantErr: true, + wantErrContain: "no boundary found", + }, + { + name: "no header/body separator", + rawMIME: "Content-Type: multipart/encrypted; boundary=\"xyz\"", + wantErr: true, + wantErrContain: "could not find end of headers", + }, + { + name: "only one part (missing encrypted data)", + rawMIME: `Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; boundary="enc" + +--enc +Content-Type: application/pgp-encrypted + +Version: 1 +--enc--`, + wantErr: true, + wantErrContain: "could not find encrypted content part", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseEncryptedMIME(tt.rawMIME) + if tt.wantErr { + if err == nil { + t.Errorf("parseEncryptedMIME() expected error containing %q, got nil", tt.wantErrContain) + return + } + if !strings.Contains(err.Error(), tt.wantErrContain) { + t.Errorf("parseEncryptedMIME() error = %q, want error containing %q", err.Error(), tt.wantErrContain) + } + return + } + if err != nil { + t.Errorf("parseEncryptedMIME() unexpected error: %v", err) + return + } + if !strings.Contains(string(got), tt.wantContains) { + t.Errorf("parseEncryptedMIME() result doesn't contain %q, got: %q", tt.wantContains, string(got)) + } + }) + } +} + +func TestExtractBodyFromMIME(t *testing.T) { + tests := []struct { + name string + mimeContent string + want string + }{ + { + name: "simple text/plain", + mimeContent: `Content-Type: text/plain; charset=utf-8 + +Hello World`, + want: "Hello World", + }, + { + name: "text/html", + mimeContent: `Content-Type: text/html; charset=utf-8 + +

Hello World

`, + want: "

Hello World

", + }, + { + name: "multipart/alternative with text part", + mimeContent: `Content-Type: multipart/alternative; boundary="alt" + +--alt +Content-Type: text/plain + +Plain text version +--alt +Content-Type: text/html + +

HTML version

+--alt--`, + want: "Plain text version", + }, + { + name: "multipart/mixed with text part", + mimeContent: `Content-Type: multipart/mixed; boundary="mixed" + +--mixed +Content-Type: text/plain + +Message body here +--mixed +Content-Type: application/pdf +Content-Disposition: attachment; filename="doc.pdf" + +PDF content +--mixed--`, + want: "Message body here", + }, + { + name: "no Content-Type returns original", + mimeContent: "Just plain content without headers", + want: "Just plain content without headers", + }, + { + name: "unknown content type returns original", + mimeContent: `Content-Type: application/octet-stream + +Binary data here`, + want: `Content-Type: application/octet-stream + +Binary data here`, + }, + { + name: "text/plain with extra whitespace", + mimeContent: `Content-Type: text/plain + + Trimmed content `, + want: "Trimmed content", + }, + { + name: "multipart with CRLF line endings", + mimeContent: "Content-Type: multipart/alternative; boundary=\"b\"\r\n\r\n--b\r\nContent-Type: text/plain\r\n\r\nCRLF content\r\n--b--", + want: "CRLF content", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := extractBodyFromMIME(tt.mimeContent) + if got != tt.want { + t.Errorf("extractBodyFromMIME() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestParseEncryptedMIME_ExtractsCiphertext(t *testing.T) { + // Test that we correctly extract just the ciphertext, trimmed + rawMIME := `Content-Type: multipart/encrypted; protocol="application/pgp-encrypted"; boundary="enc" + +--enc +Content-Type: application/pgp-encrypted + +Version: 1 + +--enc +Content-Type: application/octet-stream + +-----BEGIN PGP MESSAGE----- + +hQEMAxxxxxxxxx +=xxxx +-----END PGP MESSAGE----- + +--enc--` + + ciphertext, err := parseEncryptedMIME(rawMIME) + if err != nil { + t.Fatalf("parseEncryptedMIME() error: %v", err) + } + + // Should start with PGP header + if !strings.HasPrefix(string(ciphertext), "-----BEGIN PGP MESSAGE-----") { + t.Errorf("Ciphertext should start with PGP header, got: %q", string(ciphertext)[:50]) + } + + // Should end with PGP footer + if !strings.HasSuffix(string(ciphertext), "-----END PGP MESSAGE-----") { + t.Errorf("Ciphertext should end with PGP footer, got: %q", string(ciphertext)[len(ciphertext)-50:]) + } + + // Should not have leading/trailing whitespace + if strings.HasPrefix(string(ciphertext), " ") || strings.HasPrefix(string(ciphertext), "\n") { + t.Error("Ciphertext has leading whitespace") + } + if strings.HasSuffix(string(ciphertext), " ") || strings.HasSuffix(string(ciphertext), "\n") { + t.Error("Ciphertext has trailing whitespace") + } +} + +func TestExtractBodyFromMIME_NestedMultipart(t *testing.T) { + // Test handling of nested multipart (common in email) + mimeContent := `Content-Type: multipart/mixed; boundary="outer" + +--outer +Content-Type: text/plain + +This is the main message body. +--outer +Content-Type: application/pdf + +PDF attachment data +--outer--` + + got := extractBodyFromMIME(mimeContent) + want := "This is the main message body." + + if got != want { + t.Errorf("extractBodyFromMIME() = %q, want %q", got, want) + } +} + +func TestExtractBodyFromMIME_PreferPlainText(t *testing.T) { + // When multipart/alternative has both text/plain and text/html, + // we should get the first text part (text/plain) + mimeContent := `Content-Type: multipart/alternative; boundary="alt" + +--alt +Content-Type: text/plain + +Plain text preferred +--alt +Content-Type: text/html + +

HTML version

+--alt--` + + got := extractBodyFromMIME(mimeContent) + want := "Plain text preferred" + + if got != want { + t.Errorf("extractBodyFromMIME() = %q, want %q", got, want) + } +} + +func TestIsEncryptedMessage_CaseInsensitive(t *testing.T) { + // Content-Type values should work regardless of case + tests := []struct { + name string + contentType string + want bool + }{ + { + name: "lowercase", + contentType: `multipart/encrypted; protocol="application/pgp-encrypted"`, + want: true, + }, + { + name: "uppercase MULTIPART", + contentType: `MULTIPART/ENCRYPTED; protocol="application/pgp-encrypted"`, + want: false, // strings.Contains is case-sensitive + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isEncryptedMessage(tt.contentType) + if got != tt.want { + t.Errorf("isEncryptedMessage(%q) = %v, want %v", tt.contentType, got, tt.want) + } + }) + } +} diff --git a/internal/cli/email/send.go b/internal/cli/email/send.go index 3644107..7ae5135 100644 --- a/internal/cli/email/send.go +++ b/internal/cli/email/send.go @@ -35,6 +35,8 @@ func newSendCmd() *cobra.Command { var sign bool var gpgKeyID string var listGPGKeys bool + var encrypt bool + var recipientKey string cmd := &cobra.Command{ Use: "send [grant-id]", @@ -46,6 +48,11 @@ Supports GPG/PGP email signing: - --gpg-key : Sign with a specific GPG key - --list-gpg-keys: List available GPG signing keys +Supports GPG/PGP email encryption: +- --encrypt: Encrypt email with recipient's GPG public key (auto-fetched if needed) +- --recipient-key : Use specific GPG key for encryption +- --sign --encrypt: Sign AND encrypt for maximum security + Supports scheduled sending with the --schedule flag. You can specify: - Duration: "30m", "2h", "1d" (minutes, hours, days from now) - Time: "14:30" or "2:30pm" (today or tomorrow if past) @@ -68,6 +75,15 @@ Supports custom metadata: # Send with specific GPG key nylas email send --to user@example.com --subject "Secure" --body "Signed" --sign --gpg-key 601FEE9B1D60185F + # Encrypt email (auto-fetches recipient's public key) + nylas email send --to bob@example.com --subject "Confidential" --body "Secret message" --encrypt + + # Encrypt with specific recipient key + nylas email send --to bob@example.com --subject "Confidential" --body "Secret" --encrypt --recipient-key ABCD1234 + + # Sign AND encrypt (maximum security) + nylas email send --to bob@example.com --subject "Top Secret" --body "Secret message" --sign --encrypt + # List available GPG keys nylas email send --list-gpg-keys @@ -252,6 +268,15 @@ Supports custom metadata: } fmt.Printf(" %s %s\n", common.Green.Sprint("GPG Signed:"), signingInfo) } + if encrypt { + var encryptInfo string + if recipientKey != "" { + encryptInfo = fmt.Sprintf("with key %s", recipientKey) + } else { + encryptInfo = fmt.Sprintf("for %s (auto-fetch)", strings.Join(to, ", ")) + } + fmt.Printf(" %s %s\n", common.Blue.Sprint("GPG Encrypted:"), encryptInfo) + } if !noConfirm { if scheduledTime.IsZero() { @@ -277,7 +302,7 @@ Supports custom metadata: // Get grant info to determine provider and email grant, grantErr := client.GetGrant(ctx, grantID) - if sign { + if sign || encrypt { if grantErr == nil && grant != nil && grant.Email != "" { // Populate From field with grant's email address req.From = []domain.EmailParticipant{ @@ -285,8 +310,8 @@ Supports custom metadata: } } - // GPG signing flow - msg, err = sendSignedEmail(ctx, client, grantID, req, gpgKeyID, toContacts, subject, body) + // GPG signing and/or encryption flow + msg, err = sendSecureEmail(ctx, client, grantID, req, gpgKeyID, recipientKey, toContacts, subject, body, sign, encrypt) } else { // Standard flow var sendMsg string @@ -332,7 +357,11 @@ Supports custom metadata: printSuccess("Email scheduled successfully! Message ID: %s", msg.ID) fmt.Printf("Scheduled to send: %s\n", scheduledTime.Format(common.DisplayWeekdayFullWithTZ)) } else { - if sign { + if sign && encrypt { + printSuccess("Signed and encrypted email sent successfully! Message ID: %s", msg.ID) + } else if encrypt { + printSuccess("Encrypted email sent successfully! Message ID: %s", msg.ID) + } else if sign { printSuccess("Signed email sent successfully! Message ID: %s", msg.ID) } else { printSuccess("Email sent successfully! Message ID: %s", msg.ID) @@ -361,6 +390,8 @@ Supports custom metadata: cmd.Flags().BoolVar(&sign, "sign", false, "Sign email with GPG (uses default key from git config)") cmd.Flags().StringVar(&gpgKeyID, "gpg-key", "", "Specific GPG key ID to use for signing") cmd.Flags().BoolVar(&listGPGKeys, "list-gpg-keys", false, "List available GPG signing keys and exit") + cmd.Flags().BoolVar(&encrypt, "encrypt", false, "Encrypt email with recipient's GPG public key") + cmd.Flags().StringVar(&recipientKey, "recipient-key", "", "Specific GPG key ID for encryption (auto-detected from recipient email if not specified)") return cmd } diff --git a/internal/cli/email/send_gpg.go b/internal/cli/email/send_gpg.go index f98d617..c88f4ee 100644 --- a/internal/cli/email/send_gpg.go +++ b/internal/cli/email/send_gpg.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "strings" + "time" "github.com/nylas/cli/internal/adapters/config" "github.com/nylas/cli/internal/adapters/gpg" @@ -70,8 +71,8 @@ func handleListGPGKeys(ctx context.Context) error { return nil } -// sendSignedEmail signs an email with GPG and sends it as raw MIME. -func sendSignedEmail(ctx context.Context, client ports.NylasClient, grantID string, req *domain.SendMessageRequest, gpgKeyID string, toContacts []domain.EmailParticipant, subject, body string) (*domain.Message, error) { +// sendSecureEmail sends an email with GPG signing and/or encryption. +func sendSecureEmail(ctx context.Context, client ports.NylasClient, grantID string, req *domain.SendMessageRequest, gpgKeyID, recipientKeyID string, toContacts []domain.EmailParticipant, subject, body string, doSign, doEncrypt bool) (*domain.Message, error) { gpgSvc := gpg.NewService() // Step 1: Check GPG is available @@ -83,53 +84,33 @@ func sendSignedEmail(ctx context.Context, client ports.NylasClient, grantID stri } spinner.Stop() - // Step 2: Get signing key/identity (Priority: CLI flag > Config > From email > Git config) - spinner = common.NewSpinner("Getting GPG signing key...") - spinner.Start() - var keyID string + // Step 2: Resolve keys for signing and/or encryption + var signerKeyID string var signingIdentity string + var recipientKeyIDs []string - if gpgKeyID != "" { - // Priority 1: Explicit key ID provided via --gpg-key flag - keyID = gpgKeyID - signingIdentity = gpgKeyID - } else { - // Priority 2: Check Nylas config for default key - configStore := config.NewDefaultFileStore() - cfg, err := configStore.Load() - if err == nil && cfg != nil && cfg.GPG != nil && cfg.GPG.DefaultKey != "" { - keyID = cfg.GPG.DefaultKey - signingIdentity = keyID - } else if len(req.From) > 0 && req.From[0].Email != "" { - // Priority 3: Use From email address to find key - // IMPORTANT: We must use the actual key ID for --local-user, not the email. - // GPG's --sender option only works correctly when --local-user is a key ID. - fromEmail := req.From[0].Email - signingIdentity = fromEmail - - // Look up the actual key ID for this email - key, err := gpgSvc.FindKeyByEmail(ctx, fromEmail) - if err != nil { - spinner.Stop() - return nil, fmt.Errorf("no GPG key found for %s: %w", fromEmail, err) - } - keyID = key.KeyID - } else { - // Priority 4: Fallback to default key from git config - key, err := gpgSvc.GetDefaultSigningKey(ctx) - if err != nil { - spinner.Stop() - return nil, err - } - keyID = key.KeyID - signingIdentity = keyID + if doSign { + spinner = common.NewSpinner("Getting GPG signing key...") + spinner.Start() + signerKeyID, signingIdentity = resolveSigningKey(ctx, gpgSvc, gpgKeyID, req) + if signerKeyID == "" { + spinner.Stop() + return nil, fmt.Errorf("could not determine signing key") } + spinner.Stop() } - spinner.Stop() - // Step 3: Build MIME content to sign - spinner = common.NewSpinner(fmt.Sprintf("Signing email with GPG identity: %s...", signingIdentity)) - spinner.Start() + if doEncrypt { + spinner = common.NewSpinner("Resolving recipient public keys...") + spinner.Start() + var err error + recipientKeyIDs, err = resolveRecipientKeys(ctx, gpgSvc, recipientKeyID, toContacts, req.Cc, req.Bcc) + if err != nil { + spinner.Stop() + return nil, err + } + spinner.Stop() + } // Determine content type contentType := "text/plain" @@ -137,36 +118,148 @@ func sendSignedEmail(ctx context.Context, client ports.NylasClient, grantID stri contentType = "text/html" } - // Prepare the MIME content part to be signed (includes headers) - // PGP/MIME requires signing the entire MIME part, not just the body text mimeBuilder := mime.NewBuilder() + var rawMIME []byte + + // Step 3: Build and send based on mode + if doSign && doEncrypt { + // Sign+Encrypt: Maximum security + rawMIME = buildSignedEncryptedMessage(ctx, gpgSvc, mimeBuilder, req, toContacts, subject, body, contentType, signerKeyID, recipientKeyIDs) + } else if doEncrypt { + // Encrypt only + rawMIME = buildEncryptedMessage(ctx, gpgSvc, mimeBuilder, req, toContacts, subject, body, contentType, recipientKeyIDs) + } else if doSign { + // Sign only (original behavior) + rawMIME = buildSignedMessage(ctx, gpgSvc, mimeBuilder, req, toContacts, subject, body, contentType, signerKeyID, signingIdentity) + } + + if rawMIME == nil { + return nil, fmt.Errorf("failed to build secure message") + } + + // Step 4: Send raw MIME message + var sendingMsg string + if doSign && doEncrypt { + sendingMsg = "Sending signed and encrypted email..." + } else if doEncrypt { + sendingMsg = "Sending encrypted email..." + } else { + sendingMsg = "Sending signed email..." + } + + spinner = common.NewSpinner(sendingMsg) + spinner.Start() + + msg, err := client.SendRawMessage(ctx, grantID, rawMIME) + spinner.Stop() + + if err != nil { + return nil, err + } + + return msg, nil +} + +// resolveSigningKey determines the signing key to use. +func resolveSigningKey(ctx context.Context, gpgSvc gpg.Service, explicitKeyID string, req *domain.SendMessageRequest) (keyID, identity string) { + if explicitKeyID != "" { + return explicitKeyID, explicitKeyID + } + + // Check Nylas config for default key + configStore := config.NewDefaultFileStore() + cfg, err := configStore.Load() + if err == nil && cfg != nil && cfg.GPG != nil && cfg.GPG.DefaultKey != "" { + return cfg.GPG.DefaultKey, cfg.GPG.DefaultKey + } + + // Use From email address to find key + if len(req.From) > 0 && req.From[0].Email != "" { + fromEmail := req.From[0].Email + key, err := gpgSvc.FindKeyByEmail(ctx, fromEmail) + if err == nil { + return key.KeyID, fromEmail + } + } + + // Fallback to default key from git config + key, err := gpgSvc.GetDefaultSigningKey(ctx) + if err == nil { + return key.KeyID, key.KeyID + } + + return "", "" +} + +// resolveRecipientKeys determines the encryption keys for all recipients. +func resolveRecipientKeys(ctx context.Context, gpgSvc gpg.Service, explicitKeyID string, to, cc, bcc []domain.EmailParticipant) ([]string, error) { + // If explicit key provided, use it + if explicitKeyID != "" { + return []string{explicitKeyID}, nil + } + + // Collect all recipient emails + var recipients []domain.EmailParticipant + recipients = append(recipients, to...) + recipients = append(recipients, cc...) + recipients = append(recipients, bcc...) + + if len(recipients) == 0 { + return nil, fmt.Errorf("no recipients specified for encryption") + } + + // Find public keys for each recipient (auto-fetch from key servers if needed) + keyIDs := make([]string, 0, len(recipients)) + seen := make(map[string]bool) + + for _, recipient := range recipients { + if seen[recipient.Email] { + continue + } + seen[recipient.Email] = true + + key, err := gpgSvc.FindPublicKeyByEmail(ctx, recipient.Email) + if err != nil { + return nil, fmt.Errorf("could not find public key for %s: %w\n\nTip: Ask the recipient to upload their key to keys.openpgp.org", recipient.Email, err) + } + + // Check for expired key (key.Expires is checked in FindPublicKeyByEmail) + // Additional check here for recently expired keys + if key.Expires != nil && key.Expires.Before(time.Now()) { + return nil, fmt.Errorf("public key for %s has expired on %s", recipient.Email, key.Expires.Format("2006-01-02")) + } + + keyIDs = append(keyIDs, key.KeyID) + } + + return keyIDs, nil +} + +// buildSignedMessage builds a signed-only PGP/MIME message. +func buildSignedMessage(ctx context.Context, gpgSvc gpg.Service, mimeBuilder mime.Builder, req *domain.SendMessageRequest, toContacts []domain.EmailParticipant, subject, body, contentType, signerKeyID, signingIdentity string) []byte { + spinner := common.NewSpinner(fmt.Sprintf("Signing email with GPG identity: %s...", signingIdentity)) + spinner.Start() + defer spinner.Stop() + + // Prepare the MIME content part to be signed dataToSign, err := mimeBuilder.PrepareContentToSign(body, contentType, req.Attachments) if err != nil { - spinner.Stop() - return nil, fmt.Errorf("failed to prepare content for signing: %w", err) + return nil } // Extract sender email for the Signer's User ID subpacket - // This ensures the correct email appears in the signature when - // the key has multiple UIDs var senderEmail string if len(req.From) > 0 && req.From[0].Email != "" { senderEmail = req.From[0].Email } - // Sign the MIME content part with sender email for proper UID embedding - signResult, err := gpgSvc.SignData(ctx, keyID, dataToSign, senderEmail) + // Sign the MIME content part + signResult, err := gpgSvc.SignData(ctx, signerKeyID, dataToSign, senderEmail) if err != nil { - spinner.Stop() - return nil, err + return nil } - spinner.Stop() - // Step 4: Build PGP/MIME message - spinner = common.NewSpinner("Building PGP/MIME message...") - spinner.Start() - - // Use the same MIME builder instance to ensure consistency + // Build PGP/MIME signed message mimeReq := &mime.SignedMessageRequest{ From: req.From, To: toContacts, @@ -178,27 +271,101 @@ func sendSignedEmail(ctx context.Context, client ports.NylasClient, grantID stri ContentType: contentType, Signature: signResult.Signature, HashAlgo: signResult.HashAlgo, - PreparedContent: dataToSign, // Use the exact content that was signed + PreparedContent: dataToSign, Attachments: req.Attachments, } rawMIME, err := mimeBuilder.BuildSignedMessage(mimeReq) if err != nil { - spinner.Stop() - return nil, fmt.Errorf("failed to build PGP/MIME message: %w", err) + return nil } - spinner.Stop() - // Step 5: Send raw MIME message - spinner = common.NewSpinner("Sending signed email...") + return rawMIME +} + +// buildEncryptedMessage builds an encrypted-only PGP/MIME message. +func buildEncryptedMessage(ctx context.Context, gpgSvc gpg.Service, mimeBuilder mime.Builder, req *domain.SendMessageRequest, toContacts []domain.EmailParticipant, subject, body, contentType string, recipientKeyIDs []string) []byte { + spinner := common.NewSpinner("Encrypting email...") spinner.Start() + defer spinner.Stop() - msg, err := client.SendRawMessage(ctx, grantID, rawMIME) - spinner.Stop() + // Prepare the content to encrypt + dataToEncrypt, err := mimeBuilder.PrepareContentToEncrypt(body, contentType, req.Attachments) + if err != nil { + return nil + } + // Encrypt the content + encryptResult, err := gpgSvc.EncryptData(ctx, recipientKeyIDs, dataToEncrypt) if err != nil { - return nil, err + return nil } - return msg, nil + // Build PGP/MIME encrypted message + mimeReq := &mime.EncryptedMessageRequest{ + From: req.From, + To: toContacts, + Cc: req.Cc, + Bcc: req.Bcc, + ReplyTo: req.ReplyTo, + Subject: subject, + Ciphertext: encryptResult.Ciphertext, + } + + rawMIME, err := mimeBuilder.BuildEncryptedMessage(mimeReq) + if err != nil { + return nil + } + + return rawMIME +} + +// buildSignedEncryptedMessage builds a signed AND encrypted PGP/MIME message. +// Order: Sign first, then encrypt (per OpenPGP best practice). +func buildSignedEncryptedMessage(ctx context.Context, gpgSvc gpg.Service, mimeBuilder mime.Builder, req *domain.SendMessageRequest, toContacts []domain.EmailParticipant, subject, body, contentType, signerKeyID string, recipientKeyIDs []string) []byte { + spinner := common.NewSpinner("Signing and encrypting email...") + spinner.Start() + defer spinner.Stop() + + // Prepare the content + dataToProcess, err := mimeBuilder.PrepareContentToEncrypt(body, contentType, req.Attachments) + if err != nil { + return nil + } + + // Extract sender email for signing + var senderEmail string + if len(req.From) > 0 && req.From[0].Email != "" { + senderEmail = req.From[0].Email + } + + // Sign AND encrypt in one GPG operation + encryptResult, err := gpgSvc.SignAndEncryptData(ctx, signerKeyID, recipientKeyIDs, dataToProcess, senderEmail) + if err != nil { + return nil + } + + // Build PGP/MIME encrypted message (signature is inside the encrypted payload) + mimeReq := &mime.EncryptedMessageRequest{ + From: req.From, + To: toContacts, + Cc: req.Cc, + Bcc: req.Bcc, + ReplyTo: req.ReplyTo, + Subject: subject, + Ciphertext: encryptResult.Ciphertext, + } + + rawMIME, err := mimeBuilder.BuildEncryptedMessage(mimeReq) + if err != nil { + return nil + } + + return rawMIME +} + +// sendSignedEmail signs an email with GPG and sends it as raw MIME. +// Deprecated: Use sendSecureEmail with doSign=true instead. +func sendSignedEmail(ctx context.Context, client ports.NylasClient, grantID string, req *domain.SendMessageRequest, gpgKeyID string, toContacts []domain.EmailParticipant, subject, body string) (*domain.Message, error) { + return sendSecureEmail(ctx, client, grantID, req, gpgKeyID, "", toContacts, subject, body, true, false) }