Files
langship.sh/pkg/secrets/secrets.go
T
patel-lyzr 1766bdf497 feat: Implement AI Gateway page with feature tiles and descriptions
feat: Update AppSidebar to include new Environments and Credentials sections

feat: Enhance node form with branch selection for triggers and deployment

feat: Create CredentialForm and CredentialRow components for managing credentials

feat: Add API endpoints for credential management

fix: Update node catalog defaults for deploy and promote nodes
2026-05-13 22:15:08 +05:30

157 lines
4.2 KiB
Go

// Package secrets is the host-side encryption helper used by anything
// in Flow that persists user-supplied credentials (cloud creds, kv
// secrets, agent PATs). Master key comes from the FLOW_SECRET_KEY env
// var. Format: base64-url(nonce || ciphertext || tag), AES-256-GCM.
//
// Design choices:
// - Fail loud if the key is missing. We never silently store plaintext
// in fields the rest of the system thinks are encrypted — that's
// worse than failing to start.
// - Master key is hashed with SHA-256 so any reasonable input length
// works (32-byte hex, base64, or a passphrase).
// - Each Seal generates a fresh random 12-byte nonce. Ciphertext is
// authenticated by GCM's tag.
//
// We do NOT version the ciphertext envelope. If we ever need key
// rotation, prepend a 1-byte version + key-id and decode by version.
package secrets
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"io"
"os"
"sync"
)
const envKey = "FLOW_SECRET_KEY"
var (
keyOnce sync.Once
keyVal []byte
keyErr error
)
// loadKey reads FLOW_SECRET_KEY once and caches the SHA-256 of its bytes.
// Returns an error (not panic) so callers can surface it cleanly.
func loadKey() ([]byte, error) {
keyOnce.Do(func() {
raw := os.Getenv(envKey)
if raw == "" {
keyErr = fmt.Errorf("secrets: %s is not set; refusing to encrypt/decrypt", envKey)
return
}
sum := sha256.Sum256([]byte(raw))
keyVal = sum[:]
})
return keyVal, keyErr
}
// SealString encrypts plaintext with AES-256-GCM and returns a
// base64-url-encoded envelope. Empty input returns empty output (so
// "no secret set" round-trips through encryption cleanly).
func SealString(plaintext string) (string, error) {
if plaintext == "" {
return "", nil
}
key, err := loadKey()
if err != nil {
return "", err
}
block, err := aes.NewCipher(key)
if err != nil {
return "", fmt.Errorf("aes new: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("gcm new: %w", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return "", fmt.Errorf("nonce: %w", err)
}
ct := gcm.Seal(nil, nonce, []byte(plaintext), nil)
envelope := append(nonce, ct...)
return base64.RawURLEncoding.EncodeToString(envelope), nil
}
// OpenString reverses SealString. Empty input returns empty output.
// Any decode/auth failure is reported — never silently fall back to
// returning the input as plaintext.
func OpenString(envelope string) (string, error) {
if envelope == "" {
return "", nil
}
key, err := loadKey()
if err != nil {
return "", err
}
raw, err := base64.RawURLEncoding.DecodeString(envelope)
if err != nil {
return "", fmt.Errorf("base64: %w", err)
}
block, err := aes.NewCipher(key)
if err != nil {
return "", fmt.Errorf("aes new: %w", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return "", fmt.Errorf("gcm new: %w", err)
}
if len(raw) < gcm.NonceSize() {
return "", errors.New("ciphertext too short")
}
nonce, ct := raw[:gcm.NonceSize()], raw[gcm.NonceSize():]
pt, err := gcm.Open(nil, nonce, ct, nil)
if err != nil {
return "", fmt.Errorf("gcm open (key mismatch or tampered): %w", err)
}
return string(pt), nil
}
// SealMap encrypts every value of m in place, returning a new map.
// Useful for kv-credential maps without writing a loop at every caller.
func SealMap(m map[string]string) (map[string]string, error) {
if m == nil {
return nil, nil
}
out := make(map[string]string, len(m))
for k, v := range m {
sealed, err := SealString(v)
if err != nil {
return nil, fmt.Errorf("seal %q: %w", k, err)
}
out[k] = sealed
}
return out, nil
}
// OpenMap reverses SealMap.
func OpenMap(m map[string]string) (map[string]string, error) {
if m == nil {
return nil, nil
}
out := make(map[string]string, len(m))
for k, v := range m {
opened, err := OpenString(v)
if err != nil {
return nil, fmt.Errorf("open %q: %w", k, err)
}
out[k] = opened
}
return out, nil
}
// IsConfigured reports whether the master key is set. Useful at startup
// so the API can refuse to mount the credentials endpoints (or warn
// loudly) when secrets won't work.
func IsConfigured() bool {
_, err := loadKey()
return err == nil
}