From 8d9109a77190a65b5d24bdc4de1dd39c43b1573e Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Fri, 18 Sep 2026 19:35:24 +0200 Subject: [PATCH] feat(05-03): implement lagoon.Encrypted, key:generate, Laravel decrypt helper - AES-256-GCM with stdlib HKDF column keys and previous_keys fallback - Fail-loud LoadAppKey, redacting marshal paths, key:generate via crypto/rand - Standalone DecryptLaravelPayload unwired from Scan/Value --- lagoon/commands.go | 3 +- lagoon/connection.go | 12 +- lagoon/connection_test.go | 3 + lagoon/encrypted.go | 349 ++++++++++++++++++++++++++++++++++++++ lagoon/encrypted_test.go | 2 +- lagoon/keygen.go | 26 +++ lagoon/laravel_decrypt.go | 105 ++++++++++++ lagoon/migrations_test.go | 12 +- 8 files changed, 503 insertions(+), 9 deletions(-) create mode 100644 lagoon/encrypted.go create mode 100644 lagoon/keygen.go create mode 100644 lagoon/laravel_decrypt.go diff --git a/lagoon/commands.go b/lagoon/commands.go index 1eb9db6..bc230b0 100644 --- a/lagoon/commands.go +++ b/lagoon/commands.go @@ -11,7 +11,7 @@ import ( "gorm.io/gorm" ) -// RuntimeCommands returns migrate, migrate:rollback and migrate:status. +// RuntimeCommands returns migrate, migrate:rollback, migrate:status, and key:generate. // Serve is registered separately via surf.ServeCommand. func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Command { return []bonfire.Command{ @@ -71,6 +71,7 @@ func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Comman }) }, }, + KeyGenerateCommand(), } } diff --git a/lagoon/connection.go b/lagoon/connection.go index f1bb65f..d774fc1 100644 --- a/lagoon/connection.go +++ b/lagoon/connection.go @@ -80,11 +80,21 @@ func gormFromSQL(sqlDB *sql.DB) (*gorm.DB, error) { } // OpenFromApp reads database.dsn from app config and opens the shared pool. +// It also loads app.key via LoadAppKey and PublishEncryptionKeys so Encrypted +// columns do not re-read config on every row. func OpenFromApp(ctx context.Context, app *backpack.App) (*sql.DB, *gorm.DB, error) { if app == nil || app.Config == nil { return nil, nil, fmt.Errorf("lagoon: app config is missing") } - return Open(ctx, DSN(app.Config)) + sqlDB, gdb, err := Open(ctx, DSN(app.Config)) + if err != nil { + return nil, nil, err + } + if err := loadEncryptionKeysFromApp(app); err != nil { + _ = sqlDB.Close() + return nil, nil, err + } + return sqlDB, gdb, nil } // DSN returns database.dsn from layered config (env SUMMER_DATABASE__DSN). diff --git a/lagoon/connection_test.go b/lagoon/connection_test.go index 04f705e..5685f02 100644 --- a/lagoon/connection_test.go +++ b/lagoon/connection_test.go @@ -1,7 +1,9 @@ package lagoon import ( + "bytes" "database/sql" + "encoding/base64" "os" "path/filepath" "strings" @@ -139,6 +141,7 @@ func TestOpenFromAppReadsDSN(t *testing.T) { Environ: []string{ "SUMMER_ENV=development", "SUMMER_DATABASE__DSN=" + dsn, + "SUMMER_APP__KEY=" + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("T"), 32)), }, }) if err != nil { diff --git a/lagoon/encrypted.go b/lagoon/encrypted.go new file mode 100644 index 0000000..0782055 --- /dev/null +++ b/lagoon/encrypted.go @@ -0,0 +1,349 @@ +package lagoon + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hkdf" + "crypto/rand" + "crypto/sha256" + "database/sql/driver" + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "sync" + + "git.golem15.com/golem15/summercms/backpack" + "git.golem15.com/golem15/summercms/compass" +) + +const ( + encryptedFormatV1 = byte(0x10) + encryptedNonceSize = 12 + encryptedKeySize = 32 + columnKeyInfo = "summercms.lagoon.encrypted.v1" + redactedLiteral = "[redacted]" + appKeyErr = "lagoon: app.key is empty or invalid (set SUMMER_APP__KEY to a 32-byte base64 value)" +) + +// Encrypted is an AES-256-GCM at-rest cast. Plaintext is unexported; the only +// greppable accessor is Reveal. MarshalJSON, String, and GoString always redact. +type Encrypted struct { + plaintext []byte + set bool +} + +// NewEncrypted holds plaintext for a subsequent Value() write. +func NewEncrypted(plaintext string) Encrypted { + return Encrypted{plaintext: []byte(plaintext), set: true} +} + +// Reveal returns the plaintext, or "" if the value is unset. +func (e Encrypted) Reveal() string { + if !e.set { + return "" + } + return string(e.plaintext) +} + +// MarshalJSON always emits a redaction, never plaintext. +func (e Encrypted) MarshalJSON() ([]byte, error) { + return json.Marshal(redactedLiteral) +} + +// String returns a fixed redaction literal. +func (e Encrypted) String() string { return redactedLiteral } + +// GoString returns a fixed redaction so %#v cannot leak plaintext. +func (e Encrypted) GoString() string { return "lagoon.Encrypted{[redacted]}" } + +// Scan decrypts versioned ciphertext using the published primary key, then +// each previous key. src may be string, []byte, or nil (SQL NULL). +func (e *Encrypted) Scan(src any) error { + if e == nil { + return fmt.Errorf("lagoon: encrypted scan on nil receiver") + } + if src == nil { + e.plaintext = nil + e.set = false + return nil + } + var raw string + switch v := src.(type) { + case string: + raw = v + case []byte: + raw = string(v) + default: + return fmt.Errorf("lagoon: encrypted scan unsupported type %T", src) + } + raw = strings.TrimSpace(raw) + if raw == "" { + e.plaintext = nil + e.set = false + return nil + } + plain, err := decryptCiphertext(raw) + if err != nil { + return err + } + e.plaintext = plain + e.set = true + return nil +} + +// Value re-encrypts the current plaintext under the published primary key. +// Unset values store SQL NULL. +func (e Encrypted) Value() (driver.Value, error) { + if !e.set { + return nil, nil + } + return encryptPlaintext(e.plaintext) +} + +type encryptionKeys struct { + primary []byte + previous [][]byte + primaryID byte +} + +var ( + keysMu sync.RWMutex + currentKeys *encryptionKeys +) + +// PublishEncryptionKeys installs column-encryption keys for Encrypted Scan/Value. +// OpenFromApp calls this once per boot so row-level encrypt/decrypt does not +// re-read config. Pass a nil app from tests that only need the package-level +// key set. The backpack publish is best-effort once-per-boot; package-level +// keys are the live source of truth and may be rotated in tests. +func PublishEncryptionKeys(app *backpack.App, primaryKey []byte, previousKeys [][]byte) error { + k, err := newEncryptionKeys(primaryKey, previousKeys) + if err != nil { + return err + } + setCurrentKeys(k) + if app == nil { + return nil + } + if _, ok := app.Lookup[*encryptionKeys](); ok { + return nil + } + return app.Publish(k) +} + +func newEncryptionKeys(primaryKey []byte, previousKeys [][]byte) (*encryptionKeys, error) { + if len(primaryKey) != encryptedKeySize { + return nil, fmt.Errorf(appKeyErr) + } + prev := make([][]byte, 0, len(previousKeys)) + for i, k := range previousKeys { + if len(k) != encryptedKeySize { + return nil, fmt.Errorf("lagoon: app.previous_keys[%d] is empty or invalid (set SUMMER_APP__KEY to a 32-byte base64 value)", i) + } + prev = append(prev, cloneBytes(k)) + } + id := byte(len(prev) + 1) + if id > 0x0F { + id = 0x0F + } + return &encryptionKeys{ + primary: cloneBytes(primaryKey), + previous: prev, + primaryID: id, + }, nil +} + +func setCurrentKeys(k *encryptionKeys) { + keysMu.Lock() + currentKeys = k + keysMu.Unlock() +} + +func clearEncryptionKeys() { + setCurrentKeys(nil) +} + +func liveKeys() (*encryptionKeys, error) { + keysMu.RLock() + k := currentKeys + keysMu.RUnlock() + if k == nil || len(k.primary) != encryptedKeySize { + return nil, fmt.Errorf(appKeyErr) + } + return k, nil +} + +// LoadAppKey reads app.key and app.previous_keys from cfg. Missing, short, or +// undecodable values fail loudly with no default (D-11). +func LoadAppKey(cfg *compass.Config) ([]byte, [][]byte, error) { + if cfg == nil { + return nil, nil, fmt.Errorf(appKeyErr) + } + primary, err := decodeAppKey(cfg.String("app.key")) + if err != nil { + return nil, nil, err + } + var previous [][]byte + if v, ok := cfg.Lookup("app.previous_keys"); ok && v != nil { + previous, err = decodePreviousKeys(v) + if err != nil { + return nil, nil, err + } + } + return primary, previous, nil +} + +func decodeAppKey(s string) ([]byte, error) { + s = strings.TrimSpace(s) + if s == "" { + return nil, fmt.Errorf(appKeyErr) + } + raw, err := base64.StdEncoding.DecodeString(s) + if err != nil || len(raw) != encryptedKeySize { + return nil, fmt.Errorf(appKeyErr) + } + return raw, nil +} + +func decodePreviousKeys(v any) ([][]byte, error) { + items, ok := asStringList(v) + if !ok { + return nil, fmt.Errorf("lagoon: app.previous_keys is empty or invalid (set SUMMER_APP__KEY to a 32-byte base64 value)") + } + out := make([][]byte, 0, len(items)) + for _, item := range items { + raw, err := decodeAppKey(item) + if err != nil { + return nil, fmt.Errorf("lagoon: app.previous_keys is empty or invalid (set SUMMER_APP__KEY to a 32-byte base64 value)") + } + out = append(out, raw) + } + return out, nil +} + +func asStringList(v any) ([]string, bool) { + switch list := v.(type) { + case []string: + return list, true + case []any: + out := make([]string, 0, len(list)) + for _, item := range list { + s, ok := item.(string) + if !ok { + return nil, false + } + out = append(out, s) + } + return out, true + default: + return nil, false + } +} + +func deriveColumnKey(appKey []byte) ([]byte, error) { + if len(appKey) != encryptedKeySize { + return nil, fmt.Errorf(appKeyErr) + } + return hkdf.Key(sha256.New, appKey, nil, columnKeyInfo, encryptedKeySize) +} + +func encryptPlaintext(plain []byte) (string, error) { + keys, err := liveKeys() + if err != nil { + return "", err + } + colKey, err := deriveColumnKey(keys.primary) + if err != nil { + return "", err + } + block, err := aes.NewCipher(colKey) + if err != nil { + return "", fmt.Errorf("lagoon: encrypted aes: %w", err) + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return "", fmt.Errorf("lagoon: encrypted gcm: %w", err) + } + nonce := make([]byte, encryptedNonceSize) + if _, err := rand.Read(nonce); err != nil { + return "", fmt.Errorf("lagoon: encrypted nonce: %w", err) + } + sealed := gcm.Seal(nil, nonce, plain, nil) + out := make([]byte, 1+len(nonce)+len(sealed)) + out[0] = encryptedFormatV1 | (keys.primaryID & 0x0F) + copy(out[1:], nonce) + copy(out[1+len(nonce):], sealed) + return base64.StdEncoding.EncodeToString(out), nil +} + +func decryptCiphertext(stored string) ([]byte, error) { + keys, err := liveKeys() + if err != nil { + return nil, err + } + raw, err := base64.StdEncoding.DecodeString(stored) + if err != nil { + return nil, fmt.Errorf("lagoon: encrypted ciphertext is not base64") + } + if len(raw) < 1+encryptedNonceSize+16 { + return nil, fmt.Errorf("lagoon: encrypted ciphertext is truncated") + } + if raw[0]&0xF0 != encryptedFormatV1 { + return nil, fmt.Errorf("lagoon: encrypted ciphertext has unknown format") + } + nonce := raw[1 : 1+encryptedNonceSize] + sealed := raw[1+encryptedNonceSize:] + + candidates := make([][]byte, 0, 1+len(keys.previous)) + candidates = append(candidates, keys.primary) + candidates = append(candidates, keys.previous...) + var last error + for _, appKey := range candidates { + plain, err := gcmOpen(appKey, nonce, sealed) + if err == nil { + return plain, nil + } + last = err + } + if last == nil { + last = fmt.Errorf("lagoon: encrypted decrypt failed") + } + return nil, last +} + +func gcmOpen(appKey, nonce, sealed []byte) ([]byte, error) { + colKey, err := deriveColumnKey(appKey) + if err != nil { + return nil, err + } + block, err := aes.NewCipher(colKey) + if err != nil { + return nil, err + } + gcm, err := cipher.NewGCM(block) + if err != nil { + return nil, err + } + return gcm.Open(nil, nonce, sealed, nil) +} + +func cloneBytes(b []byte) []byte { + if b == nil { + return nil + } + out := make([]byte, len(b)) + copy(out, b) + return out +} + +func loadEncryptionKeysFromApp(app *backpack.App) error { + if app == nil || app.Config == nil { + return fmt.Errorf(appKeyErr) + } + primary, previous, err := LoadAppKey(app.Config) + if err != nil { + return err + } + return PublishEncryptionKeys(app, primary, previous) +} diff --git a/lagoon/encrypted_test.go b/lagoon/encrypted_test.go index 3966bfe..9746928 100644 --- a/lagoon/encrypted_test.go +++ b/lagoon/encrypted_test.go @@ -78,7 +78,7 @@ func TestEncryptedRedacts(t *testing.T) { } goRepr := fmt.Sprintf("%#v", e) if strings.Contains(goRepr, secret) { - t.Fatalf("GoString/%#v leaked plaintext: %q", goRepr) + t.Fatalf("GoString/%%#v leaked plaintext: %q", goRepr) } } diff --git a/lagoon/keygen.go b/lagoon/keygen.go new file mode 100644 index 0000000..950dc89 --- /dev/null +++ b/lagoon/keygen.go @@ -0,0 +1,26 @@ +package lagoon + +import ( + "context" + "crypto/rand" + "encoding/base64" + + "git.golem15.com/golem15/summercms/bonfire" +) + +// KeyGenerateCommand prints a fresh 32-byte base64 app.key and performs no +// other side effect (D-11). +func KeyGenerateCommand() bonfire.Command { + return bonfire.Command{ + Name: "key:generate", + Description: "Print a fresh 32-byte base64 app.key", + Run: func(ctx context.Context, in bonfire.Input, out bonfire.Output) error { + key := make([]byte, encryptedKeySize) + if _, err := rand.Read(key); err != nil { + return err + } + out.Success(base64.StdEncoding.EncodeToString(key)) + return nil + }, + } +} diff --git a/lagoon/laravel_decrypt.go b/lagoon/laravel_decrypt.go new file mode 100644 index 0000000..bc6855c --- /dev/null +++ b/lagoon/laravel_decrypt.go @@ -0,0 +1,105 @@ +package lagoon + +import ( + "crypto/aes" + "crypto/cipher" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "strings" +) + +// laravelPayload is Laravel's Encrypter JSON body (iv/value/mac). +type laravelPayload struct { + IV string `json:"iv"` + Value string `json:"value"` + MAC string `json:"mac"` +} + +// DecryptLaravelPayload decrypts a Laravel AES-256-CBC+HMAC "encrypted" payload +// (base64 JSON {iv,value,mac}) using the raw APP_KEY bytes. +// +// Cutover-import-only; never called from Encrypted's live Scan/Value path (D-10). +func DecryptLaravelPayload(payloadJSON string, appKey []byte) ([]byte, error) { + if len(appKey) != encryptedKeySize { + return nil, fmt.Errorf("lagoon: laravel payload key must be 32 bytes") + } + body, err := decodeLaravelJSON(payloadJSON) + if err != nil { + return nil, err + } + var payload laravelPayload + if err := json.Unmarshal(body, &payload); err != nil { + return nil, fmt.Errorf("lagoon: laravel payload json: %w", err) + } + if payload.IV == "" || payload.Value == "" || payload.MAC == "" { + return nil, fmt.Errorf("lagoon: laravel payload missing iv, value, or mac") + } + wantMAC, err := hex.DecodeString(payload.MAC) + if err != nil { + return nil, fmt.Errorf("lagoon: laravel payload mac: %w", err) + } + mac := hmac.New(sha256.New, appKey) + _, _ = mac.Write([]byte(payload.IV + payload.Value)) + gotMAC := mac.Sum(nil) + if !hmac.Equal(wantMAC, gotMAC) { + return nil, fmt.Errorf("lagoon: laravel payload mac mismatch") + } + iv, err := base64.StdEncoding.DecodeString(payload.IV) + if err != nil { + return nil, fmt.Errorf("lagoon: laravel payload iv: %w", err) + } + ct, err := base64.StdEncoding.DecodeString(payload.Value) + if err != nil { + return nil, fmt.Errorf("lagoon: laravel payload value: %w", err) + } + if len(iv) != aes.BlockSize { + return nil, fmt.Errorf("lagoon: laravel payload iv length %d", len(iv)) + } + if len(ct) == 0 || len(ct)%aes.BlockSize != 0 { + return nil, fmt.Errorf("lagoon: laravel payload ciphertext length %d", len(ct)) + } + block, err := aes.NewCipher(appKey) + if err != nil { + return nil, err + } + plain := make([]byte, len(ct)) + cipher.NewCBCDecrypter(block, iv).CryptBlocks(plain, ct) + return pkcs7Unpad(plain, aes.BlockSize) +} + +func decodeLaravelJSON(payloadJSON string) ([]byte, error) { + s := strings.TrimSpace(payloadJSON) + if s == "" { + return nil, fmt.Errorf("lagoon: laravel payload is empty") + } + if decoded, err := base64.StdEncoding.DecodeString(s); err == nil { + trimmed := strings.TrimSpace(string(decoded)) + if strings.HasPrefix(trimmed, "{") { + return []byte(trimmed), nil + } + } + if strings.HasPrefix(s, "{") { + return []byte(s), nil + } + return nil, fmt.Errorf("lagoon: laravel payload is not base64 JSON") +} + +func pkcs7Unpad(b []byte, blockSize int) ([]byte, error) { + if blockSize <= 0 || len(b) == 0 || len(b)%blockSize != 0 { + return nil, fmt.Errorf("lagoon: laravel payload padding") + } + pad := int(b[len(b)-1]) + if pad == 0 || pad > blockSize || pad > len(b) { + return nil, fmt.Errorf("lagoon: laravel payload padding") + } + for i := len(b) - pad; i < len(b); i++ { + if int(b[i]) != pad { + return nil, fmt.Errorf("lagoon: laravel payload padding") + } + } + return b[:len(b)-pad], nil +} diff --git a/lagoon/migrations_test.go b/lagoon/migrations_test.go index 1acf977..446dda1 100644 --- a/lagoon/migrations_test.go +++ b/lagoon/migrations_test.go @@ -61,7 +61,7 @@ func TestRuntimeCommandsRegisterBareAndColonNames(t *testing.T) { for _, c := range cmds { names[c.Name] = true } - for _, want := range []string{"migrate", "migrate:rollback", "migrate:status"} { + for _, want := range []string{"migrate", "migrate:rollback", "migrate:status", "key:generate"} { if !names[want] { t.Fatalf("missing %s", want) } @@ -131,11 +131,11 @@ type migPlugin struct { migrations []*gormigrate.Migration } -func (p migPlugin) ID() string { return p.id } -func (p migPlugin) Requires() []string { return nil } -func (p migPlugin) Register(*backpack.App) error { return nil } -func (p migPlugin) Boot(*backpack.App) error { return nil } -func (p migPlugin) Migrations() []*gormigrate.Migration { return p.migrations } +func (p migPlugin) ID() string { return p.id } +func (p migPlugin) Requires() []string { return nil } +func (p migPlugin) Register(*backpack.App) error { return nil } +func (p migPlugin) Boot(*backpack.App) error { return nil } +func (p migPlugin) Migrations() []*gormigrate.Migration { return p.migrations } func TestTwoPluginMigrationSetsIsolated(t *testing.T) { db, _ := dedicatedDB(t, "lagoon_mig_iso")