Files
nexus/internal/secrets/secrets.go
T
andy 35bc9c75b1 Add Go project scaffold for Wails v2 SSH/Telnet/RAW TCP client
- go.mod with Wails v2.9.2, x/crypto, go-sqlite3, go-keyring, uuid
- cmd/app/main.go: Wails entry point (Nexus Terminal, 1400x900, #1a1a2e)
- internal/app/app.go: lifecycle (Startup/Shutdown/DomReady)
- internal/models/models.go: full model definitions (Profile, SSHConfig,
  TelnetConfig, RAWConfig, SessionInfo, KnownHost, AppPreferences, etc.)
- internal/models/defaults.go: default values and NewProfile factory
- internal/validation/validation.go: thorough field-level validation
- frontend/embed.go: embedded frontend assets
- Updated existing storage, secrets, config to match new model structure
2026-04-15 00:00:00 +01:00

346 lines
9.1 KiB
Go

package secrets
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"golang.org/x/crypto/argon2"
)
const (
vaultFileName = "vault.json"
vaultVersion = 1
// Argon2id key derivation parameters.
argonTime = 3
argonMemory = 64 * 1024
argonThreads = 4
argonKeyLen = 32
saltLen = 32
)
// SecretStore manages encrypted credential storage.
// It uses AES-256-GCM with Argon2id key derivation from a user-controlled
// master password. No hard-coded secrets are used.
//
// Threat model:
// - Protects secrets at rest on the local filesystem
// - Requires the master password to decrypt
// - Does NOT protect against a compromised running process
// - Does NOT protect against memory forensics on a compromised machine
// - The master password is held in memory only for the duration of the session
type SecretStore struct {
mu sync.RWMutex
storePath string
key []byte // derived from master password, held in memory
vault *secretVault
unlocked bool
}
type secretVault struct {
Version int `json:"version"`
Salt string `json:"salt"` // base64-encoded
Secrets map[string]string `json:"secrets"` // key -> base64(nonce + ciphertext)
}
// NewSecretStore creates a new SecretStore that persists its vault file in the
// given directory path. The vault is not unlocked until Unlock is called.
func NewSecretStore(storePath string) *SecretStore {
return &SecretStore{
storePath: storePath,
}
}
// IsInitialised returns true if a vault file already exists on disc.
func (s *SecretStore) IsInitialised() bool {
_, err := os.Stat(s.vaultPath())
return err == nil
}
// Initialise creates a new vault with the given master password. A random
// 32-byte salt is generated and the key is derived using Argon2id. Returns an
// error if a vault already exists.
func (s *SecretStore) Initialise(masterPassword string) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.IsInitialised() {
return errors.New("secrets: vault already initialised")
}
salt := make([]byte, saltLen)
if _, err := io.ReadFull(rand.Reader, salt); err != nil {
return fmt.Errorf("secrets: failed to generate salt: %w", err)
}
s.vault = &secretVault{
Version: vaultVersion,
Salt: base64.StdEncoding.EncodeToString(salt),
Secrets: make(map[string]string),
}
s.key = deriveKey(masterPassword, salt)
s.unlocked = true
if err := os.MkdirAll(s.storePath, 0700); err != nil {
return fmt.Errorf("secrets: failed to create store directory: %w", err)
}
if err := s.save(); err != nil {
return fmt.Errorf("secrets: failed to save initial vault: %w", err)
}
return nil
}
// Unlock decrypts the vault using the provided master password. The derived
// key is verified by attempting to decrypt the first stored secret; if no
// secrets exist the password is accepted optimistically.
func (s *SecretStore) Unlock(masterPassword string) error {
s.mu.Lock()
defer s.mu.Unlock()
vault, err := s.load()
if err != nil {
return fmt.Errorf("secrets: failed to load vault: %w", err)
}
salt, err := base64.StdEncoding.DecodeString(vault.Salt)
if err != nil {
return fmt.Errorf("secrets: corrupt vault salt: %w", err)
}
s.vault = vault
s.key = deriveKey(masterPassword, salt)
s.unlocked = true
// Attempt to decrypt any existing secret to verify the password.
for _, encVal := range vault.Secrets {
if _, err := s.decrypt(encVal); err != nil {
s.lockInternal()
return fmt.Errorf("secrets: incorrect master password or corrupt vault")
}
break
}
return nil
}
// Lock zeroes the derived key material and locks the vault. After calling Lock
// no secret operations are possible until Unlock is called again.
func (s *SecretStore) Lock() {
s.mu.Lock()
defer s.mu.Unlock()
s.lockInternal()
}
// lockInternal performs the actual lock without acquiring the mutex. The caller
// must already hold s.mu.
func (s *SecretStore) lockInternal() {
for i := range s.key {
s.key[i] = 0
}
s.key = nil
s.vault = nil
s.unlocked = false
}
// IsUnlocked returns whether the vault is currently unlocked and ready for use.
func (s *SecretStore) IsUnlocked() bool {
s.mu.RLock()
defer s.mu.RUnlock()
return s.unlocked
}
// SetSecret encrypts and stores a secret under the given key. The key format
// should follow "profile:{id}:password" or "profile:{id}:passphrase".
func (s *SecretStore) SetSecret(key string, value string) error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.unlocked {
return errors.New("secrets: vault is locked")
}
encrypted, err := s.encrypt(value)
if err != nil {
return fmt.Errorf("secrets: encryption failed: %w", err)
}
s.vault.Secrets[key] = encrypted
return s.save()
}
// GetSecret retrieves and decrypts a secret by key. Returns an error if the
// key does not exist or decryption fails.
func (s *SecretStore) GetSecret(key string) (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
if !s.unlocked {
return "", errors.New("secrets: vault is locked")
}
encVal, ok := s.vault.Secrets[key]
if !ok {
return "", fmt.Errorf("secrets: key %q not found", key)
}
return s.decrypt(encVal)
}
// DeleteSecret removes a secret by key. Returns an error if the vault is
// locked or the save fails.
func (s *SecretStore) DeleteSecret(key string) error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.unlocked {
return errors.New("secrets: vault is locked")
}
delete(s.vault.Secrets, key)
return s.save()
}
// DeleteProfileSecrets removes all secrets whose key begins with
// "profile:{profileID}:".
func (s *SecretStore) DeleteProfileSecrets(profileID string) error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.unlocked {
return errors.New("secrets: vault is locked")
}
prefix := "profile:" + profileID + ":"
for k := range s.vault.Secrets {
if strings.HasPrefix(k, prefix) {
delete(s.vault.Secrets, k)
}
}
return s.save()
}
// ListKeys returns all secret keys stored in the vault. Secret values are
// never returned by this method.
func (s *SecretStore) ListKeys() []string {
s.mu.RLock()
defer s.mu.RUnlock()
if !s.unlocked || s.vault == nil {
return nil
}
keys := make([]string, 0, len(s.vault.Secrets))
for k := range s.vault.Secrets {
keys = append(keys, k)
}
return keys
}
// --- internal helpers --------------------------------------------------------
// vaultPath returns the full filesystem path to the vault JSON file.
func (s *SecretStore) vaultPath() string {
return filepath.Join(s.storePath, vaultFileName)
}
// deriveKey uses Argon2id to derive a 256-bit key from the master password and
// salt. The parameters are intentionally hard-coded to ensure consistency
// across vault open/close cycles.
func deriveKey(password string, salt []byte) []byte {
return argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
}
// encrypt encrypts plaintext using AES-256-GCM. A random nonce is generated
// for each call and prepended to the ciphertext. The result is returned as a
// base64-encoded string.
func (s *SecretStore) encrypt(plaintext string) (string, error) {
block, err := aes.NewCipher(s.key)
if err != nil {
return "", fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("failed to create GCM: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf("failed to generate nonce: %w", err)
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext), nil
}
// decrypt decodes a base64 string and decrypts it using AES-256-GCM. The
// nonce is expected to be prepended to the ciphertext.
func (s *SecretStore) decrypt(encoded string) (string, error) {
data, err := base64.StdEncoding.DecodeString(encoded)
if err != nil {
return "", fmt.Errorf("failed to decode secret: %w", err)
}
block, err := aes.NewCipher(s.key)
if err != nil {
return "", fmt.Errorf("failed to create cipher: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("failed to create GCM: %w", err)
}
nonceSize := gcm.NonceSize()
if len(data) < nonceSize {
return "", errors.New("ciphertext too short")
}
nonce, ciphertext := data[:nonceSize], data[nonceSize:]
plaintext, err := gcm.Open(nil, nonce, ciphertext, nil)
if err != nil {
return "", fmt.Errorf("decryption failed: %w", err)
}
return string(plaintext), nil
}
// save writes the vault to disc as a JSON file with restricted permissions.
func (s *SecretStore) save() error {
data, err := json.MarshalIndent(s.vault, "", " ")
if err != nil {
return fmt.Errorf("failed to marshal vault: %w", err)
}
return os.WriteFile(s.vaultPath(), data, 0600)
}
// load reads and unmarshals the vault from disc.
func (s *SecretStore) load() (*secretVault, error) {
data, err := os.ReadFile(s.vaultPath())
if err != nil {
return nil, fmt.Errorf("failed to read vault file: %w", err)
}
var vault secretVault
if err := json.Unmarshal(data, &vault); err != nil {
return nil, fmt.Errorf("failed to parse vault file: %w", err)
}
return &vault, nil
}