- 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
350 lines
8.6 KiB
Go
350 lines
8.6 KiB
Go
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)
|
|
}
|