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 // Copy the key so ClearSessionKey's in-place zeroing doesn't destroy it.
266 origKey := GetSessionKey()
267 key := make([]byte, len(origKey))
268 copy(key, origKey)
269
270 // Decrypt all cache files and write them back as plain data.
271 // We use Decrypt directly instead of toggling the session key, because
272 // ClearSessionKey zeroes the slice in-place which would corrupt our copy.
273 for _, f := range files {
274 if f == cfgPath {
275 continue
276 }
277 encrypted, err := os.ReadFile(f)
278 if err != nil {
279 continue // File may not exist
280 }
281 plain, err := Decrypt(encrypted, key)
282 if err != nil {
283 continue // File may not be encrypted
284 }
285 if err := os.WriteFile(f, plain, 0600); err != nil { //nolint:gosec
286 return err
287 }
288 }
289
290 // Clear session key so SaveConfig writes plain JSON and restores passwords to keyring
291 ClearSessionKey()
292
293 // Re-save config — this will use the keyring (no session key) and strip passwords from JSON
294 if cfg != nil {
295 if err := SaveConfig(cfg); err != nil {
296 return fmt.Errorf("failed to save plain config: %w", err)
297 }
298 }
299
300 // Remove secure.meta
301 path, err := secureMetaPath()
302 if err != nil {
303 return err
304 }
305 _ = os.Remove(path)
306
307 return nil
308}
309
310// SecureReadFile reads a file, decrypting it if a session key is set.
311func SecureReadFile(path string) ([]byte, error) {
312 data, err := os.ReadFile(path)
313 if err != nil {
314 return nil, err
315 }
316 key := GetSessionKey()
317 if key == nil {
318 return data, nil
319 }
320 return Decrypt(data, key)
321}
322
323// SecureWriteFile writes data to a file, encrypting it if a session key is set.
324func SecureWriteFile(path string, data []byte, perm os.FileMode) error {
325 key := GetSessionKey()
326 if key == nil {
327 return os.WriteFile(path, data, perm) //nolint:gosec
328 }
329 encrypted, err := Encrypt(data, key)
330 if err != nil {
331 return err
332 }
333 return os.WriteFile(path, encrypted, perm) //nolint:gosec
334}
335
336// reEncryptCacheFiles reads all plain cache/data files (excluding config.json) and writes them encrypted.
337func reEncryptCacheFiles() error {
338 files, err := collectDataFiles()
339 if err != nil {
340 return err
341 }
342
343 // Find config.json path to skip it (already saved separately with passwords)
344 cfgPath, _ := configFile()
345
346 for _, f := range files {
347 if f == cfgPath {
348 continue // Already handled by SaveConfig
349 }
350 plainData, err := os.ReadFile(f)
351 if err != nil {
352 continue // File may not exist
353 }
354 // Write encrypted using SecureWriteFile (session key is already set)
355 if err := SecureWriteFile(f, plainData, 0600); err != nil {
356 return err
357 }
358 }
359 return nil
360}
361
362// collectDataFiles returns paths to all data files that should be encrypted/decrypted.
363func collectDataFiles() ([]string, error) {
364 var files []string
365
366 // Config files
367 cfgDir, err := configDir()
368 if err != nil {
369 return nil, err
370 }
371 files = append(files, filepath.Join(cfgDir, "config.json"))
372
373 // Cache files
374 cDir, err := cacheDir()
375 if err != nil {
376 return nil, err
377 }
378
379 for _, f := range cacheFiles {
380 files = append(files, filepath.Join(cDir, f))
381 }
382
383 for _, f := range cacheDirectories {
384 dir := filepath.Join(cDir, f)
385 if entries, err := os.ReadDir(dir); err == nil {
386 for _, entry := range entries {
387 if !entry.IsDir() {
388 files = append(files, filepath.Join(dir, entry.Name()))
389 }
390 }
391 }
392 }
393
394 // Signature files
395 sigDir := filepath.Join(cfgDir, "signatures")
396 if entries, err := os.ReadDir(sigDir); err == nil {
397 for _, entry := range entries {
398 if !entry.IsDir() {
399 files = append(files, filepath.Join(sigDir, entry.Name()))
400 }
401 }
402 }
403
404 return files, nil
405}