encryption.go

  1package config
  2
  3import (
  4	"encoding/base64"
  5	"encoding/json"
  6	"errors"
  7	"fmt"
  8	"os"
  9	"path/filepath"
 10	"strings"
 11	"sync"
 12
 13	"github.com/floatpane/go-secretbox"
 14)
 15
 16const secureMetaFile = "secure.meta"
 17
 18// legacyMeta is the secure.meta format written before go-secretbox was
 19// integrated. It is read-only; the first successful unlock auto-migrates it.
 20type legacyMeta struct {
 21	Salt          string `json:"salt"`
 22	Sentinel      string `json:"sentinel"`
 23	Argon2Time    uint32 `json:"argon2_time"`
 24	Argon2Memory  uint32 `json:"argon2_memory"`
 25	Argon2Threads uint8  `json:"argon2_threads"`
 26}
 27
 28// legacySentinel is the plaintext the old code encrypted as the verification token.
 29const legacySentinel = "matcha-verified"
 30
 31var (
 32	vaultMu     sync.Mutex
 33	cachedVault *secretbox.Vault
 34)
 35
 36// getVault returns the shared Vault instance, creating it on first call.
 37func getVault() *secretbox.Vault {
 38	vaultMu.Lock()
 39	defer vaultMu.Unlock()
 40	if cachedVault == nil {
 41		path, _ := secureMetaPath()
 42		cachedVault = secretbox.NewVault(path)
 43	}
 44	return cachedVault
 45}
 46
 47// secureMetaPath returns the path to the secure.meta file.
 48func secureMetaPath() (string, error) {
 49	dir, err := configDir()
 50	if err != nil {
 51		return "", err
 52	}
 53	return filepath.Join(dir, secureMetaFile), nil
 54}
 55
 56// IsSecureModeEnabled checks whether encryption is active by looking for secure.meta.
 57func IsSecureModeEnabled() bool {
 58	return getVault().Initialized()
 59}
 60
 61// Encrypt encrypts plaintext using AES-256-GCM. The nonce is prepended to the ciphertext.
 62func Encrypt(plaintext, key []byte) ([]byte, error) {
 63	return secretbox.AESGCM{}.Encrypt(plaintext, key)
 64}
 65
 66// Decrypt decrypts ciphertext produced by Encrypt using AES-256-GCM.
 67func Decrypt(ciphertext, key []byte) ([]byte, error) {
 68	plain, err := secretbox.AESGCM{}.Decrypt(ciphertext, key)
 69	if errors.Is(err, secretbox.ErrDecrypt) {
 70		return nil, fmt.Errorf("decryption: %w", err)
 71	}
 72	return plain, err
 73}
 74
 75// DeriveKey derives an AES-256 key from a password and salt using Argon2id.
 76func DeriveKey(password string, salt []byte) []byte {
 77	return secretbox.NewArgon2id(secretbox.DefaultArgon2id).DeriveKey(password, salt, 32)
 78}
 79
 80// SetSessionKey is kept for API compatibility. VerifyPassword unlocks the vault
 81// internally, so callers that pass the returned key back through SetSessionKey
 82// are no-ops — the vault is already in the correct state.
 83func SetSessionKey(_ []byte) {}
 84
 85// GetSessionKey returns the current session key, or nil if the vault is locked.
 86// The caller must not modify the returned slice.
 87func GetSessionKey() []byte {
 88	return getVault().Key()
 89}
 90
 91// ClearSessionKey removes the session key from memory.
 92func ClearSessionKey() {
 93	getVault().Lock()
 94}
 95
 96// VerifyPassword checks the password against the stored sentinel and unlocks
 97// the vault. Returns the derived key for callers that store it via SetSessionKey.
 98func VerifyPassword(password string) ([]byte, error) {
 99	v := getVault()
100
101	if err := migrateLegacyMeta(password); err != nil {
102		return nil, err
103	}
104
105	// migrateLegacyMeta may have left the vault unlocked via Init; skip Unlock
106	// in that case to avoid a redundant (and slow) Argon2id call.
107	if v.Locked() {
108		if err := v.Unlock(password); err != nil {
109			if errors.Is(err, secretbox.ErrWrongPassword) {
110				return nil, errors.New("incorrect password")
111			}
112			return nil, fmt.Errorf("could not read secure metadata: %w", err)
113		}
114	}
115	return v.Key(), nil
116}
117
118// EnableSecureMode sets up encryption with the given password. It initialises
119// the vault, re-saves the config with passwords embedded in the encrypted JSON,
120// and re-encrypts all existing data files.
121func EnableSecureMode(password string, cfg *Config) error {
122	v := getVault()
123	if err := v.Init(password); err != nil {
124		return fmt.Errorf("could not initialise secure vault: %w", err)
125	}
126
127	rollback := func() {
128		v.Lock()
129		path, _ := secureMetaPath()
130		_ = os.Remove(path)
131	}
132
133	if cfg != nil {
134		if err := SaveConfig(cfg); err != nil {
135			rollback()
136			return fmt.Errorf("failed to save encrypted config: %w", err)
137		}
138	}
139	if err := reEncryptCacheFiles(); err != nil {
140		rollback()
141		return fmt.Errorf("failed to encrypt existing files: %w", err)
142	}
143	return nil
144}
145
146// DisableSecureMode decrypts all files back to plain JSON and removes secure.meta.
147// The config must be passed so passwords can be restored to the OS keyring.
148func DisableSecureMode(cfg *Config) error {
149	v := getVault()
150
151	files, err := collectDataFiles()
152	if err != nil {
153		return err
154	}
155	cfgPath, _ := configFile()
156
157	for _, f := range files {
158		if f == cfgPath {
159			continue
160		}
161		enc, err := os.ReadFile(f)
162		if err != nil {
163			continue
164		}
165		plain, err := v.Decrypt(enc)
166		if err != nil {
167			continue // file may not be encrypted
168		}
169		if err := writeDataFile(f, plain, 0o600); err != nil {
170			return err
171		}
172	}
173
174	// Lock before SaveConfig so it writes plain JSON and restores keyring passwords.
175	v.Lock()
176
177	if cfg != nil {
178		if err := SaveConfig(cfg); err != nil {
179			return fmt.Errorf("failed to save plain config: %w", err)
180		}
181	}
182
183	path, _ := secureMetaPath()
184	_ = os.Remove(path)
185	return nil
186}
187
188// SecureReadFile reads a file, decrypting it if the vault is unlocked.
189func SecureReadFile(path string) ([]byte, error) {
190	v := getVault()
191	if v.Locked() {
192		return os.ReadFile(path)
193	}
194	return v.ReadFile(path)
195}
196
197// SecureWriteFile writes data to a file, encrypting it if the vault is unlocked.
198func SecureWriteFile(path string, data []byte, perm os.FileMode) error {
199	v := getVault()
200	if v.Locked() {
201		return os.WriteFile(path, data, perm) //nolint:gosec
202	}
203	return v.WriteFile(path, data, perm)
204}
205
206// reEncryptCacheFiles reads all plain data files (excluding config.json) and
207// writes them encrypted via the unlocked vault.
208func reEncryptCacheFiles() error {
209	files, err := collectDataFiles()
210	if err != nil {
211		return err
212	}
213	cfgPath, _ := configFile()
214	for _, f := range files {
215		if f == cfgPath {
216			continue
217		}
218		data, err := os.ReadFile(f)
219		if err != nil {
220			continue
221		}
222		if err := secureWriteDataFile(f, data, 0o600); err != nil {
223			return err
224		}
225	}
226	return nil
227}
228
229// collectDataFiles returns paths to all data files that should be encrypted/decrypted.
230func collectDataFiles() ([]string, error) {
231	var files []string
232
233	cfgDir, err := configDir()
234	if err != nil {
235		return nil, err
236	}
237	files = append(files, filepath.Join(cfgDir, "config.json"))
238
239	cDir, err := cacheDir()
240	if err != nil {
241		return nil, err
242	}
243
244	for _, f := range cacheFiles {
245		files = append(files, filepath.Join(cDir, f))
246	}
247
248	for _, f := range cacheDirectories {
249		dir := filepath.Join(cDir, f)
250		if entries, err := os.ReadDir(dir); err == nil {
251			for _, entry := range entries {
252				if !entry.IsDir() {
253					// filepath.Base strips any directory components from the
254					// entry name, preventing traversal via crafted filenames.
255					files = append(files, filepath.Join(dir, filepath.Base(entry.Name())))
256				}
257			}
258		}
259	}
260
261	sigDir := filepath.Join(cfgDir, "signatures")
262	if entries, err := os.ReadDir(sigDir); err == nil {
263		for _, entry := range entries {
264			if !entry.IsDir() {
265				files = append(files, filepath.Join(sigDir, filepath.Base(entry.Name())))
266			}
267		}
268	}
269
270	return files, nil
271}
272
273// migrateLegacyMeta detects a pre-go-secretbox secure.meta and converts it.
274// It verifies the password via the old sentinel, decrypts all existing files
275// with the old key, rewrites metadata in the new format, and re-encrypts.
276// A wrong password during migration returns errors.New("incorrect password").
277// If the file is absent or already in the new format, it is a no-op.
278func migrateLegacyMeta(password string) error {
279	path, err := secureMetaPath()
280	if err != nil || path == "" {
281		return nil
282	}
283	data, err := os.ReadFile(path)
284	if err != nil {
285		return nil
286	}
287
288	// Old format has "argon2_time"; new format does not.
289	var probe struct {
290		ArgonTime uint32 `json:"argon2_time"`
291	}
292	if json.Unmarshal(data, &probe) != nil || probe.ArgonTime == 0 {
293		return nil // already new format
294	}
295
296	var old legacyMeta
297	if err := json.Unmarshal(data, &old); err != nil {
298		return fmt.Errorf("corrupt legacy metadata: %w", err)
299	}
300	salt, err := base64.StdEncoding.DecodeString(old.Salt)
301	if err != nil {
302		return fmt.Errorf("corrupt legacy salt: %w", err)
303	}
304
305	// Derive old key and verify password via the legacy sentinel.
306	oldKDF := secretbox.NewArgon2id(secretbox.Argon2idParams{
307		Time:    old.Argon2Time,
308		Memory:  old.Argon2Memory,
309		Threads: old.Argon2Threads,
310	})
311	oldKey := oldKDF.DeriveKey(password, salt, 32)
312	defer zeroSlice(oldKey)
313
314	sentinelCipher, err := base64.StdEncoding.DecodeString(old.Sentinel)
315	if err != nil {
316		return fmt.Errorf("corrupt legacy sentinel: %w", err)
317	}
318	plain, err := secretbox.AESGCM{}.Decrypt(sentinelCipher, oldKey)
319	if err != nil || string(plain) != legacySentinel {
320		return errors.New("incorrect password")
321	}
322
323	// Decrypt every data file with the old key before we touch the vault.
324	type decryptedFile struct {
325		path string
326		perm os.FileMode
327		data []byte
328	}
329	files, _ := collectDataFiles()
330	var pending []decryptedFile
331	for _, f := range files {
332		enc, err := os.ReadFile(f)
333		if err != nil {
334			continue
335		}
336		pt, err := secretbox.AESGCM{}.Decrypt(enc, oldKey)
337		if err != nil {
338			continue // not encrypted or not with this key
339		}
340		perm := os.FileMode(0o600)
341		if info, err := os.Stat(f); err == nil {
342			perm = info.Mode().Perm()
343		}
344		pending = append(pending, decryptedFile{f, perm, pt})
345	}
346
347	// Remove old meta and initialise the new-format vault.
348	_ = os.Remove(path)
349	v := getVault()
350	if err := v.Init(password); err != nil {
351		return fmt.Errorf("migration: vault init: %w", err)
352	}
353
354	// Re-encrypt with the new vault key.
355	for _, p := range pending {
356		_ = v.WriteFile(p.path, p.data, p.perm)
357	}
358	return nil
359}
360
361// secureWriteDataFile validates path is within app directories then delegates
362// to SecureWriteFile (which encrypts when the vault is unlocked).
363func secureWriteDataFile(path string, data []byte, perm os.FileMode) error {
364	cfgDir, err := configDir()
365	if err != nil {
366		return err
367	}
368	cDir, err := cacheDir()
369	if err != nil {
370		return err
371	}
372	clean := filepath.Clean(path)
373	if !isUnder(clean, cfgDir) && !isUnder(clean, cDir) {
374		return fmt.Errorf("config: refusing write outside app directories: %s", clean)
375	}
376	return SecureWriteFile(clean, data, perm)
377}
378
379// writeDataFile writes data to path only if path is within the application's
380// config or cache directories. It cleans the path first. This breaks the taint
381// flow for any directory-entry–derived paths returned by collectDataFiles.
382func writeDataFile(path string, data []byte, perm os.FileMode) error {
383	cfgDir, err := configDir()
384	if err != nil {
385		return err
386	}
387	cDir, err := cacheDir()
388	if err != nil {
389		return err
390	}
391	clean := filepath.Clean(path)
392	if !isUnder(clean, cfgDir) && !isUnder(clean, cDir) {
393		return fmt.Errorf("config: refusing write outside app directories: %s", clean)
394	}
395	return os.WriteFile(clean, data, perm) //nolint:gosec
396}
397
398// isUnder reports whether path is inside (or equal to) base.
399// Uses filepath.Rel so it is OS-path-separator aware.
400func isUnder(path, base string) bool {
401	rel, err := filepath.Rel(base, path)
402	return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
403}
404
405func zeroSlice(b []byte) {
406	for i := range b {
407		b[i] = 0
408	}
409}