Files

140 lines
3.3 KiB
Go

package internals
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// fileEncKeyHex is injected at build time via:
//
// go build -ldflags "-X gotermix/internals.fileEncKeyHex=$(openssl rand -hex 32)" .
// go build -ldflags "-X gotermix/internals.fileEncKeyHex=${GOTERMINAL_ENC}" .
//
// If empty (dev builds without -ldflags), a random key is generated on first
// run and stored in gws.key next to the binary so the creds file stays
// readable across restarts without being hardcoded anywhere.
var fileEncKeyHex string
func keyFilePath() string {
return filepath.Join(filepath.Dir(credsPath), "gws.key")
}
// fileKey returns the AES-256 key used to encrypt/decrypt the credentials file.
//
// Priority order:
// 1. Build-time injected key (fileEncKeyHex set via -ldflags)
// 2. Persisted per-install key stored in gws.key next to the binary
// 3. Newly generated random key (written to gws.key for future runs)
func fileKey() []byte {
if len(fileEncKeyHex) == 64 {
if b, err := hex.DecodeString(fileEncKeyHex); err == nil {
return b
}
}
kp := keyFilePath()
if data, err := os.ReadFile(kp); err == nil {
if b, err := hex.DecodeString(strings.TrimSpace(string(data))); err == nil && len(b) == 32 {
return b
}
}
key := make([]byte, 32)
rand.Read(key)
os.WriteFile(kp, []byte(hex.EncodeToString(key)), 0600) //nolint:errcheck
return key
}
func encryptJSON(v any) ([]byte, error) {
plain, err := json.Marshal(v)
if err != nil {
return nil, err
}
block, err := aes.NewCipher(fileKey())
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := rand.Read(nonce); err != nil {
return nil, err
}
return gcm.Seal(nonce, nonce, plain, nil), nil
}
func decryptJSON(data []byte, v any) error {
block, err := aes.NewCipher(fileKey())
if err != nil {
return err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return err
}
if len(data) < gcm.NonceSize() {
return fmt.Errorf("data too short")
}
plain, err := gcm.Open(nil, data[:gcm.NonceSize()], data[gcm.NonceSize():], nil)
if err != nil {
return err
}
return json.Unmarshal(plain, v)
}
func newSalt() string {
b := make([]byte, 32)
rand.Read(b)
return hex.EncodeToString(b)
}
// hashPassword runs 50 000 rounds of SHA-256 so brute-forcing a stolen file is slow.
func hashPassword(password, salt string) string {
saltBytes, _ := hex.DecodeString(salt)
h := sha256.New()
h.Write(saltBytes)
h.Write([]byte(password))
result := h.Sum(nil)
for i := 0; i < 50000; i++ {
h.Reset()
h.Write(result)
h.Write(saltBytes)
result = h.Sum(nil)
}
return hex.EncodeToString(result)
}
func defaultCreds() storedCreds {
salt := newSalt()
return storedCreds{Username: defaultUser, Salt: salt, Hash: hashPassword(defaultPass, salt)}
}
func loadCreds() storedCreds {
data, err := os.ReadFile(credsPath)
if err != nil {
return defaultCreds()
}
var c storedCreds
if err := decryptJSON(data, &c); err != nil {
fmt.Fprintln(os.Stderr, "warning: credentials file unreadable — using defaults")
return defaultCreds()
}
return c
}
func saveCreds(c storedCreds) error {
data, err := encryptJSON(c)
if err != nil {
return err
}
return os.WriteFile(credsPath, data, 0600)
}