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}