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) }