diff --git a/lagoon/encrypted_test.go b/lagoon/encrypted_test.go new file mode 100644 index 0000000..3966bfe --- /dev/null +++ b/lagoon/encrypted_test.go @@ -0,0 +1,184 @@ +package lagoon + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/compass" +) + +func TestEncryptedRoundTrip(t *testing.T) { + key := bytes.Repeat([]byte("A"), 32) + if err := PublishEncryptionKeys(nil, key, nil); err != nil { + t.Fatal(err) + } + t.Cleanup(clearEncryptionKeys) + + original := NewEncrypted("hello-secret") + stored, err := original.Value() + if err != nil { + t.Fatal(err) + } + ciphertext, ok := stored.(string) + if !ok { + t.Fatalf("Value() type %T, want string", stored) + } + if ciphertext == "" || ciphertext == "hello-secret" { + t.Fatalf("Value() stored plaintext: %q", ciphertext) + } + + var got Encrypted + if err := got.Scan(ciphertext); err != nil { + t.Fatal(err) + } + if got.Reveal() != "hello-secret" { + t.Fatalf("Reveal() = %q, want %q", got.Reveal(), "hello-secret") + } +} + +func TestEncryptedFreshNonce(t *testing.T) { + key := bytes.Repeat([]byte("B"), 32) + if err := PublishEncryptionKeys(nil, key, nil); err != nil { + t.Fatal(err) + } + t.Cleanup(clearEncryptionKeys) + + a, err := NewEncrypted("same-plaintext").Value() + if err != nil { + t.Fatal(err) + } + b, err := NewEncrypted("same-plaintext").Value() + if err != nil { + t.Fatal(err) + } + if a == b { + t.Fatal("two encrypts of the same plaintext must not produce identical ciphertext") + } +} + +func TestEncryptedRedacts(t *testing.T) { + const secret = "super-secret-plaintext" + e := NewEncrypted(secret) + + raw, err := json.Marshal(e) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(raw, []byte(secret)) { + t.Fatalf("MarshalJSON leaked plaintext: %s", raw) + } + if strings.Contains(e.String(), secret) { + t.Fatalf("String() leaked plaintext: %q", e.String()) + } + goRepr := fmt.Sprintf("%#v", e) + if strings.Contains(goRepr, secret) { + t.Fatalf("GoString/%#v leaked plaintext: %q", goRepr) + } +} + +func TestEncryptedPreviousKeysFallback(t *testing.T) { + key1 := bytes.Repeat([]byte{1}, 32) + key2 := bytes.Repeat([]byte{2}, 32) + cfg1 := appKeyConfig(t, base64.StdEncoding.EncodeToString(key1), nil) + primary1, previous1, err := LoadAppKey(cfg1) + if err != nil { + t.Fatal(err) + } + if err := PublishEncryptionKeys(nil, primary1, previous1); err != nil { + t.Fatal(err) + } + + stored, err := NewEncrypted("rotate-me").Value() + if err != nil { + t.Fatal(err) + } + ciphertext, ok := stored.(string) + if !ok { + t.Fatalf("Value() type %T", stored) + } + + cfg2 := appKeyConfig(t, base64.StdEncoding.EncodeToString(key2), []string{base64.StdEncoding.EncodeToString(key1)}) + primary2, previous2, err := LoadAppKey(cfg2) + if err != nil { + t.Fatal(err) + } + if err := PublishEncryptionKeys(nil, primary2, previous2); err != nil { + t.Fatal(err) + } + t.Cleanup(clearEncryptionKeys) + + var got Encrypted + if err := got.Scan(ciphertext); err != nil { + t.Fatal(err) + } + if got.Reveal() != "rotate-me" { + t.Fatalf("Reveal() after rotation = %q", got.Reveal()) + } +} + +func TestEncryptedLoadAppKeyRejectsInvalid(t *testing.T) { + t.Parallel() + cases := []struct { + name string + key string + }{ + {name: "empty", key: ""}, + {name: "short", key: base64.StdEncoding.EncodeToString(bytes.Repeat([]byte("x"), 16))}, + {name: "undecodable", key: "not-valid-base64!!!"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + cfg := appKeyConfig(t, tc.key, nil) + _, _, err := LoadAppKey(cfg) + if err == nil { + t.Fatal("want error") + } + msg := err.Error() + if !strings.Contains(msg, "SUMMER_APP__KEY") { + t.Fatalf("error %q must mention SUMMER_APP__KEY", msg) + } + if !strings.Contains(msg, "lagoon:") { + t.Fatalf("error %q must be a named lagoon error", msg) + } + }) + } +} + +func appKeyConfig(t *testing.T, key string, previous []string) *compass.Config { + t.Helper() + dir := t.TempDir() + var b strings.Builder + b.WriteString("key: ") + b.WriteString(quoteYAML(key)) + b.WriteByte('\n') + if previous != nil { + b.WriteString("previous_keys:\n") + for _, p := range previous { + b.WriteString(" - ") + b.WriteString(quoteYAML(p)) + b.WriteByte('\n') + } + } + if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte(b.String()), 0o644); err != nil { + t.Fatal(err) + } + cfg, err := compass.Open(compass.Options{ + Dir: dir, + Env: "development", + Environ: []string{"SUMMER_ENV=development"}, + }) + if err != nil { + t.Fatal(err) + } + return cfg +} + +func quoteYAML(s string) string { + return `"` + strings.ReplaceAll(s, `"`, `\"`) + `"` +} diff --git a/lagoon/keygen_test.go b/lagoon/keygen_test.go new file mode 100644 index 0000000..513ec63 --- /dev/null +++ b/lagoon/keygen_test.go @@ -0,0 +1,40 @@ +package lagoon + +import ( + "bytes" + "context" + "encoding/base64" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/bonfire" +) + +func TestEncryptedKeyGeneratePrints32ByteBase64(t *testing.T) { + cmd := KeyGenerateCommand() + if cmd.Name != "key:generate" { + t.Fatalf("Name = %q", cmd.Name) + } + if cmd.Run == nil { + t.Fatal("Run is nil") + } + + var buf bytes.Buffer + out := bonfire.NewOutput(strings.NewReader(""), &buf, &buf) + if err := cmd.Run(context.Background(), nil, out); err != nil { + t.Fatal(err) + } + line := strings.TrimSpace(buf.String()) + fields := strings.Fields(line) + if len(fields) == 0 { + t.Fatal("key:generate produced no output") + } + encoded := fields[len(fields)-1] + raw, err := base64.StdEncoding.DecodeString(encoded) + if err != nil { + t.Fatalf("output %q is not base64: %v", encoded, err) + } + if len(raw) != 32 { + t.Fatalf("decoded key length = %d, want 32", len(raw)) + } +} diff --git a/lagoon/laravel_decrypt_test.go b/lagoon/laravel_decrypt_test.go new file mode 100644 index 0000000..1248945 --- /dev/null +++ b/lagoon/laravel_decrypt_test.go @@ -0,0 +1,21 @@ +package lagoon + +import ( + "bytes" + "testing" +) + +// Fixture produced by PHP openssl_encrypt AES-256-CBC + HMAC-SHA256 over iv+value, +// matching Laravel's Encrypter payload (base64 JSON {iv,value,mac}) with serialize=false. +const laravelTestPayload = "eyJpdiI6IlNVbEpTVWxKU1VsSlNVbEpTVWxKU1E9PSIsInZhbHVlIjoiYVJ6ODYrRitUM25oVFNoR0o5WGMxbUJtSW5DeWdaVWN6WktYR3RFV3liZz0iLCJtYWMiOiI4YmI4NTRmZDk0ZmQ0NDYzNjk3ZDhmYmQ3M2QyNzFhYzI1MGI1YjFhNzk2ZDdiOWVjN2YyYmQ3YzJjOTk4MzBiIn0=" + +func TestEncryptedDecryptLaravelPayload(t *testing.T) { + key := bytes.Repeat([]byte("K"), 32) + got, err := DecryptLaravelPayload(laravelTestPayload, key) + if err != nil { + t.Fatal(err) + } + if string(got) != "sk-test-super-secret-api-key" { + t.Fatalf("decrypted = %q", got) + } +}