molecule-core/workspace-server/internal/crypto/aes.go
Hongming Wang 479a027e4b chore: open-source restructure — rename dirs, remove internal files, scrub secrets
Renames:
- platform/ → workspace-server/ (Go module path stays as "platform" for
  external dep compat — will update after plugin module republish)
- workspace-template/ → workspace/

Removed (moved to separate repos or deleted):
- PLAN.md — internal roadmap (move to private project board)
- HANDOFF.md, AGENTS.md — one-time internal session docs
- .claude/ — gitignored entirely (local agent config)
- infra/cloudflare-worker/ → Molecule-AI/molecule-tenant-proxy
- org-templates/molecule-dev/ → standalone template repo
- .mcp-eval/ → molecule-mcp-server repo
- test-results/ — ephemeral, gitignored

Security scrubbing:
- Cloudflare account/zone/KV IDs → placeholders
- Real EC2 IPs → <EC2_IP> in all docs
- CF token prefix, Neon project ID, Fly app names → redacted
- Langfuse dev credentials → parameterized
- Personal runner username/machine name → generic

Community files:
- CONTRIBUTING.md — build, test, branch conventions
- CODE_OF_CONDUCT.md — Contributor Covenant 2.1

All Dockerfiles, CI workflows, docker-compose, railway.toml, render.yaml,
README, CLAUDE.md updated for new directory names.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-18 00:24:44 -07:00

237 lines
7.5 KiB
Go

