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
This commit is contained in:
Jakub Zych
2026-09-18 19:35:24 +02:00
parent 06bad37c24
commit 8d9109a771
8 changed files with 503 additions and 9 deletions

View File

@@ -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(),
}
}

View File

@@ -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).

View File

@@ -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 {

349
lagoon/encrypted.go Normal file
View File

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

View File

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

26
lagoon/keygen.go Normal file
View File

@@ -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
},
}
}

105
lagoon/laravel_decrypt.go Normal file
View File

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

View File

@@ -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")