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:
@@ -11,7 +11,7 @@ import (
|
|||||||
"gorm.io/gorm"
|
"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.
|
// Serve is registered separately via surf.ServeCommand.
|
||||||
func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Command {
|
func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Command {
|
||||||
return []bonfire.Command{
|
return []bonfire.Command{
|
||||||
@@ -71,6 +71,7 @@ func RuntimeCommands(app *backpack.App, plugins []party.Plugin) []bonfire.Comman
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
KeyGenerateCommand(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -80,11 +80,21 @@ func gormFromSQL(sqlDB *sql.DB) (*gorm.DB, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// OpenFromApp reads database.dsn from app config and opens the shared pool.
|
// 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) {
|
func OpenFromApp(ctx context.Context, app *backpack.App) (*sql.DB, *gorm.DB, error) {
|
||||||
if app == nil || app.Config == nil {
|
if app == nil || app.Config == nil {
|
||||||
return nil, nil, fmt.Errorf("lagoon: app config is missing")
|
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).
|
// DSN returns database.dsn from layered config (env SUMMER_DATABASE__DSN).
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package lagoon
|
package lagoon
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"encoding/base64"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -139,6 +141,7 @@ func TestOpenFromAppReadsDSN(t *testing.T) {
|
|||||||
Environ: []string{
|
Environ: []string{
|
||||||
"SUMMER_ENV=development",
|
"SUMMER_ENV=development",
|
||||||
"SUMMER_DATABASE__DSN=" + dsn,
|
"SUMMER_DATABASE__DSN=" + dsn,
|
||||||
|
"SUMMER_APP__KEY=" + base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("T"), 32)),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
349
lagoon/encrypted.go
Normal file
349
lagoon/encrypted.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@ func TestEncryptedRedacts(t *testing.T) {
|
|||||||
}
|
}
|
||||||
goRepr := fmt.Sprintf("%#v", e)
|
goRepr := fmt.Sprintf("%#v", e)
|
||||||
if strings.Contains(goRepr, secret) {
|
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
26
lagoon/keygen.go
Normal 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
105
lagoon/laravel_decrypt.go
Normal 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
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ func TestRuntimeCommandsRegisterBareAndColonNames(t *testing.T) {
|
|||||||
for _, c := range cmds {
|
for _, c := range cmds {
|
||||||
names[c.Name] = true
|
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] {
|
if !names[want] {
|
||||||
t.Fatalf("missing %s", want)
|
t.Fatalf("missing %s", want)
|
||||||
}
|
}
|
||||||
@@ -131,11 +131,11 @@ type migPlugin struct {
|
|||||||
migrations []*gormigrate.Migration
|
migrations []*gormigrate.Migration
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p migPlugin) ID() string { return p.id }
|
func (p migPlugin) ID() string { return p.id }
|
||||||
func (p migPlugin) Requires() []string { return nil }
|
func (p migPlugin) Requires() []string { return nil }
|
||||||
func (p migPlugin) Register(*backpack.App) error { return nil }
|
func (p migPlugin) Register(*backpack.App) error { return nil }
|
||||||
func (p migPlugin) Boot(*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) Migrations() []*gormigrate.Migration { return p.migrations }
|
||||||
|
|
||||||
func TestTwoPluginMigrationSetsIsolated(t *testing.T) {
|
func TestTwoPluginMigrationSetsIsolated(t *testing.T) {
|
||||||
db, _ := dedicatedDB(t, "lagoon_mig_iso")
|
db, _ := dedicatedDB(t, "lagoon_mig_iso")
|
||||||
|
|||||||
Reference in New Issue
Block a user