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	// 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}