From 8552d0d59194829fa6b7a70cacbf1c3763fd8e63 Mon Sep 17 00:00:00 2001 From: Drew Smirnoff Date: Sat, 11 Apr 2026 23:39:26 +0400 Subject: [PATCH] feat: secure mode (#487) --- config/README.md | 77 +++++- config/cache.go | 164 ++++++++++++- config/config.go | 183 ++++++++++++-- config/encryption.go | 408 +++++++++++++++++++++++++++++++ config/folder_cache.go | 12 +- config/signature.go | 4 +- docs/docs/Configuration.md | 35 ++- docs/docs/Features/Encryption.md | 75 ++++++ fetcher/fetcher.go | 2 +- fetcher/idle.go | 7 + go.mod | 2 +- main.go | 128 +++++++++- tui/messages.go | 16 ++ tui/password_prompt.go | 131 ++++++++++ tui/settings.go | 262 +++++++++++++++++++- 15 files changed, 1446 insertions(+), 60 deletions(-) create mode 100644 config/encryption.go create mode 100644 docs/docs/Features/Encryption.md create mode 100644 tui/password_prompt.go diff --git a/config/README.md b/config/README.md index 061a53d6826d0f668acd814b6ced6067855653c8..2cd94b77dfb300865cc34036c906a35ac8345a04 100644 --- a/config/README.md +++ b/config/README.md @@ -1,30 +1,95 @@ # config -The `config` package handles all persistent application state: user configuration, email/contacts/drafts caching, folder caching, and email signatures. All data is stored as JSON files under `~/.config/matcha/`. +The `config` package handles all persistent application state: user configuration, email/contacts/drafts caching, folder caching, email signatures, and optional at-rest encryption. Configuration is stored under `~/.config/matcha/`, cache data under `~/.cache/matcha/`. ## Architecture This package acts as the data layer for Matcha. It manages: - **Account configuration** with multi-account support (Gmail, iCloud, custom IMAP/SMTP) -- **Secure credential storage** via the OS keyring (with automatic migration from plain-text passwords) -- **Local caches** for emails, contacts, drafts, and folder listings to enable fast startup and offline browsing +- **Secure credential storage** via the OS keyring (with automatic migration from plain-text passwords), or inside the encrypted config when encryption is enabled +- **Local caches** for emails, email bodies, contacts, drafts, and folder listings to enable fast startup and offline browsing +- **Email body caching** for instant display of previously viewed emails without network round-trips - **Email signatures** stored as plain text +- **Optional encryption** of all data files using AES-256-GCM with Argon2id key derivation -All cache files use JSON serialization with restrictive file permissions (`0600`/`0700`). +All files use JSON serialization with restrictive file permissions (`0600`/`0700`). When encryption is enabled, all files (except `secure.meta`) are encrypted before writing to disk. + +## Storage Layout + +Configuration (`~/.config/matcha/`): + +| Path | Description | +|------|-------------| +| `config.json` | Account settings, preferences | +| `signature.txt` | Email signature | +| `secure.meta` | Encryption metadata (salt + sentinel), only present when encryption is enabled | +| `pgp/` | PGP keys | +| `plugins/` | Installed Lua plugins | +| `themes/` | Custom theme JSON files | + +Cache (`~/.cache/matcha/`): + +| Path | Description | +|------|-------------| +| `email_cache.json` | Email metadata cache | +| `contacts.json` | Contact autocomplete data | +| `drafts.json` | Saved email drafts | +| `folder_cache.json` | Folder listings per account | +| `folder_emails/` | Per-folder email list cache | +| `email_bodies/` | Cached email body content and attachment metadata | + +On startup, `MigrateCacheFiles()` moves any cache files from the old location (`~/.config/matcha/`) to `~/.cache/matcha/`. ## Files | File | Description | |------|-------------| -| `config.go` | Core configuration types (`Account`, `Config`, `MailingList`) and functions for loading, saving, and managing accounts. Handles IMAP/SMTP server resolution per provider, OS keyring integration, and legacy config migration. | -| `cache.go` | Email, contacts, and drafts caching. Provides CRUD operations for `EmailCache`, `ContactsCache` (with search and frequency-based ranking), and `DraftsCache` (with save/delete/get operations). | +| `config.go` | Core configuration types (`Account`, `Config`, `MailingList`) and functions for loading, saving, and managing accounts. Handles IMAP/SMTP server resolution per provider, OS keyring integration, legacy config migration, and cache directory management (`cacheDir()`, `MigrateCacheFiles()`). | +| `cache.go` | Email, contacts, drafts, and email body caching. Provides CRUD operations for `EmailCache`, `ContactsCache` (with search and frequency-based ranking), `DraftsCache` (with save/delete/get operations), and `EmailBodyCache` (per-folder body + attachment metadata caching with pruning). | | `folder_cache.go` | Caches IMAP folder listings per account and per-folder email metadata. Stores folder names to avoid repeated IMAP `LIST` commands, and caches email headers per folder for fast navigation. | +| `encryption.go` | Optional at-rest encryption using AES-256-GCM with Argon2id key derivation. Provides `SecureReadFile`/`SecureWriteFile` (transparent encryption wrappers used by all other files), `EnableSecureMode`/`DisableSecureMode`, password verification via an encrypted sentinel phrase, and session key management. | | `signature.go` | Loads and saves the user's email signature from `~/.config/matcha/signature.txt`. | | `oauth.go` | OAuth2 integration — token retrieval, authorization flow launcher, and embedded Python helper extraction. | | `oauth_script.py` | Embedded Gmail OAuth2 helper script (browser-based auth, token refresh, secure storage). | | `config_test.go` | Unit tests for configuration logic. | +## Encryption + +When enabled via Settings, all data files are encrypted at rest using a user-chosen password. The password is never stored anywhere. + +### How it works + +1. **Key derivation**: The password is combined with a random 256-bit salt using Argon2id (time=3, memory=64MB, threads=4) to produce a 256-bit AES key. +2. **Sentinel verification**: A known phrase (`"matcha-verified"`) is encrypted with the derived key and stored in `secure.meta`. On login, if decrypting the sentinel succeeds, the password is correct. +3. **Transparent I/O**: All file read/write operations go through `SecureReadFile`/`SecureWriteFile`. When a session key is set, these encrypt/decrypt automatically. When no key is set (encryption disabled), they pass through to plain `os.ReadFile`/`os.WriteFile`. +4. **Password storage**: When encryption is active, account passwords are stored inside the encrypted `config.json` (via `secureDiskAccount`) instead of the OS keyring. When encryption is disabled, passwords are restored to the keyring. + +### `secure.meta` format + +```json +{ + "salt": "", + "sentinel": "", + "argon2_time": 3, + "argon2_memory": 65536, + "argon2_threads": 4 +} +``` + +This file is never encrypted itself — its existence signals that encryption is enabled. + +## Email Body Cache + +Email bodies and attachment metadata are cached per-folder in `~/.cache/matcha/email_bodies/`. When a user views an email: + +1. The cache is checked first (`GetCachedEmailBody`). +2. If found, the cached body is returned instantly without a network call. +3. If not found, the body is fetched from the server and saved to cache (`SaveEmailBody`). +4. When folder emails are refreshed, stale body cache entries (for emails no longer on the server) are pruned (`PruneEmailBodyCache`). + +Attachment binary data is not cached — only metadata (filename, MIME type, part ID, etc.) is stored. Attachment downloads always go to the server. + ## OAuth2 / XOAUTH2 Accounts with `auth_method: "oauth2"` use Gmail's XOAUTH2 mechanism instead of passwords. The flow works across three layers: diff --git a/config/cache.go b/config/cache.go index d26ff0d38c315ed0ad9b8d27fc54ade745ae7691..14426b33ca9356d31062f99b9a5062e323710910 100644 --- a/config/cache.go +++ b/config/cache.go @@ -29,7 +29,7 @@ type EmailCache struct { // cacheFile returns the full path to the email cache file. func cacheFile() (string, error) { - dir, err := configDir() + dir, err := cacheDir() if err != nil { return "", err } @@ -50,7 +50,7 @@ func SaveEmailCache(cache *EmailCache) error { if err != nil { return err } - return os.WriteFile(path, data, 0600) + return SecureWriteFile(path, data, 0600) } // LoadEmailCache loads emails from the cache file. @@ -59,7 +59,7 @@ func LoadEmailCache() (*EmailCache, error) { if err != nil { return nil, err } - data, err := os.ReadFile(path) + data, err := SecureReadFile(path) if err != nil { return nil, err } @@ -107,7 +107,7 @@ type ContactsCache struct { // contactsFile returns the full path to the contacts cache file. func contactsFile() (string, error) { - dir, err := configDir() + dir, err := cacheDir() if err != nil { return "", err } @@ -128,7 +128,7 @@ func SaveContactsCache(cache *ContactsCache) error { if err != nil { return err } - return os.WriteFile(path, data, 0600) + return SecureWriteFile(path, data, 0600) } // LoadContactsCache loads contacts from the cache file. @@ -137,7 +137,7 @@ func LoadContactsCache() (*ContactsCache, error) { if err != nil { return nil, err } - data, err := os.ReadFile(path) + data, err := SecureReadFile(path) if err != nil { return nil, err } @@ -270,7 +270,7 @@ type DraftsCache struct { // draftsFile returns the full path to the drafts cache file. func draftsFile() (string, error) { - dir, err := configDir() + dir, err := cacheDir() if err != nil { return "", err } @@ -291,7 +291,7 @@ func SaveDraftsCache(cache *DraftsCache) error { if err != nil { return err } - return os.WriteFile(path, data, 0600) + return SecureWriteFile(path, data, 0600) } // LoadDraftsCache loads drafts from the cache file. @@ -300,7 +300,7 @@ func LoadDraftsCache() (*DraftsCache, error) { if err != nil { return nil, err } - data, err := os.ReadFile(path) + data, err := SecureReadFile(path) if err != nil { return nil, err } @@ -396,3 +396,149 @@ func HasDrafts() bool { } return len(cache.Drafts) > 0 } + +// --- Email Body Cache --- + +// CachedAttachment stores attachment metadata (not the binary data). +type CachedAttachment struct { + Filename string `json:"filename"` + PartID string `json:"part_id"` + Encoding string `json:"encoding,omitempty"` + MIMEType string `json:"mime_type,omitempty"` + ContentID string `json:"content_id,omitempty"` + Inline bool `json:"inline,omitempty"` + IsSMIMESignature bool `json:"is_smime_signature,omitempty"` + SMIMEVerified bool `json:"smime_verified,omitempty"` + IsSMIMEEncrypted bool `json:"is_smime_encrypted,omitempty"` +} + +// CachedEmailBody stores the body and attachment metadata for a single email. +type CachedEmailBody struct { + UID uint32 `json:"uid"` + AccountID string `json:"account_id"` + Body string `json:"body"` + Attachments []CachedAttachment `json:"attachments,omitempty"` + CachedAt time.Time `json:"cached_at"` +} + +// EmailBodyCache stores cached email bodies for a folder. +type EmailBodyCache struct { + FolderName string `json:"folder_name"` + Bodies []CachedEmailBody `json:"bodies"` + UpdatedAt time.Time `json:"updated_at"` +} + +// bodyCacheDir returns the directory for body cache files. +func bodyCacheDir() (string, error) { + dir, err := cacheDir() + if err != nil { + return "", err + } + return filepath.Join(dir, "email_bodies"), nil +} + +// bodyBacheFile returns the file path for a folder's body cache. +func bodyCacheFile(folderName string) (string, error) { + dir, err := bodyCacheDir() + if err != nil { + return "", err + } + safe := strings.NewReplacer("/", "_", "\\", "_", ":", "_", " ", "_").Replace(folderName) + return filepath.Join(dir, safe+".json"), nil +} + +// LoadEmailBodyCache loads the body cache for a folder. +func LoadEmailBodyCache(folderName string) (*EmailBodyCache, error) { + path, err := bodyCacheFile(folderName) + if err != nil { + return nil, err + } + data, err := SecureReadFile(path) + if err != nil { + return nil, err + } + var cache EmailBodyCache + if err := json.Unmarshal(data, &cache); err != nil { + return nil, err + } + return &cache, nil +} + +// saveEmailBodyCache writes the body cache for a folder. +func saveEmailBodyCache(cache *EmailBodyCache) error { + path, err := bodyCacheFile(cache.FolderName) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + cache.UpdatedAt = time.Now() + data, err := json.Marshal(cache) + if err != nil { + return err + } + return SecureWriteFile(path, data, 0600) +} + +// GetCachedEmailBody returns the cached body for a specific email, or nil if not cached. +func GetCachedEmailBody(folderName string, uid uint32, accountID string) *CachedEmailBody { + cache, err := LoadEmailBodyCache(folderName) + if err != nil { + return nil + } + for _, b := range cache.Bodies { + if b.UID == uid && b.AccountID == accountID { + return &b + } + } + return nil +} + +// SaveEmailBody saves or updates a cached email body for a folder. +func SaveEmailBody(folderName string, body CachedEmailBody) error { + cache, err := LoadEmailBodyCache(folderName) + if err != nil { + cache = &EmailBodyCache{FolderName: folderName} + } + + body.CachedAt = time.Now() + + // Replace existing or append + found := false + for i, b := range cache.Bodies { + if b.UID == body.UID && b.AccountID == body.AccountID { + cache.Bodies[i] = body + found = true + break + } + } + if !found { + cache.Bodies = append(cache.Bodies, body) + } + + return saveEmailBodyCache(cache) +} + +// PruneEmailBodyCache removes cached bodies for emails that are no longer in the folder. +// validUIDs is a map of UID -> AccountID for emails still present. +func PruneEmailBodyCache(folderName string, validUIDs map[uint32]string) error { + cache, err := LoadEmailBodyCache(folderName) + if err != nil { + return nil // No cache to prune + } + + var kept []CachedEmailBody + for _, b := range cache.Bodies { + if accID, ok := validUIDs[b.UID]; ok && accID == b.AccountID { + kept = append(kept, b) + } + } + + if len(kept) == len(cache.Bodies) { + return nil // Nothing pruned + } + + cache.Bodies = kept + return saveEmailBodyCache(cache) +} diff --git a/config/config.go b/config/config.go index b20393a78129513e333c8d2d5ad735a9f874fe3e..ac7d53e4d6fdf5eea145e83a5d8c6aa26f8b0174 100644 --- a/config/config.go +++ b/config/config.go @@ -155,6 +155,64 @@ func configDir() (string, error) { return filepath.Join(home, ".config", "matcha"), nil } +// GetCacheDir returns the path to the cache directory (exported). +func GetCacheDir() (string, error) { + return cacheDir() +} + +// cacheDir returns the path to the cache directory (internal). +func cacheDir() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, ".cache", "matcha"), nil +} + +// MigrateCacheFiles moves cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed. +// This is a one-time migration for existing installations. +func MigrateCacheFiles() error { + src, err := configDir() + if err != nil { + return err + } + dst, err := cacheDir() + if err != nil { + return err + } + if err := os.MkdirAll(dst, 0700); err != nil { + return err + } + + // Files to migrate + files := []string{"email_cache.json", "contacts.json", "drafts.json", "folder_cache.json"} + for _, f := range files { + oldPath := filepath.Join(src, f) + newPath := filepath.Join(dst, f) + if _, err := os.Stat(oldPath); err == nil { + // Only migrate if destination doesn't already exist + if _, err := os.Stat(newPath); err != nil { + if err := os.Rename(oldPath, newPath); err != nil { + return err + } + } + } + } + + // Migrate folder_emails directory + oldDir := filepath.Join(src, "folder_emails") + newDir := filepath.Join(dst, "folder_emails") + if info, err := os.Stat(oldDir); err == nil && info.IsDir() { + if _, err := os.Stat(newDir); err != nil { + if err := os.Rename(oldDir, newDir); err != nil { + return err + } + } + } + + return nil +} + // configFile returns the full path to the configuration file. func configFile() (string, error) { dir, err := configDir() @@ -164,18 +222,56 @@ func configFile() (string, error) { return filepath.Join(dir, "config.json"), nil } +// secureDiskAccount includes the Password field in JSON when secure mode is active. +type secureDiskAccount struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Password string `json:"password,omitempty"` + ServiceProvider string `json:"service_provider"` + FetchEmail string `json:"fetch_email,omitempty"` + IMAPServer string `json:"imap_server,omitempty"` + IMAPPort int `json:"imap_port,omitempty"` + SMTPServer string `json:"smtp_server,omitempty"` + SMTPPort int `json:"smtp_port,omitempty"` + Insecure bool `json:"insecure,omitempty"` + SMIMECert string `json:"smime_cert,omitempty"` + SMIMEKey string `json:"smime_key,omitempty"` + SMIMESignByDefault bool `json:"smime_sign_by_default,omitempty"` + PGPPublicKey string `json:"pgp_public_key,omitempty"` + PGPPrivateKey string `json:"pgp_private_key,omitempty"` + PGPKeySource string `json:"pgp_key_source,omitempty"` + PGPPIN string `json:"pgp_pin,omitempty"` + PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"` + AuthMethod string `json:"auth_method,omitempty"` + Protocol string `json:"protocol,omitempty"` + JMAPEndpoint string `json:"jmap_endpoint,omitempty"` + POP3Server string `json:"pop3_server,omitempty"` + POP3Port int `json:"pop3_port,omitempty"` +} + +type secureDiskConfig struct { + Accounts []secureDiskAccount `json:"accounts"` + DisableImages bool `json:"disable_images,omitempty"` + HideTips bool `json:"hide_tips,omitempty"` + DisableNotifications bool `json:"disable_notifications,omitempty"` + Theme string `json:"theme,omitempty"` + MailingLists []MailingList `json:"mailing_lists,omitempty"` +} + // SaveConfig saves the given configuration to the config file and passwords to the keyring. func SaveConfig(config *Config) error { - // Save passwords and PGP PINs to the OS keyring before writing the JSON file - for _, acc := range config.Accounts { - if acc.Password != "" { - // We ignore the error here because some environments (like headless CI) - // might not have a keyring service, but we still want to save the rest of the config. - _ = keyring.Set(keyringServiceName, acc.Email, acc.Password) - } - // Save YubiKey PIN if present - if acc.PGPPIN != "" && acc.PGPKeySource == "yubikey" { - _ = keyring.Set(keyringServiceName, acc.Email+":pgp-pin", acc.PGPPIN) + secureMode := GetSessionKey() != nil + + if !secureMode { + // Save passwords and PGP PINs to the OS keyring before writing the JSON file + for _, acc := range config.Accounts { + if acc.Password != "" { + _ = keyring.Set(keyringServiceName, acc.Email, acc.Password) + } + if acc.PGPPIN != "" && acc.PGPKeySource == "yubikey" { + _ = keyring.Set(keyringServiceName, acc.Email+":pgp-pin", acc.PGPPIN) + } } } @@ -186,11 +282,53 @@ func SaveConfig(config *Config) error { if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return err } - data, err := json.MarshalIndent(config, "", " ") + + var data []byte + if secureMode { + // In secure mode, include passwords in the JSON (they'll be encrypted on disk) + sdc := secureDiskConfig{ + DisableImages: config.DisableImages, + HideTips: config.HideTips, + DisableNotifications: config.DisableNotifications, + Theme: config.Theme, + MailingLists: config.MailingLists, + } + for _, acc := range config.Accounts { + sdc.Accounts = append(sdc.Accounts, secureDiskAccount{ + ID: acc.ID, + Name: acc.Name, + Email: acc.Email, + Password: acc.Password, + ServiceProvider: acc.ServiceProvider, + FetchEmail: acc.FetchEmail, + IMAPServer: acc.IMAPServer, + IMAPPort: acc.IMAPPort, + SMTPServer: acc.SMTPServer, + SMTPPort: acc.SMTPPort, + Insecure: acc.Insecure, + SMIMECert: acc.SMIMECert, + SMIMEKey: acc.SMIMEKey, + SMIMESignByDefault: acc.SMIMESignByDefault, + PGPPublicKey: acc.PGPPublicKey, + PGPPrivateKey: acc.PGPPrivateKey, + PGPKeySource: acc.PGPKeySource, + PGPPIN: acc.PGPPIN, + PGPSignByDefault: acc.PGPSignByDefault, + AuthMethod: acc.AuthMethod, + Protocol: acc.Protocol, + JMAPEndpoint: acc.JMAPEndpoint, + POP3Server: acc.POP3Server, + POP3Port: acc.POP3Port, + }) + } + data, err = json.MarshalIndent(sdc, "", " ") + } else { + data, err = json.MarshalIndent(config, "", " ") + } if err != nil { return err } - return os.WriteFile(path, data, 0600) + return SecureWriteFile(path, data, 0600) } // LoadConfig loads the configuration from the config file and passwords from the keyring. @@ -200,11 +338,13 @@ func LoadConfig() (*Config, error) { if err != nil { return nil, err } - data, err := os.ReadFile(path) + data, err := SecureReadFile(path) if err != nil { return nil, err } + secureMode := GetSessionKey() != nil + var config Config var needsMigration bool @@ -226,6 +366,7 @@ func LoadConfig() (*Config, error) { PGPPublicKey string `json:"pgp_public_key,omitempty"` PGPPrivateKey string `json:"pgp_private_key,omitempty"` PGPKeySource string `json:"pgp_key_source,omitempty"` + PGPPIN string `json:"pgp_pin,omitempty"` PGPSignByDefault bool `json:"pgp_sign_by_default,omitempty"` AuthMethod string `json:"auth_method,omitempty"` Protocol string `json:"protocol,omitempty"` @@ -298,7 +439,11 @@ func LoadConfig() (*Config, error) { POP3Port: rawAcc.POP3Port, } - if rawAcc.Password != "" { + if secureMode { + // In secure mode, passwords and PINs are stored in the encrypted config JSON + acc.Password = rawAcc.Password + acc.PGPPIN = rawAcc.PGPPIN + } else if rawAcc.Password != "" { // Found a plain-text password! Move it to the OS Keyring. _ = keyring.Set(keyringServiceName, rawAcc.Email, rawAcc.Password) acc.Password = rawAcc.Password @@ -310,10 +455,12 @@ func LoadConfig() (*Config, error) { } } - // Load YubiKey PIN from keyring if using YubiKey - if acc.PGPKeySource == "yubikey" { - if pin, err := keyring.Get(keyringServiceName, acc.Email+":pgp-pin"); err == nil { - acc.PGPPIN = pin + if !secureMode { + // Load YubiKey PIN from keyring if using YubiKey + if acc.PGPKeySource == "yubikey" { + if pin, err := keyring.Get(keyringServiceName, acc.Email+":pgp-pin"); err == nil { + acc.PGPPIN = pin + } } } diff --git a/config/encryption.go b/config/encryption.go new file mode 100644 index 0000000000000000000000000000000000000000..a4e11bac6c033a8397d963d8ddf204c703e49b75 --- /dev/null +++ b/config/encryption.go @@ -0,0 +1,408 @@ +package config + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + + "golang.org/x/crypto/argon2" +) + +const ( + sentinelPlaintext = "matcha-verified" + secureMetaFile = "secure.meta" + + // Argon2id parameters + argon2Time = 3 + argon2Memory = 64 * 1024 // 64 MB + argon2Threads = 4 + argon2KeyLen = 32 // AES-256 +) + +// secureMeta is stored as plain JSON at ~/.config/matcha/secure.meta. +// Its existence signals that secure mode is enabled. +type secureMeta struct { + Salt string `json:"salt"` + Sentinel string `json:"sentinel"` + Argon2Time uint32 `json:"argon2_time"` + Argon2Memory uint32 `json:"argon2_memory"` + Argon2Threads uint8 `json:"argon2_threads"` +} + +var ( + sessionKey []byte + sessionKeyMu sync.RWMutex +) + +// SetSessionKey stores the derived encryption key in memory for this session. +func SetSessionKey(key []byte) { + sessionKeyMu.Lock() + defer sessionKeyMu.Unlock() + sessionKey = key +} + +// GetSessionKey returns the current session key, or nil if not set. +func GetSessionKey() []byte { + sessionKeyMu.RLock() + defer sessionKeyMu.RUnlock() + return sessionKey +} + +// ClearSessionKey removes the session key from memory. +func ClearSessionKey() { + sessionKeyMu.Lock() + defer sessionKeyMu.Unlock() + // Overwrite key material before clearing + for i := range sessionKey { + sessionKey[i] = 0 + } + sessionKey = nil +} + +// DeriveKey derives an AES-256 key from a password and salt using Argon2id. +func DeriveKey(password string, salt []byte) []byte { + return argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen) +} + +// deriveKeyWithParams derives a key using specific Argon2id parameters (for loading existing meta). +func deriveKeyWithParams(password string, salt []byte, time, memory uint32, threads uint8) []byte { + return argon2.IDKey([]byte(password), salt, time, memory, threads, argon2KeyLen) +} + +// Encrypt encrypts plaintext using AES-256-GCM. The nonce is prepended to the ciphertext. +func Encrypt(plaintext, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("encryption: %w", err) + } + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("encryption: %w", err) + } + nonce := make([]byte, aesGCM.NonceSize()) + if _, err := rand.Read(nonce); err != nil { + return nil, fmt.Errorf("encryption: %w", err) + } + return aesGCM.Seal(nonce, nonce, plaintext, nil), nil +} + +// Decrypt decrypts ciphertext produced by Encrypt using AES-256-GCM. +func Decrypt(ciphertext, key []byte) ([]byte, error) { + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("decryption: %w", err) + } + aesGCM, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("decryption: %w", err) + } + nonceSize := aesGCM.NonceSize() + if len(ciphertext) < nonceSize { + return nil, errors.New("decryption: ciphertext too short") + } + nonce, encrypted := ciphertext[:nonceSize], ciphertext[nonceSize:] + plaintext, err := aesGCM.Open(nil, nonce, encrypted, nil) + if err != nil { + return nil, fmt.Errorf("decryption: %w", err) + } + return plaintext, nil +} + +// secureMetaPath returns the path to the secure.meta file. +func secureMetaPath() (string, error) { + dir, err := configDir() + if err != nil { + return "", err + } + return filepath.Join(dir, secureMetaFile), nil +} + +// IsSecureModeEnabled checks whether encryption is active by looking for secure.meta. +func IsSecureModeEnabled() bool { + path, err := secureMetaPath() + if err != nil { + return false + } + _, err = os.Stat(path) + return err == nil +} + +// loadSecureMeta reads and parses the secure.meta file. +func loadSecureMeta() (*secureMeta, error) { + path, err := secureMetaPath() + if err != nil { + return nil, err + } + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var meta secureMeta + if err := json.Unmarshal(data, &meta); err != nil { + return nil, err + } + return &meta, nil +} + +// VerifyPassword checks the password against the stored sentinel. +// Returns the derived key on success. +func VerifyPassword(password string) ([]byte, error) { + meta, err := loadSecureMeta() + if err != nil { + return nil, fmt.Errorf("could not read secure metadata: %w", err) + } + + salt, err := base64.StdEncoding.DecodeString(meta.Salt) + if err != nil { + return nil, fmt.Errorf("invalid salt: %w", err) + } + + key := deriveKeyWithParams(password, salt, meta.Argon2Time, meta.Argon2Memory, meta.Argon2Threads) + + sentinelCiphertext, err := base64.StdEncoding.DecodeString(meta.Sentinel) + if err != nil { + return nil, fmt.Errorf("invalid sentinel: %w", err) + } + + plaintext, err := Decrypt(sentinelCiphertext, key) + if err != nil { + return nil, errors.New("incorrect password") + } + + if string(plaintext) != sentinelPlaintext { + return nil, errors.New("incorrect password") + } + + return key, nil +} + +// EnableSecureMode sets up encryption with the given password. +// It generates a salt, derives a key, encrypts the sentinel, saves secure.meta, +// and re-encrypts all existing data files. The config must be passed so that +// passwords (normally stored in the OS keyring) can be written into the encrypted config. +func EnableSecureMode(password string, cfg *Config) error { + // Generate random salt + salt := make([]byte, 32) + if _, err := rand.Read(salt); err != nil { + return fmt.Errorf("could not generate salt: %w", err) + } + + key := DeriveKey(password, salt) + + // Encrypt sentinel + sentinelCipher, err := Encrypt([]byte(sentinelPlaintext), key) + if err != nil { + return fmt.Errorf("could not encrypt sentinel: %w", err) + } + + meta := secureMeta{ + Salt: base64.StdEncoding.EncodeToString(salt), + Sentinel: base64.StdEncoding.EncodeToString(sentinelCipher), + Argon2Time: argon2Time, + Argon2Memory: argon2Memory, + Argon2Threads: argon2Threads, + } + + metaData, err := json.MarshalIndent(meta, "", " ") + if err != nil { + return err + } + + path, err := secureMetaPath() + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return err + } + + // Set the session key so SecureWriteFile will encrypt + SetSessionKey(key) + + // Re-save config first — this writes passwords into the encrypted JSON + // (SaveConfig uses secureDiskConfig when session key is set) + if cfg != nil { + if err := SaveConfig(cfg); err != nil { + ClearSessionKey() + return fmt.Errorf("failed to save encrypted config: %w", err) + } + } + + // Re-encrypt all remaining data files (caches, signatures, etc.) + if err := reEncryptCacheFiles(); err != nil { + ClearSessionKey() + return fmt.Errorf("failed to encrypt existing files: %w", err) + } + + // Write secure.meta last (plain JSON, not encrypted) + if err := os.WriteFile(path, metaData, 0600); err != nil { + ClearSessionKey() + return err + } + + return nil +} + +// DisableSecureMode decrypts all files back to plain JSON and removes secure.meta. +// The config must be passed so passwords can be restored to the OS keyring. +func DisableSecureMode(cfg *Config) error { + // Collect all files that need decryption + files, err := collectDataFiles() + if err != nil { + return err + } + + // Find config.json path to skip it (handled separately below) + cfgPath, _ := configFile() + + // Read and decrypt all cache files while we still have the session key + key := GetSessionKey() + for _, f := range files { + if f == cfgPath { + continue + } + data, err := SecureReadFile(f) + if err != nil { + continue // File may not exist + } + // Write plain + ClearSessionKey() + if err := os.WriteFile(f, data, 0600); err != nil { + SetSessionKey(key) // Restore on error + return err + } + SetSessionKey(key) + } + + // Clear session key so SaveConfig writes plain JSON and restores passwords to keyring + ClearSessionKey() + + // Re-save config — this will use the keyring (no session key) and strip passwords from JSON + if cfg != nil { + if err := SaveConfig(cfg); err != nil { + return fmt.Errorf("failed to save plain config: %w", err) + } + } + + // Remove secure.meta + path, err := secureMetaPath() + if err != nil { + return err + } + _ = os.Remove(path) + + return nil +} + +// SecureReadFile reads a file, decrypting it if a session key is set. +func SecureReadFile(path string) ([]byte, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + key := GetSessionKey() + if key == nil { + return data, nil + } + return Decrypt(data, key) +} + +// SecureWriteFile writes data to a file, encrypting it if a session key is set. +func SecureWriteFile(path string, data []byte, perm os.FileMode) error { + key := GetSessionKey() + if key == nil { + return os.WriteFile(path, data, perm) + } + encrypted, err := Encrypt(data, key) + if err != nil { + return err + } + return os.WriteFile(path, encrypted, perm) +} + +// reEncryptCacheFiles reads all plain cache/data files (excluding config.json) and writes them encrypted. +func reEncryptCacheFiles() error { + files, err := collectDataFiles() + if err != nil { + return err + } + + // Find config.json path to skip it (already saved separately with passwords) + cfgPath, _ := configFile() + + for _, f := range files { + if f == cfgPath { + continue // Already handled by SaveConfig + } + plainData, err := os.ReadFile(f) + if err != nil { + continue // File may not exist + } + // Write encrypted using SecureWriteFile (session key is already set) + if err := SecureWriteFile(f, plainData, 0600); err != nil { + return err + } + } + return nil +} + +// collectDataFiles returns paths to all data files that should be encrypted/decrypted. +func collectDataFiles() ([]string, error) { + var files []string + + // Config files + cfgDir, err := configDir() + if err != nil { + return nil, err + } + files = append(files, filepath.Join(cfgDir, "config.json")) + + // Cache files + cDir, err := cacheDir() + if err != nil { + return nil, err + } + cacheFiles := []string{"email_cache.json", "contacts.json", "drafts.json", "folder_cache.json"} + for _, f := range cacheFiles { + files = append(files, filepath.Join(cDir, f)) + } + + // Folder email cache files + folderDir := filepath.Join(cDir, "folder_emails") + if entries, err := os.ReadDir(folderDir); err == nil { + for _, entry := range entries { + if !entry.IsDir() { + files = append(files, filepath.Join(folderDir, entry.Name())) + } + } + } + + // Email body cache files + bodyDir := filepath.Join(cDir, "email_bodies") + if entries, err := os.ReadDir(bodyDir); err == nil { + for _, entry := range entries { + if !entry.IsDir() { + files = append(files, filepath.Join(bodyDir, entry.Name())) + } + } + } + + // Signature files + sigDir := filepath.Join(cfgDir, "signatures") + if entries, err := os.ReadDir(sigDir); err == nil { + for _, entry := range entries { + if !entry.IsDir() { + files = append(files, filepath.Join(sigDir, entry.Name())) + } + } + } + + return files, nil +} diff --git a/config/folder_cache.go b/config/folder_cache.go index d36b32781ab0c3478d9495615442db74cff6ae23..d4f916c5ce9977e8f3d03ee8b7c18848b9b636de 100644 --- a/config/folder_cache.go +++ b/config/folder_cache.go @@ -23,7 +23,7 @@ type FolderCache struct { // folderCacheFile returns the full path to the folder cache file. func folderCacheFile() (string, error) { - dir, err := configDir() + dir, err := cacheDir() if err != nil { return "", err } @@ -44,7 +44,7 @@ func SaveFolderCache(cache *FolderCache) error { if err != nil { return err } - return os.WriteFile(path, data, 0600) + return SecureWriteFile(path, data, 0600) } // LoadFolderCache loads the folder cache from disk. @@ -53,7 +53,7 @@ func LoadFolderCache() (*FolderCache, error) { if err != nil { return nil, err } - data, err := os.ReadFile(path) + data, err := SecureReadFile(path) if err != nil { return nil, err } @@ -117,7 +117,7 @@ type FolderEmailCache struct { // folderEmailCacheDir returns the directory for folder email cache files. func folderEmailCacheDir() (string, error) { - dir, err := configDir() + dir, err := cacheDir() if err != nil { return "", err } @@ -160,7 +160,7 @@ func SaveFolderEmailCache(folderName string, emails []CachedEmail) error { if err != nil { return err } - return os.WriteFile(path, data, 0600) + return SecureWriteFile(path, data, 0600) } // LoadFolderEmailCache loads cached emails for a folder from disk. @@ -169,7 +169,7 @@ func LoadFolderEmailCache(folderName string) ([]CachedEmail, error) { if err != nil { return nil, err } - data, err := os.ReadFile(path) + data, err := SecureReadFile(path) if err != nil { return nil, err } diff --git a/config/signature.go b/config/signature.go index e070f18db74e8a89aef1e753a9572d06ef37c4cc..e2e8e0d72b0c8e7c1d8ef5a52776f95831ed1d9c 100644 --- a/config/signature.go +++ b/config/signature.go @@ -20,7 +20,7 @@ func LoadSignature() (string, error) { if err != nil { return "", err } - data, err := os.ReadFile(path) + data, err := SecureReadFile(path) if err != nil { if os.IsNotExist(err) { return "", nil @@ -39,7 +39,7 @@ func SaveSignature(signature string) error { if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { return err } - return os.WriteFile(path, []byte(signature), 0600) + return SecureWriteFile(path, []byte(signature), 0600) } // HasSignature checks if a signature file exists and is non-empty. diff --git a/docs/docs/Configuration.md b/docs/docs/Configuration.md index c8cda7c01f0d5ff2729dfa357e043fe9d3fa9603..dcf58c3f1a48144bd92562e38135c9d245b0f50c 100644 --- a/docs/docs/Configuration.md +++ b/docs/docs/Configuration.md @@ -47,9 +47,34 @@ Configuration is stored in `~/.config/matcha/config.json`. } ``` -## Additional Data Locations +## Data Locations -- **Drafts**: `~/.config/matcha/drafts/` -- **Email Cache**: `~/.config/matcha/cache.json` -- **Contacts**: `~/.config/matcha/contacts.json` -- **Custom Themes**: `~/.config/matcha/themes/*.json` +Configuration and persistent data are stored in `~/.config/matcha/`: + +| File | Description | +|------|-------------| +| `config.json` | Account settings, preferences | +| `signatures/` | Email signatures | +| `pgp/` | PGP keys | +| `plugins/` | Installed Lua plugins | +| `themes/` | Custom theme JSON files | +| `secure.meta` | Encryption metadata (only when encryption is enabled) | + +Cache data is stored in `~/.cache/matcha/`: + +| File | Description | +|------|-------------| +| `email_cache.json` | Email metadata cache | +| `contacts.json` | Contact autocomplete data | +| `drafts.json` | Saved email drafts | +| `folder_cache.json` | Folder listings per account | +| `folder_emails/` | Per-folder email list cache | +| `email_bodies/` | Cached email body content | + +Cache files are automatically refreshed from the server on each app launch and manual refresh. If an email is removed from the server, its cache entry is cleaned up on the next refresh. + +## Encryption + +All data files can optionally be encrypted with a password. See [Encryption](/docs/Features/Encryption) for details. + +When encryption is enabled, account passwords are stored inside the encrypted `config.json` instead of the OS keyring. diff --git a/docs/docs/Features/Encryption.md b/docs/docs/Features/Encryption.md new file mode 100644 index 0000000000000000000000000000000000000000..3c1cff28499c3216e4481123ac7e3c16f84c77fc --- /dev/null +++ b/docs/docs/Features/Encryption.md @@ -0,0 +1,75 @@ +# Encryption + +Matcha supports optional full-disk encryption of all local data using a password you choose. The password is never stored anywhere -- not on disk, not in the OS keyring, not in environment variables. You enter it each time you open matcha. + +## How It Works + +When encryption is enabled: + +1. All local files (config, email cache, email bodies, contacts, drafts, signatures) are encrypted using **AES-256-GCM**. +2. Your password is used to derive an encryption key via **Argon2id**, a memory-hard key derivation function designed to resist brute-force attacks. +3. A small metadata file (`secure.meta`) stores a random salt and an encrypted sentinel phrase. This is used to verify your password is correct -- if decrypting the sentinel produces the expected phrase, you're in. +4. Account passwords are stored inside the encrypted config file instead of the OS keyring, so everything is protected by a single password. +5. The derived key lives only in memory for the duration of your session. + +## Enabling Encryption + +1. Open **Settings** from the main menu. +2. Select **Encryption: OFF**. +3. Enter a password and confirm it. +4. Press **Enable Encryption**. + +All existing data files will be encrypted immediately. On next launch, matcha will prompt for your password before showing anything. + +## Unlocking Matcha + +When encryption is enabled, matcha shows a lock screen on startup: + +``` +matcha is locked + +> ******** + +enter: unlock | ctrl+c: quit +``` + +Enter your password to decrypt and proceed. If the password is wrong, you'll see an error and can try again. + +## Disabling Encryption + +1. Open **Settings** from the main menu. +2. Select **Encryption: ON**. +3. Confirm with **y** when prompted. + +All files will be decrypted back to plain JSON, account passwords will be restored to the OS keyring, and the `secure.meta` file will be removed. + +## Technical Details + +| Property | Value | +|----------|-------| +| **Cipher** | AES-256-GCM (authenticated encryption) | +| **Key Derivation** | Argon2id (time=3, memory=64MB, threads=4) | +| **Key Size** | 256-bit (32 bytes) | +| **Salt** | 256-bit random, unique per installation | +| **Nonce** | Random per-file, prepended to ciphertext | +| **Password Storage** | Never stored. Derived key held in memory only. | + +## What Gets Encrypted + +- `~/.config/matcha/config.json` (accounts, settings, passwords) +- `~/.config/matcha/signatures/` (email signatures) +- `~/.cache/matcha/email_cache.json` (email metadata) +- `~/.cache/matcha/contacts.json` (contact autocomplete) +- `~/.cache/matcha/drafts.json` (saved drafts) +- `~/.cache/matcha/folder_cache.json` (folder listings) +- `~/.cache/matcha/folder_emails/` (per-folder email lists) +- `~/.cache/matcha/email_bodies/` (cached email bodies) + +The `secure.meta` file itself is **not** encrypted -- it contains only the salt and encrypted sentinel needed to verify your password. + +## Important Notes + +- **If you forget your password, your data cannot be recovered.** There is no reset mechanism. +- The encryption protects data at rest. Once unlocked, data is decrypted in memory for the session. +- PGP keys and S/MIME certificates referenced by path in your config are not encrypted by matcha (they are external files managed by you). +- OAuth2 tokens are managed separately and are not covered by this encryption. diff --git a/fetcher/fetcher.go b/fetcher/fetcher.go index aeb620ed10ffaadeb52ab3f70bf7fa1857f88608..c477a04fe40a589448813d30ada5299b19f37534 100644 --- a/fetcher/fetcher.go +++ b/fetcher/fetcher.go @@ -217,7 +217,7 @@ func connect(account *config.Account) (*client.Client, error) { } } else { if err := c.Login(account.Email, account.Password); err != nil { - return nil, err + return nil, fmt.Errorf("authentication error") } } diff --git a/fetcher/idle.go b/fetcher/idle.go index c98aa71642d3c47f7b63a699320c4ddedb516b32..f75293be6e893271b8db3bcd0e66b8ed6d5a4eb7 100644 --- a/fetcher/idle.go +++ b/fetcher/idle.go @@ -2,6 +2,7 @@ package fetcher import ( "log" + "strings" "sync" "time" @@ -129,6 +130,12 @@ func (a *accountIdle) run() { default: } + // Don't retry on authentication errors — they won't resolve by retrying + if strings.Contains(err.Error(), "authentication error") || strings.Contains(err.Error(), "XOAUTH2 authentication failed") { + log.Printf("IDLE stopped for account %s: %v", a.account.ID, err) + return + } + log.Printf("IDLE error for account %s: %v (reconnecting in %v)", a.account.ID, err, backoff) // Wait with backoff before reconnecting diff --git a/go.mod b/go.mod index fe2367ae56af51be9f021d4b1946c6758cbe56d8..e60041161e46494e18be60978f41bd304f1a7914 100644 --- a/go.mod +++ b/go.mod @@ -22,6 +22,7 @@ require ( github.com/yuin/gopher-lua v1.1.2 github.com/zalando/go-keyring v0.2.8 go.mozilla.org/pkcs7 v0.9.0 + golang.org/x/crypto v0.49.0 golang.org/x/sys v0.43.0 golang.org/x/text v0.36.0 ) @@ -47,7 +48,6 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/crypto v0.49.0 // indirect golang.org/x/net v0.52.0 // indirect golang.org/x/oauth2 v0.4.0 // indirect golang.org/x/sync v0.20.0 // indirect diff --git a/main.go b/main.go index be37637b6e374105e1d7b5c6b88c44ff29b429e1..b15b62925a0568b603d24665d4cc229f33e8cca7 100644 --- a/main.go +++ b/main.go @@ -520,6 +520,14 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Always cache in memory and to disk m.folderEmails[msg.FolderName] = msg.Emails go saveFolderEmailsToCache(msg.FolderName, msg.Emails) + // Prune stale body cache entries + go func() { + validUIDs := make(map[uint32]string, len(msg.Emails)) + for _, e := range msg.Emails { + validUIDs[e.UID] = e.AccountID + } + _ = config.PruneEmailBodyCache(msg.FolderName, validUIDs) + }() // Only update the view if the user is still on this folder if m.folderInbox.GetCurrentFolder() != msg.FolderName { return m, nil @@ -839,6 +847,42 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) return m, m.current.Init() + case tui.PasswordVerifiedMsg: + if msg.Err != nil { + // Error is handled inside PasswordPrompt itself + return m, nil + } + // Password verified — set session key and load config + config.SetSessionKey(msg.Key) + cfg, err := config.LoadConfig() + if err == nil && cfg.Theme != "" { + theme.SetTheme(cfg.Theme) + tui.RebuildStyles() + } + _ = config.EnsurePGPDir() + if err != nil { + m.config = nil + hideTips := false + m.current = tui.NewLogin(hideTips) + } else { + m.config = cfg + m.current = tui.NewChoice() + } + m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) + return m, m.current.Init() + + case tui.SecureModeEnabledMsg: + if msg.Err != nil { + log.Printf("Failed to enable encryption: %v", msg.Err) + } + return m, nil + + case tui.SecureModeDisabledMsg: + if msg.Err != nil { + log.Printf("Failed to disable encryption: %v", msg.Err) + } + return m, nil + case tui.GoToChoiceMenuMsg: m.current = tui.NewChoice() m.current, _ = m.current.Update(tea.WindowSizeMsg{Width: m.width, Height: m.height}) @@ -879,6 +923,33 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { t := m.plugins.EmailToTable(email.UID, email.From, email.To, email.Subject, email.Date, email.IsRead, email.AccountID, folderName) m.plugins.CallHook(plugin.HookEmailViewed, t) } + // Check body cache first + if cached := config.GetCachedEmailBody(folderName, msg.UID, msg.AccountID); cached != nil { + // Convert cached attachments back to fetcher.Attachment + var attachments []fetcher.Attachment + for _, ca := range cached.Attachments { + attachments = append(attachments, fetcher.Attachment{ + Filename: ca.Filename, + PartID: ca.PartID, + Encoding: ca.Encoding, + MIMEType: ca.MIMEType, + ContentID: ca.ContentID, + Inline: ca.Inline, + IsSMIMESignature: ca.IsSMIMESignature, + SMIMEVerified: ca.SMIMEVerified, + IsSMIMEEncrypted: ca.IsSMIMEEncrypted, + }) + } + return m, func() tea.Msg { + return tui.EmailBodyFetchedMsg{ + UID: msg.UID, + Body: cached.Body, + Attachments: attachments, + AccountID: msg.AccountID, + Mailbox: msg.Mailbox, + } + } + } m.current = tui.NewStatus("Fetching email content...") return m, tea.Batch(m.current.Init(), fetchFolderEmailBodyCmd(m.config, msg.UID, msg.AccountID, folderName, msg.Mailbox), m.pluginNotifyCmd()) @@ -894,6 +965,32 @@ func (m *mainModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Update the email in our stores m.updateEmailBodyByUID(msg.UID, msg.AccountID, msg.Mailbox, msg.Body, msg.Attachments) + // Cache the body to disk + folderForCache := "INBOX" + if m.folderInbox != nil { + folderForCache = m.folderInbox.GetCurrentFolder() + } + var cachedAttachments []config.CachedAttachment + for _, a := range msg.Attachments { + cachedAttachments = append(cachedAttachments, config.CachedAttachment{ + Filename: a.Filename, + PartID: a.PartID, + Encoding: a.Encoding, + MIMEType: a.MIMEType, + ContentID: a.ContentID, + Inline: a.Inline, + IsSMIMESignature: a.IsSMIMESignature, + SMIMEVerified: a.SMIMEVerified, + IsSMIMEEncrypted: a.IsSMIMEEncrypted, + }) + } + _ = config.SaveEmailBody(folderForCache, config.CachedEmailBody{ + UID: msg.UID, + AccountID: msg.AccountID, + Body: msg.Body, + Attachments: cachedAttachments, + }) + email := m.getEmailByUIDAndAccount(msg.UID, msg.AccountID, msg.Mailbox) if email == nil { if m.folderInbox != nil { @@ -2865,20 +2962,31 @@ func main() { os.Exit(0) } - cfg, err := config.LoadConfig() - if err == nil && cfg.Theme != "" { - theme.SetTheme(cfg.Theme) - } - tui.RebuildStyles() - - // Ensure PGP keys directory exists - _ = config.EnsurePGPDir() + // Migrate cache files from ~/.config/matcha/ to ~/.cache/matcha/ if needed + _ = config.MigrateCacheFiles() var initialModel *mainModel - if err != nil { + + if config.IsSecureModeEnabled() { + // Secure mode: show password prompt before loading config + tui.RebuildStyles() initialModel = newInitialModel(nil) + initialModel.current = tui.NewPasswordPrompt() } else { - initialModel = newInitialModel(cfg) + cfg, err := config.LoadConfig() + if err == nil && cfg.Theme != "" { + theme.SetTheme(cfg.Theme) + } + tui.RebuildStyles() + + // Ensure PGP keys directory exists + _ = config.EnsurePGPDir() + + if err != nil { + initialModel = newInitialModel(nil) + } else { + initialModel = newInitialModel(cfg) + } } // Initialize plugin system diff --git a/tui/messages.go b/tui/messages.go index 9e2c14705a5e60662df0a5ddb1c7fafacc51ce53..5494cfdf377a16fcf9f3187723d7f5555a803ea3 100644 --- a/tui/messages.go +++ b/tui/messages.go @@ -455,3 +455,19 @@ type PluginPromptCancelMsg struct{} // GoToMarketplaceMsg signals navigation to the plugin marketplace. type GoToMarketplaceMsg struct{} + +// PasswordVerifiedMsg signals that the encryption password was verified (or failed). +type PasswordVerifiedMsg struct { + Key []byte // The derived encryption key (nil on failure) + Err error // Non-nil if verification failed +} + +// SecureModeEnabledMsg signals that encryption was enabled from settings. +type SecureModeEnabledMsg struct { + Err error +} + +// SecureModeDisabledMsg signals that encryption was disabled from settings. +type SecureModeDisabledMsg struct { + Err error +} diff --git a/tui/password_prompt.go b/tui/password_prompt.go new file mode 100644 index 0000000000000000000000000000000000000000..4f5ed3ef6eaeeeb576ed37c7cc8c5ebdd6683372 --- /dev/null +++ b/tui/password_prompt.go @@ -0,0 +1,131 @@ +package tui + +import ( + "strings" + + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/floatpane/matcha/config" +) + +// PasswordPrompt asks the user for their encryption password to unlock the app. +type PasswordPrompt struct { + input textinput.Model + err string + width int + height int + verifying bool +} + +// NewPasswordPrompt creates a new password prompt screen. +func NewPasswordPrompt() *PasswordPrompt { + ti := textinput.New() + ti.Placeholder = "Enter your password" + ti.EchoMode = textinput.EchoPassword + ti.EchoCharacter = '*' + ti.Prompt = "> " + ti.CharLimit = 256 + ti.Focus() + ti.SetStyles(ThemedTextInputStyles()) + + return &PasswordPrompt{ + input: ti, + } +} + +func (m *PasswordPrompt) Init() tea.Cmd { + return textinput.Blink +} + +func (m *PasswordPrompt) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + return m, nil + + case tea.KeyPressMsg: + switch msg.String() { + case "enter": + password := m.input.Value() + if password == "" { + m.err = "Password cannot be empty" + return m, nil + } + m.verifying = true + return m, verifyPasswordCmd(password) + case "ctrl+c": + return m, tea.Quit + } + // Clear error on new input + if m.err != "" { + m.err = "" + } + + case PasswordVerifiedMsg: + if msg.Err != nil { + m.err = msg.Err.Error() + m.verifying = false + m.input.SetValue("") + return m, nil + } + // Password correct — key is in msg.Key + return m, nil + + } + + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd +} + +func (m *PasswordPrompt) View() tea.View { + var b strings.Builder + + b.WriteString(logoStyle.Render(choiceLogo)) + b.WriteString("\n") + + lockTitle := lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFDF5")). + Background(lipgloss.Color("#25A065")). + Padding(0, 1). + Render("Matcha is locked") + + b.WriteString(lockTitle) + b.WriteString("\n\n") + + if m.verifying { + b.WriteString(" Verifying password...\n") + } else { + b.WriteString(" " + m.input.View() + "\n") + } + + if m.err != "" { + errStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")) + b.WriteString("\n" + errStyle.Render(" "+m.err) + "\n") + } + + mainContent := b.String() + helpView := helpStyle.Render("enter: unlock • ctrl+c: quit") + + if m.height > 0 { + currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView)) + gap := m.height - currentHeight + if gap > 0 { + mainContent += strings.Repeat("\n", gap) + } + } else { + mainContent += "\n\n" + } + + return tea.NewView(docStyle.Render(mainContent + helpView)) +} + +// verifyPasswordCmd runs password verification in a goroutine. +func verifyPasswordCmd(password string) tea.Cmd { + return func() tea.Msg { + key, err := config.VerifyPassword(password) + return PasswordVerifiedMsg{Key: key, Err: err} + } +} diff --git a/tui/settings.go b/tui/settings.go index dc30f94da9d38d2e9b9cee1a059dcc365fb22ddf..49a17d41ab9aca0d27aec9b3589fdf265eb810f0 100644 --- a/tui/settings.go +++ b/tui/settings.go @@ -29,6 +29,7 @@ const ( SettingsMailingLists SettingsSMIMEConfig SettingsTheme + SettingsEncryption ) // Settings displays the settings screen. @@ -51,6 +52,14 @@ type Settings struct { pgpPrivateKeyInput textinput.Model pgpKeySource string // "file" or "yubikey" pgpPINInput textinput.Model + + // Encryption fields + encPasswordInput textinput.Model + encConfirmInput textinput.Model + encFocusIndex int + encError string + encEnabling bool // true when enabling encryption in progress + confirmingDisable bool // true when confirming disable } // NewSettings creates a new settings model. @@ -93,6 +102,22 @@ func NewSettings(cfg *config.Config) *Settings { pgpPINInput.EchoCharacter = '*' pgpPINInput.SetStyles(tiStyles) + encPassInput := textinput.New() + encPassInput.Placeholder = "Password" + encPassInput.Prompt = "> " + encPassInput.CharLimit = 256 + encPassInput.EchoMode = textinput.EchoPassword + encPassInput.EchoCharacter = '*' + encPassInput.SetStyles(tiStyles) + + encConfInput := textinput.New() + encConfInput.Placeholder = "Confirm Password" + encConfInput.Prompt = "> " + encConfInput.CharLimit = 256 + encConfInput.EchoMode = textinput.EchoPassword + encConfInput.EchoCharacter = '*' + encConfInput.SetStyles(tiStyles) + return &Settings{ cfg: cfg, state: SettingsMain, @@ -103,6 +128,8 @@ func NewSettings(cfg *config.Config) *Settings { pgpPrivateKeyInput: pgpPrivInput, pgpKeySource: "file", // Default to file-based keys pgpPINInput: pgpPINInput, + encPasswordInput: encPassInput, + encConfirmInput: encConfInput, } } @@ -139,9 +166,38 @@ func (m *Settings) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m2, cmd = m.updateSMIMEConfig(msg) cmds = append(cmds, cmd) return m2, tea.Batch(cmds...) + } else if m.state == SettingsEncryption { + return m.updateEncryption(msg) } else { return m.updateAccounts(msg) } + + case SecureModeEnabledMsg: + m.encEnabling = false + if msg.Err != nil { + m.encError = msg.Err.Error() + return m, nil + } + m.state = SettingsMain + m.cursor = 7 + return m, nil + + case SecureModeDisabledMsg: + if msg.Err != nil { + m.encError = msg.Err.Error() + return m, nil + } + m.confirmingDisable = false + m.state = SettingsMain + m.cursor = 7 + return m, nil + } + + if m.state == SettingsEncryption { + m.encPasswordInput, cmd = m.encPasswordInput.Update(msg) + cmds = append(cmds, cmd) + m.encConfirmInput, cmd = m.encConfirmInput.Update(msg) + cmds = append(cmds, cmd) } if m.state == SettingsSMIMEConfig { @@ -167,8 +223,8 @@ func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.cursor-- } case "down", "j": - // Options: 0: Email Accounts, 1: Theme, 2: Image Display, 3: Edit Signature, 4: Contextual Tips, 5: Desktop Notifications, 6: Mailing Lists - if m.cursor < 6 { + // Options: 0: Email Accounts, 1: Theme, 2: Image Display, 3: Edit Signature, 4: Contextual Tips, 5: Desktop Notifications, 6: Mailing Lists, 7: Encryption + if m.cursor < 7 { m.cursor++ } case "enter": @@ -207,6 +263,20 @@ func (m *Settings) updateMain(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { m.state = SettingsMailingLists m.cursor = 0 return m, nil + case 7: // Encryption + m.state = SettingsEncryption + m.encError = "" + m.encPasswordInput.SetValue("") + m.encConfirmInput.SetValue("") + m.encFocusIndex = 0 + m.confirmingDisable = false + m.encEnabling = false + if !config.IsSecureModeEnabled() { + m.encPasswordInput.Focus() + m.encConfirmInput.Blur() + return m, textinput.Blink + } + return m, nil } case "esc": return m, func() tea.Msg { return GoToChoiceMenuMsg{} } @@ -517,6 +587,8 @@ func (m *Settings) View() tea.View { return tea.NewView(m.viewMailingLists()) } else if m.state == SettingsSMIMEConfig { return tea.NewView(m.viewSMIMEConfig()) + } else if m.state == SettingsEncryption { + return tea.NewView(m.viewEncryption()) } return tea.NewView(m.viewAccounts()) } @@ -601,6 +673,19 @@ func (m *Settings) viewMain() string { } else { b.WriteString(accountItemStyle.Render(" " + mailingListsText)) } + b.WriteString("\n") + + // Option 7: Encryption + encStatus := "OFF" + if config.IsSecureModeEnabled() { + encStatus = "ON" + } + encText := fmt.Sprintf("Encryption: %s", encStatus) + if m.cursor == 7 { + b.WriteString(selectedAccountItemStyle.Render("> " + encText)) + } else { + b.WriteString(accountItemStyle.Render(" " + encText)) + } b.WriteString("\n\n") if !m.cfg.HideTips { @@ -620,6 +705,8 @@ func (m *Settings) viewMain() string { tip = "Toggle desktop notifications when new mail arrives." case 6: tip = "Manage groups of email addresses to quickly send to multiple people." + case 7: + tip = "Encrypt all data with a password. You'll need to enter it each time you open matcha." } if tip != "" { b.WriteString(TipStyle.Render("Tip: "+tip) + "\n\n") @@ -1044,6 +1131,177 @@ func renderThemePreview(t theme.Theme, maxWidth int) string { return box } +func (m *Settings) updateEncryption(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) { + isEnabled := config.IsSecureModeEnabled() + + if isEnabled { + // Disable flow: confirmation dialog + if m.confirmingDisable { + switch msg.String() { + case "y", "Y": + m.confirmingDisable = false + cfg := m.cfg + return m, func() tea.Msg { + err := config.DisableSecureMode(cfg) + return SecureModeDisabledMsg{Err: err} + } + case "n", "N", "esc": + m.confirmingDisable = false + m.state = SettingsMain + m.cursor = 7 + return m, nil + } + return m, nil + } + + // Show disable prompt + m.confirmingDisable = true + return m, nil + } + + // Enable flow: password + confirm + switch msg.String() { + case "esc": + m.state = SettingsMain + m.cursor = 7 + return m, nil + case "tab", "shift+tab", "down", "up": + if msg.String() == "shift+tab" || msg.String() == "up" { + m.encFocusIndex-- + if m.encFocusIndex < 0 { + m.encFocusIndex = 2 // wrap to cancel + } + } else { + m.encFocusIndex++ + if m.encFocusIndex > 2 { + m.encFocusIndex = 0 + } + } + m.encPasswordInput.Blur() + m.encConfirmInput.Blur() + var cmds []tea.Cmd + switch m.encFocusIndex { + case 0: + cmds = append(cmds, m.encPasswordInput.Focus()) + case 1: + cmds = append(cmds, m.encConfirmInput.Focus()) + } + return m, tea.Batch(cmds...) + case "enter": + switch m.encFocusIndex { + case 0: // Password field — advance to confirm + m.encFocusIndex = 1 + m.encPasswordInput.Blur() + return m, m.encConfirmInput.Focus() + case 1: // Confirm field — advance to save + m.encFocusIndex = 2 + m.encConfirmInput.Blur() + return m, nil + case 2: // Save button + password := m.encPasswordInput.Value() + confirm := m.encConfirmInput.Value() + if password == "" { + m.encError = "Password cannot be empty" + return m, nil + } + if password != confirm { + m.encError = "Passwords do not match" + return m, nil + } + m.encEnabling = true + m.encError = "" + cfg := m.cfg + return m, func() tea.Msg { + err := config.EnableSecureMode(password, cfg) + return SecureModeEnabledMsg{Err: err} + } + } + } + + // Update text inputs + var cmd tea.Cmd + switch m.encFocusIndex { + case 0: + m.encPasswordInput, cmd = m.encPasswordInput.Update(msg) + case 1: + m.encConfirmInput, cmd = m.encConfirmInput.Update(msg) + } + return m, cmd +} + +func (m *Settings) viewEncryption() string { + var b strings.Builder + + isEnabled := config.IsSecureModeEnabled() + + b.WriteString(titleStyle.Render("Encryption") + "\n\n") + + if isEnabled { + // Disable mode + if m.confirmingDisable { + dialog := DialogBoxStyle.Render( + lipgloss.JoinVertical(lipgloss.Center, + dangerStyle.Render("Disable encryption?"), + accountEmailStyle.Render("All data will be stored unencrypted."), + HelpStyle.Render("\n(y/n)"), + ), + ) + return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, dialog) + } + + b.WriteString(settingsFocusedStyle.Render(" Encryption is currently enabled.") + "\n\n") + b.WriteString(accountEmailStyle.Render(" Press enter to disable encryption.") + "\n") + } else { + // Enable mode + b.WriteString(accountEmailStyle.Render(" Set a password to encrypt all data.") + "\n\n") + + if m.encFocusIndex == 0 { + b.WriteString(settingsFocusedStyle.Render("Password:\n")) + } else { + b.WriteString(settingsBlurredStyle.Render("Password:\n")) + } + b.WriteString(m.encPasswordInput.View() + "\n\n") + + if m.encFocusIndex == 1 { + b.WriteString(settingsFocusedStyle.Render("Confirm Password:\n")) + } else { + b.WriteString(settingsBlurredStyle.Render("Confirm Password:\n")) + } + b.WriteString(m.encConfirmInput.View() + "\n\n") + + saveBtn := "[ Enable Encryption ]" + if m.encFocusIndex == 2 { + saveBtn = settingsFocusedStyle.Render(saveBtn) + } else { + saveBtn = settingsBlurredStyle.Render(saveBtn) + } + b.WriteString(saveBtn + "\n") + + if m.encEnabling { + b.WriteString("\n" + accountEmailStyle.Render(" Encrypting data...") + "\n") + } + } + + if m.encError != "" { + b.WriteString("\n" + dangerStyle.Render(" "+m.encError) + "\n") + } + + mainContent := b.String() + helpView := helpStyle.Render("tab/shift+tab: navigate • enter: select • esc: back") + + if m.height > 0 { + currentHeight := lipgloss.Height(docStyle.Render(mainContent + helpView)) + gap := m.height - currentHeight + if gap > 0 { + mainContent += strings.Repeat("\n", gap) + } + } else { + mainContent += "\n\n" + } + + return docStyle.Render(mainContent + helpView) +} + // UpdateConfig updates the configuration (used when accounts are deleted). func (m *Settings) UpdateConfig(cfg *config.Config) { m.cfg = cfg