mirror of
https://github.com/open-gitagent/langship.sh.git
synced 2026-08-03 07:21:04 +02:00
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
157 lines
4.2 KiB
Go
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
|
|
}
|