@@ -1,34 +1,23 @@
package config
import (
- "crypto/aes"
- "crypto/cipher"
- "crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
+ "strings"
"sync"
- "golang.org/x/crypto/argon2"
+ "github.com/floatpane/go-secretbox"
)
-const (
- sentinelPlaintext = "matcha-verified"
- secureMetaFile = "secure.meta"
+const secureMetaFile = "secure.meta"
- // Argon2id parameters
- argon2Time = 3
- argon2Memory = 64 * 1024 // 64 MB
- argon2Threads = 4
- argon2KeyLen = 32 // AES-256
-)
-
-// secureMeta is stored as plain JSON at ~/.config/matcha/secure.meta.
-// Its existence signals that secure mode is enabled.
-type secureMeta struct {
+// legacyMeta is the secure.meta format written before go-secretbox was
+// integrated. It is read-only; the first successful unlock auto-migrates it.
+type legacyMeta struct {
Salt string `json:"salt"`
Sentinel string `json:"sentinel"`
Argon2Time uint32 `json:"argon2_time"`
@@ -36,323 +25,201 @@ type secureMeta struct {
Argon2Threads uint8 `json:"argon2_threads"`
}
+// legacySentinel is the plaintext the old code encrypted as the verification token.
+const legacySentinel = "matcha-verified"
+
var (
- sessionKey []byte
- sessionKeyMu sync.RWMutex
+ vaultMu sync.Mutex
+ cachedVault *secretbox.Vault
)
-// SetSessionKey stores the derived encryption key in memory for this session.
-func SetSessionKey(key []byte) {
- sessionKeyMu.Lock()
- defer sessionKeyMu.Unlock()
- sessionKey = key
-}
-
-// GetSessionKey returns the current session key, or nil if not set.
-func GetSessionKey() []byte {
- sessionKeyMu.RLock()
- defer sessionKeyMu.RUnlock()
- return sessionKey
-}
-
-// ClearSessionKey removes the session key from memory.
-func ClearSessionKey() {
- sessionKeyMu.Lock()
- defer sessionKeyMu.Unlock()
- // Overwrite key material before clearing
- for i := range sessionKey {
- sessionKey[i] = 0
+// getVault returns the shared Vault instance, creating it on first call.
+func getVault() *secretbox.Vault {
+ vaultMu.Lock()
+ defer vaultMu.Unlock()
+ if cachedVault == nil {
+ path, _ := secureMetaPath()
+ cachedVault = secretbox.NewVault(path)
}
- sessionKey = nil
+ return cachedVault
}
-// DeriveKey derives an AES-256 key from a password and salt using Argon2id.
-func DeriveKey(password string, salt []byte) []byte {
- return argon2.IDKey([]byte(password), salt, argon2Time, argon2Memory, argon2Threads, argon2KeyLen)
+// secureMetaPath returns the path to the secure.meta file.
+func secureMetaPath() (string, error) {
+ dir, err := configDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(dir, secureMetaFile), nil
}
-// deriveKeyWithParams derives a key using specific Argon2id parameters (for loading existing meta).
-func deriveKeyWithParams(password string, salt []byte, time, memory uint32, threads uint8) []byte {
- return argon2.IDKey([]byte(password), salt, time, memory, threads, argon2KeyLen)
+// IsSecureModeEnabled checks whether encryption is active by looking for secure.meta.
+func IsSecureModeEnabled() bool {
+ return getVault().Initialized()
}
// Encrypt encrypts plaintext using AES-256-GCM. The nonce is prepended to the ciphertext.
func Encrypt(plaintext, key []byte) ([]byte, error) {
- block, err := aes.NewCipher(key)
- if err != nil {
- return nil, fmt.Errorf("encryption: %w", err)
- }
- aesGCM, err := cipher.NewGCM(block)
- if err != nil {
- return nil, fmt.Errorf("encryption: %w", err)
- }
- nonce := make([]byte, aesGCM.NonceSize())
- if _, err := rand.Read(nonce); err != nil {
- return nil, fmt.Errorf("encryption: %w", err)
- }
- return aesGCM.Seal(nonce, nonce, plaintext, nil), nil
+ return secretbox.AESGCM{}.Encrypt(plaintext, key)
}
// Decrypt decrypts ciphertext produced by Encrypt using AES-256-GCM.
func Decrypt(ciphertext, key []byte) ([]byte, error) {
- block, err := aes.NewCipher(key)
- if err != nil {
- return nil, fmt.Errorf("decryption: %w", err)
- }
- aesGCM, err := cipher.NewGCM(block)
- if err != nil {
- return nil, fmt.Errorf("decryption: %w", err)
- }
- nonceSize := aesGCM.NonceSize()
- if len(ciphertext) < nonceSize {
- return nil, errors.New("decryption: ciphertext too short")
- }
- nonce, encrypted := ciphertext[:nonceSize], ciphertext[nonceSize:]
- plaintext, err := aesGCM.Open(nil, nonce, encrypted, nil)
- if err != nil {
+ plain, err := secretbox.AESGCM{}.Decrypt(ciphertext, key)
+ if errors.Is(err, secretbox.ErrDecrypt) {
return nil, fmt.Errorf("decryption: %w", err)
}
- return plaintext, nil
+ return plain, err
}
-// secureMetaPath returns the path to the secure.meta file.
-func secureMetaPath() (string, error) {
- dir, err := configDir()
- if err != nil {
- return "", err
- }
- return filepath.Join(dir, secureMetaFile), nil
+// DeriveKey derives an AES-256 key from a password and salt using Argon2id.
+func DeriveKey(password string, salt []byte) []byte {
+ return secretbox.NewArgon2id(secretbox.DefaultArgon2id).DeriveKey(password, salt, 32)
}
-// IsSecureModeEnabled checks whether encryption is active by looking for secure.meta.
-func IsSecureModeEnabled() bool {
- path, err := secureMetaPath()
- if err != nil {
- return false
- }
- _, err = os.Stat(path)
- return err == nil
+// SetSessionKey is kept for API compatibility. VerifyPassword unlocks the vault
+// internally, so callers that pass the returned key back through SetSessionKey
+// are no-ops β the vault is already in the correct state.
+func SetSessionKey(_ []byte) {}
+
+// GetSessionKey returns the current session key, or nil if the vault is locked.
+// The caller must not modify the returned slice.
+func GetSessionKey() []byte {
+ return getVault().Key()
}
-// loadSecureMeta reads and parses the secure.meta file.
-func loadSecureMeta() (*secureMeta, error) {
- path, err := secureMetaPath()
- if err != nil {
- return nil, err
- }
- data, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- var meta secureMeta
- if err := json.Unmarshal(data, &meta); err != nil {
- return nil, err
- }
- return &meta, nil
+// ClearSessionKey removes the session key from memory.
+func ClearSessionKey() {
+ getVault().Lock()
}
-// VerifyPassword checks the password against the stored sentinel.
-// Returns the derived key on success.
+// VerifyPassword checks the password against the stored sentinel and unlocks
+// the vault. Returns the derived key for callers that store it via SetSessionKey.
func VerifyPassword(password string) ([]byte, error) {
- meta, err := loadSecureMeta()
- if err != nil {
- return nil, fmt.Errorf("could not read secure metadata: %w", err)
- }
-
- salt, err := base64.StdEncoding.DecodeString(meta.Salt)
- if err != nil {
- return nil, fmt.Errorf("invalid salt: %w", err)
- }
-
- key := deriveKeyWithParams(password, salt, meta.Argon2Time, meta.Argon2Memory, meta.Argon2Threads)
+ v := getVault()
- sentinelCiphertext, err := base64.StdEncoding.DecodeString(meta.Sentinel)
- if err != nil {
- return nil, fmt.Errorf("invalid sentinel: %w", err)
- }
-
- plaintext, err := Decrypt(sentinelCiphertext, key)
- if err != nil {
- return nil, errors.New("incorrect password")
+ if err := migrateLegacyMeta(password); err != nil {
+ return nil, err
}
- if string(plaintext) != sentinelPlaintext {
- return nil, errors.New("incorrect password")
+ // migrateLegacyMeta may have left the vault unlocked via Init; skip Unlock
+ // in that case to avoid a redundant (and slow) Argon2id call.
+ if v.Locked() {
+ if err := v.Unlock(password); err != nil {
+ if errors.Is(err, secretbox.ErrWrongPassword) {
+ return nil, errors.New("incorrect password")
+ }
+ return nil, fmt.Errorf("could not read secure metadata: %w", err)
+ }
}
-
- return key, nil
+ return v.Key(), nil
}
-// EnableSecureMode sets up encryption with the given password.
-// It generates a salt, derives a key, encrypts the sentinel, saves secure.meta,
-// and re-encrypts all existing data files. The config must be passed so that
-// passwords (normally stored in the OS keyring) can be written into the encrypted config.
+// EnableSecureMode sets up encryption with the given password. It initialises
+// the vault, re-saves the config with passwords embedded in the encrypted JSON,
+// and re-encrypts all existing data files.
func EnableSecureMode(password string, cfg *Config) error {
- // Generate random salt
- salt := make([]byte, 32)
- if _, err := rand.Read(salt); err != nil {
- return fmt.Errorf("could not generate salt: %w", err)
- }
-
- key := DeriveKey(password, salt)
-
- // Encrypt sentinel
- sentinelCipher, err := Encrypt([]byte(sentinelPlaintext), key)
- if err != nil {
- return fmt.Errorf("could not encrypt sentinel: %w", err)
- }
-
- meta := secureMeta{
- Salt: base64.StdEncoding.EncodeToString(salt),
- Sentinel: base64.StdEncoding.EncodeToString(sentinelCipher),
- Argon2Time: argon2Time,
- Argon2Memory: argon2Memory,
- Argon2Threads: argon2Threads,
+ v := getVault()
+ if err := v.Init(password); err != nil {
+ return fmt.Errorf("could not initialise secure vault: %w", err)
}
- metaData, err := json.MarshalIndent(meta, "", " ")
- if err != nil {
- return err
+ rollback := func() {
+ v.Lock()
+ path, _ := secureMetaPath()
+ _ = os.Remove(path)
}
- path, err := secureMetaPath()
- if err != nil {
- return err
- }
- if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
- return err
- }
-
- // Set the session key so SecureWriteFile will encrypt
- SetSessionKey(key)
-
- // Re-save config first β this writes passwords into the encrypted JSON
- // (SaveConfig uses secureDiskConfig when session key is set)
if cfg != nil {
if err := SaveConfig(cfg); err != nil {
- ClearSessionKey()
+ rollback()
return fmt.Errorf("failed to save encrypted config: %w", err)
}
}
-
- // Re-encrypt all remaining data files (caches, signatures, etc.)
if err := reEncryptCacheFiles(); err != nil {
- ClearSessionKey()
+ rollback()
return fmt.Errorf("failed to encrypt existing files: %w", err)
}
-
- // Write secure.meta last (plain JSON, not encrypted)
- if err := os.WriteFile(path, metaData, 0600); err != nil {
- ClearSessionKey()
- return err
- }
-
return nil
}
// DisableSecureMode decrypts all files back to plain JSON and removes secure.meta.
// The config must be passed so passwords can be restored to the OS keyring.
func DisableSecureMode(cfg *Config) error {
- // Collect all files that need decryption
+ v := getVault()
+
files, err := collectDataFiles()
if err != nil {
return err
}
-
- // Find config.json path to skip it (handled separately below)
cfgPath, _ := configFile()
- // Copy the key so ClearSessionKey's in-place zeroing doesn't destroy it.
- origKey := GetSessionKey()
- key := make([]byte, len(origKey))
- copy(key, origKey)
-
- // Decrypt all cache files and write them back as plain data.
- // We use Decrypt directly instead of toggling the session key, because
- // ClearSessionKey zeroes the slice in-place which would corrupt our copy.
for _, f := range files {
if f == cfgPath {
continue
}
- encrypted, err := os.ReadFile(f)
+ enc, err := os.ReadFile(f)
if err != nil {
- continue // File may not exist
+ continue
}
- plain, err := Decrypt(encrypted, key)
+ plain, err := v.Decrypt(enc)
if err != nil {
- continue // File may not be encrypted
+ continue // file may not be encrypted
}
- if err := os.WriteFile(f, plain, 0600); err != nil { //nolint:gosec
+ if err := writeDataFile(f, plain, 0o600); err != nil {
return err
}
}
- // Clear session key so SaveConfig writes plain JSON and restores passwords to keyring
- ClearSessionKey()
+ // Lock before SaveConfig so it writes plain JSON and restores keyring passwords.
+ v.Lock()
- // Re-save config β this will use the keyring (no session key) and strip passwords from JSON
if cfg != nil {
if err := SaveConfig(cfg); err != nil {
return fmt.Errorf("failed to save plain config: %w", err)
}
}
- // Remove secure.meta
- path, err := secureMetaPath()
- if err != nil {
- return err
- }
+ path, _ := secureMetaPath()
_ = os.Remove(path)
-
return nil
}
-// SecureReadFile reads a file, decrypting it if a session key is set.
+// SecureReadFile reads a file, decrypting it if the vault is unlocked.
func SecureReadFile(path string) ([]byte, error) {
- data, err := os.ReadFile(path)
- if err != nil {
- return nil, err
- }
- key := GetSessionKey()
- if key == nil {
- return data, nil
+ v := getVault()
+ if v.Locked() {
+ return os.ReadFile(path)
}
- return Decrypt(data, key)
+ return v.ReadFile(path)
}
-// SecureWriteFile writes data to a file, encrypting it if a session key is set.
+// SecureWriteFile writes data to a file, encrypting it if the vault is unlocked.
func SecureWriteFile(path string, data []byte, perm os.FileMode) error {
- key := GetSessionKey()
- if key == nil {
+ v := getVault()
+ if v.Locked() {
return os.WriteFile(path, data, perm) //nolint:gosec
}
- encrypted, err := Encrypt(data, key)
- if err != nil {
- return err
- }
- return os.WriteFile(path, encrypted, perm) //nolint:gosec
+ return v.WriteFile(path, data, perm)
}
-// reEncryptCacheFiles reads all plain cache/data files (excluding config.json) and writes them encrypted.
+// reEncryptCacheFiles reads all plain data files (excluding config.json) and
+// writes them encrypted via the unlocked vault.
func reEncryptCacheFiles() error {
files, err := collectDataFiles()
if err != nil {
return err
}
-
- // Find config.json path to skip it (already saved separately with passwords)
cfgPath, _ := configFile()
-
for _, f := range files {
if f == cfgPath {
- continue // Already handled by SaveConfig
+ continue
}
- plainData, err := os.ReadFile(f)
+ data, err := os.ReadFile(f)
if err != nil {
- continue // File may not exist
+ continue
}
- // Write encrypted using SecureWriteFile (session key is already set)
- if err := SecureWriteFile(f, plainData, 0600); err != nil {
+ if err := secureWriteDataFile(f, data, 0o600); err != nil {
return err
}
}
@@ -363,14 +230,12 @@ func reEncryptCacheFiles() error {
func collectDataFiles() ([]string, error) {
var files []string
- // Config files
cfgDir, err := configDir()
if err != nil {
return nil, err
}
files = append(files, filepath.Join(cfgDir, "config.json"))
- // Cache files
cDir, err := cacheDir()
if err != nil {
return nil, err
@@ -385,21 +250,160 @@ func collectDataFiles() ([]string, error) {
if entries, err := os.ReadDir(dir); err == nil {
for _, entry := range entries {
if !entry.IsDir() {
- files = append(files, filepath.Join(dir, entry.Name()))
+ // filepath.Base strips any directory components from the
+ // entry name, preventing traversal via crafted filenames.
+ files = append(files, filepath.Join(dir, filepath.Base(entry.Name())))
}
}
}
}
- // Signature files
sigDir := filepath.Join(cfgDir, "signatures")
if entries, err := os.ReadDir(sigDir); err == nil {
for _, entry := range entries {
if !entry.IsDir() {
- files = append(files, filepath.Join(sigDir, entry.Name()))
+ files = append(files, filepath.Join(sigDir, filepath.Base(entry.Name())))
}
}
}
return files, nil
}
+
+// migrateLegacyMeta detects a pre-go-secretbox secure.meta and converts it.
+// It verifies the password via the old sentinel, decrypts all existing files
+// with the old key, rewrites metadata in the new format, and re-encrypts.
+// A wrong password during migration returns errors.New("incorrect password").
+// If the file is absent or already in the new format, it is a no-op.
+func migrateLegacyMeta(password string) error {
+ path, err := secureMetaPath()
+ if err != nil || path == "" {
+ return nil
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return nil
+ }
+
+ // Old format has "argon2_time"; new format does not.
+ var probe struct {
+ ArgonTime uint32 `json:"argon2_time"`
+ }
+ if json.Unmarshal(data, &probe) != nil || probe.ArgonTime == 0 {
+ return nil // already new format
+ }
+
+ var old legacyMeta
+ if err := json.Unmarshal(data, &old); err != nil {
+ return fmt.Errorf("corrupt legacy metadata: %w", err)
+ }
+ salt, err := base64.StdEncoding.DecodeString(old.Salt)
+ if err != nil {
+ return fmt.Errorf("corrupt legacy salt: %w", err)
+ }
+
+ // Derive old key and verify password via the legacy sentinel.
+ oldKDF := secretbox.NewArgon2id(secretbox.Argon2idParams{
+ Time: old.Argon2Time,
+ Memory: old.Argon2Memory,
+ Threads: old.Argon2Threads,
+ })
+ oldKey := oldKDF.DeriveKey(password, salt, 32)
+ defer zeroSlice(oldKey)
+
+ sentinelCipher, err := base64.StdEncoding.DecodeString(old.Sentinel)
+ if err != nil {
+ return fmt.Errorf("corrupt legacy sentinel: %w", err)
+ }
+ plain, err := secretbox.AESGCM{}.Decrypt(sentinelCipher, oldKey)
+ if err != nil || string(plain) != legacySentinel {
+ return errors.New("incorrect password")
+ }
+
+ // Decrypt every data file with the old key before we touch the vault.
+ type decryptedFile struct {
+ path string
+ perm os.FileMode
+ data []byte
+ }
+ files, _ := collectDataFiles()
+ var pending []decryptedFile
+ for _, f := range files {
+ enc, err := os.ReadFile(f)
+ if err != nil {
+ continue
+ }
+ pt, err := secretbox.AESGCM{}.Decrypt(enc, oldKey)
+ if err != nil {
+ continue // not encrypted or not with this key
+ }
+ perm := os.FileMode(0o600)
+ if info, err := os.Stat(f); err == nil {
+ perm = info.Mode().Perm()
+ }
+ pending = append(pending, decryptedFile{f, perm, pt})
+ }
+
+ // Remove old meta and initialise the new-format vault.
+ _ = os.Remove(path)
+ v := getVault()
+ if err := v.Init(password); err != nil {
+ return fmt.Errorf("migration: vault init: %w", err)
+ }
+
+ // Re-encrypt with the new vault key.
+ for _, p := range pending {
+ _ = v.WriteFile(p.path, p.data, p.perm)
+ }
+ return nil
+}
+
+// secureWriteDataFile validates path is within app directories then delegates
+// to SecureWriteFile (which encrypts when the vault is unlocked).
+func secureWriteDataFile(path string, data []byte, perm os.FileMode) error {
+ cfgDir, err := configDir()
+ if err != nil {
+ return err
+ }
+ cDir, err := cacheDir()
+ if err != nil {
+ return err
+ }
+ clean := filepath.Clean(path)
+ if !isUnder(clean, cfgDir) && !isUnder(clean, cDir) {
+ return fmt.Errorf("config: refusing write outside app directories: %s", clean)
+ }
+ return SecureWriteFile(clean, data, perm)
+}
+
+// writeDataFile writes data to path only if path is within the application's
+// config or cache directories. It cleans the path first. This breaks the taint
+// flow for any directory-entryβderived paths returned by collectDataFiles.
+func writeDataFile(path string, data []byte, perm os.FileMode) error {
+ cfgDir, err := configDir()
+ if err != nil {
+ return err
+ }
+ cDir, err := cacheDir()
+ if err != nil {
+ return err
+ }
+ clean := filepath.Clean(path)
+ if !isUnder(clean, cfgDir) && !isUnder(clean, cDir) {
+ return fmt.Errorf("config: refusing write outside app directories: %s", clean)
+ }
+ return os.WriteFile(clean, data, perm) //nolint:gosec
+}
+
+// isUnder reports whether path is inside (or equal to) base.
+// Uses filepath.Rel so it is OS-path-separator aware.
+func isUnder(path, base string) bool {
+ rel, err := filepath.Rel(base, path)
+ return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
+}
+
+func zeroSlice(b []byte) {
+ for i := range b {
+ b[i] = 0
+ }
+}