fix: harden security resource lifecycles

This commit is contained in:
Andy Dixon
2026-07-29 12:37:02 +01:00
parent f0a1a0b8f2
commit b5c7528160
17 changed files with 460 additions and 76 deletions
+14 -3
View File
@@ -68,6 +68,7 @@ func EncryptAndExport(profiles []models.Profile, password, filePath string) erro
if err != nil {
return fmt.Errorf("exportimport: failed to serialise profiles: %w", err)
}
defer clear(plaintext)
// Generate random salt and nonce.
salt := make([]byte, saltLength)
@@ -81,14 +82,17 @@ func EncryptAndExport(profiles []models.Profile, password, filePath string) erro
}
// Derive encryption key from password.
passwordBytes := []byte(password)
defer clear(passwordBytes)
key := argon2.IDKey(
[]byte(password),
passwordBytes,
salt,
exportArgonTime,
exportArgonMemory,
exportArgonThreads,
exportArgonKeyLen,
)
defer clear(key)
// Encrypt with AES-256-GCM.
block, err := aes.NewCipher(key)
@@ -104,11 +108,14 @@ func EncryptAndExport(profiles []models.Profile, password, filePath string) erro
ciphertext := gcm.Seal(nil, nonce, plaintext, nil)
// Write the file: version + salt + nonce + ciphertext.
f, err := os.Create(filePath)
f, err := os.OpenFile(filePath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("exportimport: failed to create file: %w", err)
}
defer f.Close()
if err := f.Chmod(0600); err != nil {
return fmt.Errorf("exportimport: failed to restrict file permissions: %w", err)
}
if _, err := f.Write([]byte{formatVersion}); err != nil {
return fmt.Errorf("exportimport: write version failed: %w", err)
@@ -163,14 +170,17 @@ func DecryptAndImport(filePath, password string) ([]models.Profile, error) {
ciphertext := data[1+saltLength+nonceLength:]
// Derive decryption key.
passwordBytes := []byte(password)
defer clear(passwordBytes)
key := argon2.IDKey(
[]byte(password),
passwordBytes,
salt,
exportArgonTime,
exportArgonMemory,
exportArgonThreads,
exportArgonKeyLen,
)
defer clear(key)
// Decrypt with AES-256-GCM.
block, err := aes.NewCipher(key)
@@ -187,6 +197,7 @@ func DecryptAndImport(filePath, password string) ([]models.Profile, error) {
if err != nil {
return nil, errors.New("exportimport: decryption failed — wrong password or corrupt file")
}
defer clear(plaintext)
// Parse the JSON bundle.
var bundle ExportBundle
@@ -3,6 +3,7 @@ package exportimport
import (
"os"
"path/filepath"
"runtime"
"testing"
"github.com/andydixon/nexus/internal/models"
@@ -104,6 +105,26 @@ func TestEncryptEmptyProfiles(t *testing.T) {
}
}
func TestEncryptAndExportRestrictsExistingFilePermissions(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("POSIX permission bits are not enforced on Windows")
}
path := filepath.Join(t.TempDir(), "profiles.nexus")
if err := os.WriteFile(path, []byte("old"), 0644); err != nil {
t.Fatal(err)
}
if err := EncryptAndExport([]models.Profile{{ID: "1"}}, "password", path); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0600 {
t.Fatalf("export permissions = %04o, want 0600", got)
}
}
func TestEncryptEmptyPassword(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "test.nexus")