// Package crypto provides AES-256 encryption for workspace secrets.
//
// Production-mode fail-secure (Top-5 #5 from the ecosystem-research outcomes
// doc, Security Auditor's top proposal): when MOLECULE_ENV=prod, Init
// refuses to let the platform boot without a valid 32-byte key. Dev mode
// retains the historical "warn + store plaintext" fallback so local
// developers aren't forced to generate a key to run the test server.
package crypto
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"encoding/base64"
"errors"
"fmt"
"io"
"log"
"os"
"strings"
"sync"
)
// ErrEncryptionKeyMissing is returned by InitStrict when MOLECULE_ENV=prod
// and SECRETS_ENCRYPTION_KEY is unset, malformed, or the wrong length.
// Callers (cmd/server/main.go) must treat this as a fatal boot error.
var ErrEncryptionKeyMissing = errors.New(
"SECRETS_ENCRYPTION_KEY is required in production (MOLECULE_ENV=prod) " +
"and must be 32 bytes raw or base64-encoded",
)
var (
encryptionKey []byte
initOnce sync.Once
)
// Init loads the encryption key from SECRETS_ENCRYPTION_KEY env var.
// Safe to call multiple times — only executes once in production.
//
// Legacy dev-mode entry point: logs a WARNING when the key is missing or
// invalid and continues with encryption disabled. Production code paths
// (cmd/server/main.go) should call InitStrict instead so that missing
// keys abort boot when MOLECULE_ENV=prod.
func Init() {
initOnce.Do(initKey)
}
// InitStrict is the fail-secure variant of Init. When MOLECULE_ENV=prod
// (or any strconv.ParseBool-truthy value), missing / malformed / wrong-
// length keys return ErrEncryptionKeyMissing so the caller can refuse to
// boot. In all other environments the behaviour matches Init — warn and
// continue with encryption disabled for local dev ergonomics.
func InitStrict() error {
var initErr error
initOnce.Do(func() {
initErr = initKeyStrict()
})
return initErr
}
// ResetForTesting clears the encryption key and allows re-initialization.
// Only for tests — not safe for concurrent use.
func ResetForTesting() {
encryptionKey = nil
initOnce = sync.Once{}
}
func initKey() {
if err := loadKeyFromEnv(); err != nil {
log.Printf("%s", err)
}
}
func initKeyStrict() error {
loadErr := loadKeyFromEnv()
if isProdEnv() && !IsEnabled() {
if loadErr != nil {
log.Printf("FATAL: %s", loadErr)
}
return fmt.Errorf("%w: refusing to boot without encryption in production", ErrEncryptionKeyMissing)
}
if loadErr != nil {
// Non-prod: match Init's historical warn-and-continue behaviour.
log.Printf("%s", loadErr)
}
return nil
}
// loadKeyFromEnv parses SECRETS_ENCRYPTION_KEY into encryptionKey.
// Returns a non-nil error describing the problem when the env var is set
// but unusable (wrong length, unparseable). Returns nil when the var is
// unset OR successfully applied.
func loadKeyFromEnv() error {
key := os.Getenv("SECRETS_ENCRYPTION_KEY")
if key == "" {
return nil
}
decoded, err := base64.StdEncoding.DecodeString(key)
if err != nil {
// Try raw key (must be exactly 32 bytes)
if len(key) == 32 {
encryptionKey = []byte(key)
return nil
}
return fmt.Errorf("SECRETS_ENCRYPTION_KEY is set but invalid (not base64 and not 32 bytes). Encryption disabled")
}
if len(decoded) == 32 {
encryptionKey = decoded
return nil
}
return fmt.Errorf("SECRETS_ENCRYPTION_KEY decoded to %d bytes (expected 32). Encryption disabled", len(decoded))
}
// isProdEnv returns true when MOLECULE_ENV matches one of the canonical
// production markers. Accepts case-insensitive "prod" / "production".
func isProdEnv() bool {
v := strings.ToLower(strings.TrimSpace(os.Getenv("MOLECULE_ENV")))
return v == "prod" || v == "production"
}
// IsEnabled returns true if encryption is configured.
func IsEnabled() bool {
return len(encryptionKey) == 32
}
// Encryption version tags. Stored in workspace_secrets.encryption_version +
// global_secrets.encryption_version (migration 018). DecryptVersioned reads
// the tag to decide whether to run GCM or pass bytes through. Prevents the
// "turn on encryption → all historical secrets become unreadable" trap
// documented in #85.
const (
// EncryptionVersionPlaintext identifies rows written when the platform
// was running without a key. encrypted_value column holds literal
// plaintext bytes.
EncryptionVersionPlaintext = 0
// EncryptionVersionAESGCM identifies rows written with the current
// scheme (AES-256-GCM, 12-byte prefix nonce).
EncryptionVersionAESGCM = 1
)
// CurrentEncryptionVersion returns the version a new Encrypt call would
// produce given the current key state. Callers use this as the value to
// INSERT into encryption_version.
func CurrentEncryptionVersion() int {
if IsEnabled() {
return EncryptionVersionAESGCM
}
return EncryptionVersionPlaintext
}
// Encrypt encrypts plaintext with AES-256-GCM.
// If encryption is disabled, returns the plaintext as-is.
//
// Callers should persist CurrentEncryptionVersion() alongside the returned
// bytes so Decrypt / DecryptVersioned knows how to read them back.
func Encrypt(plaintext []byte) ([]byte, error) {
if !IsEnabled() {
return plaintext, nil
}
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
return nil, err
}
return gcm.Seal(nonce, nonce, plaintext, nil), nil
}
// Decrypt decrypts AES-256-GCM ciphertext.
// If encryption is disabled, returns the data as-is.
//
// **Prefer DecryptVersioned** when the caller has access to the
// encryption_version column — this function can't tell plaintext rows from
// ciphertext and will attempt GCM on both when a key is set, mangling
// plaintext-era secrets. Kept for backward compatibility with callers that
// haven't migrated to the version-aware helper yet.
func Decrypt(ciphertext []byte) ([]byte, error) {
if !IsEnabled() {
return ciphertext, nil
}
block, err := aes.NewCipher(encryptionKey)
if err != nil {
return nil, err
}
gcm, err := cipher.NewGCM(block)
if err != nil {
return nil, err
}
nonceSize := gcm.NonceSize()
if len(ciphertext) < nonceSize {
return nil, errors.New("ciphertext too short")
}
nonce, ciphertext := ciphertext[:nonceSize], ciphertext[nonceSize:]
return gcm.Open(nil, nonce, ciphertext, nil)
}
// DecryptVersioned is the #85 replacement for Decrypt. It reads the
// encryption_version tag and picks the right path:
//
// - EncryptionVersionPlaintext (0): return the bytes as-is. Correct both
// on installs that never had a key, AND on installs where the operator
// has since enabled encryption but existing rows predate the key.
// - EncryptionVersionAESGCM (1): run AES-256-GCM decrypt as before.
// Fails with a clear error if IsEnabled() is false — an operator who
// enabled then disabled encryption without re-encrypting rows.
//
// Callers that store rows this cycle should write
// CurrentEncryptionVersion() into the encryption_version column alongside
// the bytes produced by Encrypt.
func DecryptVersioned(value []byte, version int) ([]byte, error) {
switch version {
case EncryptionVersionPlaintext:
return value, nil
case EncryptionVersionAESGCM:
if !IsEnabled() {
return nil, errors.New("row was encrypted (version=1) but SECRETS_ENCRYPTION_KEY is unset; cannot decrypt")
}
return Decrypt(value)
default:
return nil, fmt.Errorf("unknown encryption_version=%d; platform upgrade required", version)
}
}