chore: use secretbox (#1393)

Drew Smirnoff created

## What?

Switches to [`go-secretbox`](https://github.com/floatpane/go-secretbox).

## Why?

Makes the code reusable and easier to maintain.

Signed-off-by: drew <me@andrinoff.com>

Change summary

config/encryption.go             | 480 +++++++++++++++++----------------
docs/docs/Features/Encryption.md |  36 --
go.mod                           |   3 
go.sum                           |   2 
4 files changed, 248 insertions(+), 273 deletions(-)

Detailed changes

config/encryption.go πŸ”—

@@ -1,34 +1,23 @@
 package config
 
 import (
-	"crypto/aes"
-	"crypto/cipher"
-	"crypto/rand"
 	"encoding/base64"
 	"encoding/json"
 	"errors"
 	"fmt"
 	"os"
 	"path/filepath"
+	"strings"
 	"sync"
 
-	"golang.org/x/crypto/argon2"
+	"github.com/floatpane/go-secretbox"
 )
 
-const (
-	sentinelPlaintext = "matcha-verified"
-	secureMetaFile    = "secure.meta"
+const 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 {
+// legacyMeta is the secure.meta format written before go-secretbox was
+// integrated. It is read-only; the first successful unlock auto-migrates it.
+type legacyMeta struct {
 	Salt          string `json:"salt"`
 	Sentinel      string `json:"sentinel"`
 	Argon2Time    uint32 `json:"argon2_time"`
@@ -36,323 +25,201 @@ type secureMeta struct {
 	Argon2Threads uint8  `json:"argon2_threads"`
 }
 
+// legacySentinel is the plaintext the old code encrypted as the verification token.
+const legacySentinel = "matcha-verified"
+
 var (
-	sessionKey   []byte
-	sessionKeyMu sync.RWMutex
+	vaultMu     sync.Mutex
+	cachedVault *secretbox.Vault
 )
 
-// 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
+// getVault returns the shared Vault instance, creating it on first call.
+func getVault() *secretbox.Vault {
+	vaultMu.Lock()
+	defer vaultMu.Unlock()
+	if cachedVault == nil {
+		path, _ := secureMetaPath()
+		cachedVault = secretbox.NewVault(path)
 	}
-	sessionKey = nil
+	return cachedVault
 }
 
-// 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)
+// 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
 }
 
-// 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)
+// IsSecureModeEnabled checks whether encryption is active by looking for secure.meta.
+func IsSecureModeEnabled() bool {
+	return getVault().Initialized()
 }
 
 // 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
+	return secretbox.AESGCM{}.Encrypt(plaintext, key)
 }
 
 // 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 {
+	plain, err := secretbox.AESGCM{}.Decrypt(ciphertext, key)
+	if errors.Is(err, secretbox.ErrDecrypt) {
 		return nil, fmt.Errorf("decryption: %w", err)
 	}
-	return plaintext, nil
+	return plain, err
 }
 
-// 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
+// DeriveKey derives an AES-256 key from a password and salt using Argon2id.
+func DeriveKey(password string, salt []byte) []byte {
+	return secretbox.NewArgon2id(secretbox.DefaultArgon2id).DeriveKey(password, salt, 32)
 }
 
-// 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
+// SetSessionKey is kept for API compatibility. VerifyPassword unlocks the vault
+// internally, so callers that pass the returned key back through SetSessionKey
+// are no-ops β€” the vault is already in the correct state.
+func SetSessionKey(_ []byte) {}
+
+// GetSessionKey returns the current session key, or nil if the vault is locked.
+// The caller must not modify the returned slice.
+func GetSessionKey() []byte {
+	return getVault().Key()
 }
 
-// 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
+// ClearSessionKey removes the session key from memory.
+func ClearSessionKey() {
+	getVault().Lock()
 }
 
-// VerifyPassword checks the password against the stored sentinel.
-// Returns the derived key on success.
+// VerifyPassword checks the password against the stored sentinel and unlocks
+// the vault. Returns the derived key for callers that store it via SetSessionKey.
 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)
+	v := getVault()
 
-	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 err := migrateLegacyMeta(password); err != nil {
+		return nil, err
 	}
 
-	if string(plaintext) != sentinelPlaintext {
-		return nil, errors.New("incorrect password")
+	// migrateLegacyMeta may have left the vault unlocked via Init; skip Unlock
+	// in that case to avoid a redundant (and slow) Argon2id call.
+	if v.Locked() {
+		if err := v.Unlock(password); err != nil {
+			if errors.Is(err, secretbox.ErrWrongPassword) {
+				return nil, errors.New("incorrect password")
+			}
+			return nil, fmt.Errorf("could not read secure metadata: %w", err)
+		}
 	}
-
-	return key, nil
+	return v.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.
+// EnableSecureMode sets up encryption with the given password. It initialises
+// the vault, re-saves the config with passwords embedded in the encrypted JSON,
+// and re-encrypts all existing data files.
 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,
+	v := getVault()
+	if err := v.Init(password); err != nil {
+		return fmt.Errorf("could not initialise secure vault: %w", err)
 	}
 
-	metaData, err := json.MarshalIndent(meta, "", "  ")
-	if err != nil {
-		return err
+	rollback := func() {
+		v.Lock()
+		path, _ := secureMetaPath()
+		_ = os.Remove(path)
 	}
 
-	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()
+			rollback()
 			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()
+		rollback()
 		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
+	v := getVault()
+
 	files, err := collectDataFiles()
 	if err != nil {
 		return err
 	}
-
-	// Find config.json path to skip it (handled separately below)
 	cfgPath, _ := configFile()
 
-	// Copy the key so ClearSessionKey's in-place zeroing doesn't destroy it.
-	origKey := GetSessionKey()
-	key := make([]byte, len(origKey))
-	copy(key, origKey)
-
-	// Decrypt all cache files and write them back as plain data.
-	// We use Decrypt directly instead of toggling the session key, because
-	// ClearSessionKey zeroes the slice in-place which would corrupt our copy.
 	for _, f := range files {
 		if f == cfgPath {
 			continue
 		}
-		encrypted, err := os.ReadFile(f)
+		enc, err := os.ReadFile(f)
 		if err != nil {
-			continue // File may not exist
+			continue
 		}
-		plain, err := Decrypt(encrypted, key)
+		plain, err := v.Decrypt(enc)
 		if err != nil {
-			continue // File may not be encrypted
+			continue // file may not be encrypted
 		}
-		if err := os.WriteFile(f, plain, 0600); err != nil { //nolint:gosec
+		if err := writeDataFile(f, plain, 0o600); err != nil {
 			return err
 		}
 	}
 
-	// Clear session key so SaveConfig writes plain JSON and restores passwords to keyring
-	ClearSessionKey()
+	// Lock before SaveConfig so it writes plain JSON and restores keyring passwords.
+	v.Lock()
 
-	// 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
-	}
+	path, _ := secureMetaPath()
 	_ = os.Remove(path)
-
 	return nil
 }
 
-// SecureReadFile reads a file, decrypting it if a session key is set.
+// SecureReadFile reads a file, decrypting it if the vault is unlocked.
 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
+	v := getVault()
+	if v.Locked() {
+		return os.ReadFile(path)
 	}
-	return Decrypt(data, key)
+	return v.ReadFile(path)
 }
 
-// SecureWriteFile writes data to a file, encrypting it if a session key is set.
+// SecureWriteFile writes data to a file, encrypting it if the vault is unlocked.
 func SecureWriteFile(path string, data []byte, perm os.FileMode) error {
-	key := GetSessionKey()
-	if key == nil {
+	v := getVault()
+	if v.Locked() {
 		return os.WriteFile(path, data, perm) //nolint:gosec
 	}
-	encrypted, err := Encrypt(data, key)
-	if err != nil {
-		return err
-	}
-	return os.WriteFile(path, encrypted, perm) //nolint:gosec
+	return v.WriteFile(path, data, perm)
 }
 
-// reEncryptCacheFiles reads all plain cache/data files (excluding config.json) and writes them encrypted.
+// reEncryptCacheFiles reads all plain data files (excluding config.json) and
+// writes them encrypted via the unlocked vault.
 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
+			continue
 		}
-		plainData, err := os.ReadFile(f)
+		data, err := os.ReadFile(f)
 		if err != nil {
-			continue // File may not exist
+			continue
 		}
-		// Write encrypted using SecureWriteFile (session key is already set)
-		if err := SecureWriteFile(f, plainData, 0600); err != nil {
+		if err := secureWriteDataFile(f, data, 0o600); err != nil {
 			return err
 		}
 	}
@@ -363,14 +230,12 @@ func reEncryptCacheFiles() error {
 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
@@ -385,21 +250,160 @@ func collectDataFiles() ([]string, error) {
 		if entries, err := os.ReadDir(dir); err == nil {
 			for _, entry := range entries {
 				if !entry.IsDir() {
-					files = append(files, filepath.Join(dir, entry.Name()))
+					// filepath.Base strips any directory components from the
+					// entry name, preventing traversal via crafted filenames.
+					files = append(files, filepath.Join(dir, filepath.Base(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()))
+				files = append(files, filepath.Join(sigDir, filepath.Base(entry.Name())))
 			}
 		}
 	}
 
 	return files, nil
 }
+
+// migrateLegacyMeta detects a pre-go-secretbox secure.meta and converts it.
+// It verifies the password via the old sentinel, decrypts all existing files
+// with the old key, rewrites metadata in the new format, and re-encrypts.
+// A wrong password during migration returns errors.New("incorrect password").
+// If the file is absent or already in the new format, it is a no-op.
+func migrateLegacyMeta(password string) error {
+	path, err := secureMetaPath()
+	if err != nil || path == "" {
+		return nil
+	}
+	data, err := os.ReadFile(path)
+	if err != nil {
+		return nil
+	}
+
+	// Old format has "argon2_time"; new format does not.
+	var probe struct {
+		ArgonTime uint32 `json:"argon2_time"`
+	}
+	if json.Unmarshal(data, &probe) != nil || probe.ArgonTime == 0 {
+		return nil // already new format
+	}
+
+	var old legacyMeta
+	if err := json.Unmarshal(data, &old); err != nil {
+		return fmt.Errorf("corrupt legacy metadata: %w", err)
+	}
+	salt, err := base64.StdEncoding.DecodeString(old.Salt)
+	if err != nil {
+		return fmt.Errorf("corrupt legacy salt: %w", err)
+	}
+
+	// Derive old key and verify password via the legacy sentinel.
+	oldKDF := secretbox.NewArgon2id(secretbox.Argon2idParams{
+		Time:    old.Argon2Time,
+		Memory:  old.Argon2Memory,
+		Threads: old.Argon2Threads,
+	})
+	oldKey := oldKDF.DeriveKey(password, salt, 32)
+	defer zeroSlice(oldKey)
+
+	sentinelCipher, err := base64.StdEncoding.DecodeString(old.Sentinel)
+	if err != nil {
+		return fmt.Errorf("corrupt legacy sentinel: %w", err)
+	}
+	plain, err := secretbox.AESGCM{}.Decrypt(sentinelCipher, oldKey)
+	if err != nil || string(plain) != legacySentinel {
+		return errors.New("incorrect password")
+	}
+
+	// Decrypt every data file with the old key before we touch the vault.
+	type decryptedFile struct {
+		path string
+		perm os.FileMode
+		data []byte
+	}
+	files, _ := collectDataFiles()
+	var pending []decryptedFile
+	for _, f := range files {
+		enc, err := os.ReadFile(f)
+		if err != nil {
+			continue
+		}
+		pt, err := secretbox.AESGCM{}.Decrypt(enc, oldKey)
+		if err != nil {
+			continue // not encrypted or not with this key
+		}
+		perm := os.FileMode(0o600)
+		if info, err := os.Stat(f); err == nil {
+			perm = info.Mode().Perm()
+		}
+		pending = append(pending, decryptedFile{f, perm, pt})
+	}
+
+	// Remove old meta and initialise the new-format vault.
+	_ = os.Remove(path)
+	v := getVault()
+	if err := v.Init(password); err != nil {
+		return fmt.Errorf("migration: vault init: %w", err)
+	}
+
+	// Re-encrypt with the new vault key.
+	for _, p := range pending {
+		_ = v.WriteFile(p.path, p.data, p.perm)
+	}
+	return nil
+}
+
+// secureWriteDataFile validates path is within app directories then delegates
+// to SecureWriteFile (which encrypts when the vault is unlocked).
+func secureWriteDataFile(path string, data []byte, perm os.FileMode) error {
+	cfgDir, err := configDir()
+	if err != nil {
+		return err
+	}
+	cDir, err := cacheDir()
+	if err != nil {
+		return err
+	}
+	clean := filepath.Clean(path)
+	if !isUnder(clean, cfgDir) && !isUnder(clean, cDir) {
+		return fmt.Errorf("config: refusing write outside app directories: %s", clean)
+	}
+	return SecureWriteFile(clean, data, perm)
+}
+
+// writeDataFile writes data to path only if path is within the application's
+// config or cache directories. It cleans the path first. This breaks the taint
+// flow for any directory-entry–derived paths returned by collectDataFiles.
+func writeDataFile(path string, data []byte, perm os.FileMode) error {
+	cfgDir, err := configDir()
+	if err != nil {
+		return err
+	}
+	cDir, err := cacheDir()
+	if err != nil {
+		return err
+	}
+	clean := filepath.Clean(path)
+	if !isUnder(clean, cfgDir) && !isUnder(clean, cDir) {
+		return fmt.Errorf("config: refusing write outside app directories: %s", clean)
+	}
+	return os.WriteFile(clean, data, perm) //nolint:gosec
+}
+
+// isUnder reports whether path is inside (or equal to) base.
+// Uses filepath.Rel so it is OS-path-separator aware.
+func isUnder(path, base string) bool {
+	rel, err := filepath.Rel(base, path)
+	return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
+}
+
+func zeroSlice(b []byte) {
+	for i := range b {
+		b[i] = 0
+	}
+}

docs/docs/Features/Encryption.md πŸ”—

@@ -1,16 +1,8 @@
 # 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.
+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.
+Encryption is powered by [**go-secretbox**](https://github.com/floatpane/go-secretbox). Full specification and technical details are at [secretbox.floatpane.com](https://secretbox.floatpane.com).
 
 ## Enabling Encryption
 
@@ -43,30 +35,6 @@ Enter your password to decrypt and proceed. If the password is wrong, you'll see
 
 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.

go.mod πŸ”—

@@ -18,6 +18,7 @@ require (
 	github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
 	github.com/floatpane/bubble-overlay v0.0.1
 	github.com/floatpane/go-openpgp-card-hl v0.0.1
+	github.com/floatpane/go-secretbox v0.1.0
 	github.com/floatpane/go-uds-jsonrpc v0.0.1
 	github.com/floatpane/jwz-go v0.0.1
 	github.com/floatpane/termimage v0.2.1
@@ -30,7 +31,6 @@ 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.52.0
 	golang.org/x/term v0.43.0
 	golang.org/x/text v0.37.0
 )
@@ -60,6 +60,7 @@ require (
 	github.com/rivo/uniseg v0.4.7 // indirect
 	github.com/sahilm/fuzzy v0.1.1 // indirect
 	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+	golang.org/x/crypto v0.52.0 // indirect
 	golang.org/x/image v0.41.0 // indirect
 	golang.org/x/net v0.55.0 // indirect
 	golang.org/x/oauth2 v0.27.0 // indirect

go.sum πŸ”—

@@ -72,6 +72,8 @@ github.com/floatpane/bubble-overlay v0.0.1 h1:5xU8cNigDPYegvgGMfOG23fIDXhrqXPvLT
 github.com/floatpane/bubble-overlay v0.0.1/go.mod h1:Csi1byxb9L8EAb8X13XdWF5aX5YiBD5C9WEWACyGa8A=
 github.com/floatpane/go-openpgp-card-hl v0.0.1 h1:1DYmzwGDb8eneZxbc/xtwjXeFY8DFL3eYnUooMT0L0w=
 github.com/floatpane/go-openpgp-card-hl v0.0.1/go.mod h1:Mrx+ukCnpEpMAxyB0p8Ch2gu78Q3Ir40BxBybb2jirw=
+github.com/floatpane/go-secretbox v0.1.0 h1:xNryazmCP0oR/yVxIkHRc5bcV56YrbisY+bMl8BBfwU=
+github.com/floatpane/go-secretbox v0.1.0/go.mod h1:uNVTLb1jty9ZT5HIp3zdFIB3K4V3r2V38QaI+bWMVao=
 github.com/floatpane/go-uds-jsonrpc v0.0.1 h1:/sBlCXVAP9SyLWLj0wlFI07dX/SfXeUM67B4tRwK2QA=
 github.com/floatpane/go-uds-jsonrpc v0.0.1/go.mod h1:G/YeDIocGkPIU+uyhJ/e8ynn9wIEMIkJ74d3VUuC4rM=
 github.com/floatpane/jwz-go v0.0.1 h1:OAl/vaUYn+/8zFR47WaCybBGoQItb1ZkFplNrmeO3ps=