encryption.go

  1package config
  2
  3import (
  4	"crypto/aes"
  5	"crypto/cipher"
  6	"crypto/rand"
  7	"encoding/base64"
  8	"encoding/json"
  9	"errors"
 10	"fmt"
 11	"os"
 12	"path/filepath"
 13	"sync"
 14
 15	"golang.org/x/crypto/argon2"
 16)
 17
 18const (
 19	sentinelPlaintext = "matcha-verified"
 20	secureMetaFile    = "secure.meta"
 21
 22	// Argon2id parameters
 23	argon2Time    = 3
 24	argon2Memory  = 64 * 1024 // 64 MB
 25	argon2Threads = 4
 26	argon2KeyLen  = 32 // AES-256
 27)
 28
 29// secureMeta is stored as plain JSON at ~/.config/matcha/secure.meta.
 30// Its existence signals that secure mode is enabled.
 31type secureMeta struct {
 32	Salt          string `json:"salt"`
 33	Sentinel      string `json:"sentinel"`
 34	Argon2Time    uint32 `json:"argon2_time"`
 35	Argon2Memory  uint32 `json:"argon2_memory"`
 36	Argon2Threads uint8  `json:"argon2_threads"`
 37}
 38
 39var (
 40	sessionKey   []byte
 41	sessionKeyMu sync.RWMutex
 42)
 43
 44// SetSessionKey stores the derived encryption key in memory for this session.
 45func SetSessionKey(key []byte) {
 46	sessionKeyMu.Lock()
 47	defer sessionKeyMu.Unlock()
 48	sessionKey = key
 49}
 50
 51// GetSessionKey returns the current session key, or nil if not set.
 52func GetSessionKey() []byte {
 53	sessionKeyMu.RLock()
 54	defer sessionKeyMu.RUnlock()
 55	return sessionKey
 56}
 57
 58// ClearSessionKey removes the session key from memory.
 59func ClearSessionKey() {
 60	sessionKeyMu.Lock()
 61	defer sessionKeyMu.Unlock()
 62	// Overwrite key material before clearing
 63	for i := range sessionKey {
 64		sessionKey[i] = 0
 65	}
 66	sessionKey = nil
 67}
 68
 69// DeriveKey derives an AES-256 key from a password and salt using Argon2id.
 70func DeriveKey(password string, salt []byte) []byte {
 71	return argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen)
 72}
 73
 74// deriveKeyWithParams derives a key using specific Argon2id parameters (for loading existing meta).
 75func deriveKeyWithParams(password string, salt []byte, time, memory uint32, threads uint8) []byte {
 76	return argon2.IDKey([]byte(password), salt, time, memory, threads, argon2KeyLen)
 77}
 78
 79// Encrypt encrypts plaintext using AES-256-GCM. The nonce is prepended to the ciphertext.
 80func Encrypt(plaintext, key []byte) ([]byte, error) {
 81	block, err := aes.NewCipher(key)
 82	if err != nil {
 83		return nil, fmt.Errorf("encryption: %w", err)
 84	}
 85	aesGCM, err := cipher.NewGCM(block)
 86	if err != nil {
 87		return nil, fmt.Errorf("encryption: %w", err)
 88	}
 89	nonce := make([]byte, aesGCM.NonceSize())
 90	if _, err := rand.Read(nonce); err != nil {
 91		return nil, fmt.Errorf("encryption: %w", err)
 92	}
 93	return aesGCM.Seal(nonce, nonce, plaintext, nil), nil
 94}
 95
 96// Decrypt decrypts ciphertext produced by Encrypt using AES-256-GCM.
 97func Decrypt(ciphertext, key []byte) ([]byte, error) {
 98	block, err := aes.NewCipher(key)
 99	if err != nil {
100		return nil, fmt.Errorf("decryption: %w", err)
101	}
102	aesGCM, err := cipher.NewGCM(block)
103	if err != nil {
104		return nil, fmt.Errorf("decryption: %w", err)
105	}
106	nonceSize := aesGCM.NonceSize()
107	if len(ciphertext) < nonceSize {
108		return nil, errors.New("decryption: ciphertext too short")
109	}
110	nonce, encrypted := ciphertext[:nonceSize], ciphertext[nonceSize:]
111	plaintext, err := aesGCM.Open(nil, nonce, encrypted, nil)
112	if err != nil {
113		return nil, fmt.Errorf("decryption: %w", err)
114	}
115	return plaintext, nil
116}
117
118// secureMetaPath returns the path to the secure.meta file.
119func secureMetaPath() (string, error) {
120	dir, err := configDir()
121	if err != nil {
122		return "", err
123	}
124	return filepath.Join(dir, secureMetaFile), nil
125}
126
127// IsSecureModeEnabled checks whether encryption is active by looking for secure.meta.
128func IsSecureModeEnabled() bool {
129	path, err := secureMetaPath()
130	if err != nil {
131		return false
132	}
133	_, err = os.Stat(path)
134	return err == nil
135}
136
137// loadSecureMeta reads and parses the secure.meta file.
138func loadSecureMeta() (*secureMeta, error) {
139	path, err := secureMetaPath()
140	if err != nil {
141		return nil, err
142	}
143	data, err := os.ReadFile(path)
144	if err != nil {
145		return nil, err
146	}
147	var meta secureMeta
148	if err := json.Unmarshal(data, &meta); err != nil {
149		return nil, err
150	}
151	return &meta, nil
152}
153
154// VerifyPassword checks the password against the stored sentinel.
155// Returns the derived key on success.
156func VerifyPassword(password string) ([]byte, error) {
157	meta, err := loadSecureMeta()
158	if err != nil {
159		return nil, fmt.Errorf("could not read secure metadata: %w", err)
160	}
161
162	salt, err := base64.StdEncoding.DecodeString(meta.Salt)
163	if err != nil {
164		return nil, fmt.Errorf("invalid salt: %w", err)
165	}
166
167	key := deriveKeyWithParams(password, salt, meta.Argon2Time, meta.Argon2Memory, meta.Argon2Threads)
168
169	sentinelCiphertext, err := base64.StdEncoding.DecodeString(meta.Sentinel)
170	if err != nil {
171		return nil, fmt.Errorf("invalid sentinel: %w", err)
172	}
173
174	plaintext, err := Decrypt(sentinelCiphertext, key)
175	if err != nil {
176		return nil, errors.New("incorrect password")
177	}
178
179	if string(plaintext) != sentinelPlaintext {
180		return nil, errors.New("incorrect password")
181	}
182
183	return key, nil
184}
185
186// EnableSecureMode sets up encryption with the given password.
187// It generates a salt, derives a key, encrypts the sentinel, saves secure.meta,
188// and re-encrypts all existing data files. The config must be passed so that
189// passwords (normally stored in the OS keyring) can be written into the encrypted config.
190func EnableSecureMode(password string, cfg *Config) error {
191	// Generate random salt
192	salt := make([]byte, 32)
193	if _, err := rand.Read(salt); err != nil {
194		return fmt.Errorf("could not generate salt: %w", err)
195	}
196
197	key := DeriveKey(password, salt)
198
199	// Encrypt sentinel
200	sentinelCipher, err := Encrypt([]byte(sentinelPlaintext), key)
201	if err != nil {
202		return fmt.Errorf("could not encrypt sentinel: %w", err)
203	}
204
205	meta := secureMeta{
206		Salt:          base64.StdEncoding.EncodeToString(salt),
207		Sentinel:      base64.StdEncoding.EncodeToString(sentinelCipher),
208		Argon2Time:    argon2Time,
209		Argon2Memory:  argon2Memory,
210		Argon2Threads: argon2Threads,
211	}
212
213	metaData, err := json.MarshalIndent(meta, "", "  ")
214	if err != nil {
215		return err
216	}
217
218	path, err := secureMetaPath()
219	if err != nil {
220		return err
221	}
222	if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
223		return err
224	}
225
226	// Set the session key so SecureWriteFile will encrypt
227	SetSessionKey(key)
228
229	// Re-save config first — this writes passwords into the encrypted JSON
230	// (SaveConfig uses secureDiskConfig when session key is set)
231	if cfg != nil {
232		if err := SaveConfig(cfg); err != nil {
233			ClearSessionKey()
234			return fmt.Errorf("failed to save encrypted config: %w", err)
235		}
236	}
237
238	// Re-encrypt all remaining data files (caches, signatures, etc.)
239	if err := reEncryptCacheFiles(); err != nil {
240		ClearSessionKey()
241		return fmt.Errorf("failed to encrypt existing files: %w", err)
242	}
243
244	// Write secure.meta last (plain JSON, not encrypted)
245	if err := os.WriteFile(path, metaData, 0600); err != nil {
246		ClearSessionKey()
247		return err
248	}
249
250	return nil
251}
252
253// DisableSecureMode decrypts all files back to plain JSON and removes secure.meta.
254// The config must be passed so passwords can be restored to the OS keyring.
255func DisableSecureMode(cfg *Config) error {
256	// Collect all files that need decryption
257	files, err := collectDataFiles()
258	if err != nil {
259		return err
260	}
261
262	// Find config.json path to skip it (handled separately below)
263	cfgPath, _ := configFile()
264
265	// Read and decrypt all cache files while we still have the session key
266	key := GetSessionKey()
267	for _, f := range files {
268		if f == cfgPath {
269			continue
270		}
271		data, err := SecureReadFile(f)
272		if err != nil {
273			continue // File may not exist
274		}
275		// Write plain
276		ClearSessionKey()
277		if err := os.WriteFile(f, data, 0600); err != nil {
278			SetSessionKey(key) // Restore on error
279			return err
280		}
281		SetSessionKey(key)
282	}
283
284	// Clear session key so SaveConfig writes plain JSON and restores passwords to keyring
285	ClearSessionKey()
286
287	// Re-save config — this will use the keyring (no session key) and strip passwords from JSON
288	if cfg != nil {
289		if err := SaveConfig(cfg); err != nil {
290			return fmt.Errorf("failed to save plain config: %w", err)
291		}
292	}
293
294	// Remove secure.meta
295	path, err := secureMetaPath()
296	if err != nil {
297		return err
298	}
299	_ = os.Remove(path)
300
301	return nil
302}
303
304// SecureReadFile reads a file, decrypting it if a session key is set.
305func SecureReadFile(path string) ([]byte, error) {
306	data, err := os.ReadFile(path)
307	if err != nil {
308		return nil, err
309	}
310	key := GetSessionKey()
311	if key == nil {
312		return data, nil
313	}
314	return Decrypt(data, key)
315}
316
317// SecureWriteFile writes data to a file, encrypting it if a session key is set.
318func SecureWriteFile(path string, data []byte, perm os.FileMode) error {
319	key := GetSessionKey()
320	if key == nil {
321		return os.WriteFile(path, data, perm)
322	}
323	encrypted, err := Encrypt(data, key)
324	if err != nil {
325		return err
326	}
327	return os.WriteFile(path, encrypted, perm)
328}
329
330// reEncryptCacheFiles reads all plain cache/data files (excluding config.json) and writes them encrypted.
331func reEncryptCacheFiles() error {
332	files, err := collectDataFiles()
333	if err != nil {
334		return err
335	}
336
337	// Find config.json path to skip it (already saved separately with passwords)
338	cfgPath, _ := configFile()
339
340	for _, f := range files {
341		if f == cfgPath {
342			continue // Already handled by SaveConfig
343		}
344		plainData, err := os.ReadFile(f)
345		if err != nil {
346			continue // File may not exist
347		}
348		// Write encrypted using SecureWriteFile (session key is already set)
349		if err := SecureWriteFile(f, plainData, 0600); err != nil {
350			return err
351		}
352	}
353	return nil
354}
355
356// collectDataFiles returns paths to all data files that should be encrypted/decrypted.
357func collectDataFiles() ([]string, error) {
358	var files []string
359
360	// Config files
361	cfgDir, err := configDir()
362	if err != nil {
363		return nil, err
364	}
365	files = append(files, filepath.Join(cfgDir, "config.json"))
366
367	// Cache files
368	cDir, err := cacheDir()
369	if err != nil {
370		return nil, err
371	}
372	cacheFiles := []string{"email_cache.json", "contacts.json", "drafts.json", "folder_cache.json"}
373	for _, f := range cacheFiles {
374		files = append(files, filepath.Join(cDir, f))
375	}
376
377	// Folder email cache files
378	folderDir := filepath.Join(cDir, "folder_emails")
379	if entries, err := os.ReadDir(folderDir); err == nil {
380		for _, entry := range entries {
381			if !entry.IsDir() {
382				files = append(files, filepath.Join(folderDir, entry.Name()))
383			}
384		}
385	}
386
387	// Email body cache files
388	bodyDir := filepath.Join(cDir, "email_bodies")
389	if entries, err := os.ReadDir(bodyDir); err == nil {
390		for _, entry := range entries {
391			if !entry.IsDir() {
392				files = append(files, filepath.Join(bodyDir, entry.Name()))
393			}
394		}
395	}
396
397	// Signature files
398	sigDir := filepath.Join(cfgDir, "signatures")
399	if entries, err := os.ReadDir(sigDir); err == nil {
400		for _, entry := range entries {
401			if !entry.IsDir() {
402				files = append(files, filepath.Join(sigDir, entry.Name()))
403			}
404		}
405	}
406
407	return files, nil
408}