refactor(10.2-01): nest framework packages under modules

- Move remaining beach packages and embedded admin assets\n- Rewrite framework, example, build, and gate paths
This commit is contained in:
Jakub Zych
2026-09-28 02:21:02 +02:00
parent ac1f6d14f4
commit 5e50b166ef
277 changed files with 303 additions and 303 deletions

349
modules/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/modules/backpack"
"git.golem15.com/golem15/summercms/modules/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)
}