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}