143 lines
4.6 KiB
Go
143 lines
4.6 KiB
Go
package e2e
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
"time"
|
|
|
|
embeddedpostgres "github.com/fergusstrange/embedded-postgres"
|
|
"github.com/google/uuid"
|
|
|
|
"lambdamail/protocols/internal/infrastructure/dkim"
|
|
"lambdamail/protocols/internal/infrastructure/postgres"
|
|
"lambdamail/protocols/internal/infrastructure/vault"
|
|
httppresentation "lambdamail/protocols/internal/presentation/http"
|
|
)
|
|
|
|
// Rotation has to leave a key the signer can actually open. An earlier
|
|
// implementation retired the live key and inserted a placeholder as ACTIVE,
|
|
// which made FindActiveKeys fail for the whole domain - the console's "rotate"
|
|
// button silently stopped the domain being able to sign anything.
|
|
func TestDkimRotationLeavesAUsableKey(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("e2e test skipped in -short mode")
|
|
}
|
|
ctx := context.Background()
|
|
|
|
runtimeDir := t.TempDir()
|
|
pg := embeddedpostgres.NewDatabase(embeddedpostgres.DefaultConfig().
|
|
Port(15449).
|
|
RuntimePath(runtimeDir).
|
|
StartTimeout(120 * time.Second))
|
|
if err := pg.Start(); err != nil {
|
|
t.Fatalf("start embedded postgres: %v", err)
|
|
}
|
|
defer pg.Stop()
|
|
|
|
pool, err := postgres.NewPool(ctx, "postgres://postgres:postgres@localhost:15449/postgres?sslmode=disable")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer pool.Close()
|
|
applyMigrations(t, ctx, pool)
|
|
|
|
const domainName = "rotate.example"
|
|
domainID := uuid.New()
|
|
if _, err := pool.Exec(ctx,
|
|
`INSERT INTO domains (id, name, punycode_name) VALUES ($1, $2, $3)`,
|
|
domainID, domainName, domainName); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
v, err := vault.New("a-master-key-for-rotation-tests")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repo := postgres.NewDkimRepository(pool, v)
|
|
|
|
generate := func(algorithm string) ([]byte, string, error) {
|
|
generated, err := dkim.Generate(algorithm)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
return generated.PrivateKeyPEM, generated.PublicKeyBase64, nil
|
|
}
|
|
|
|
// A key is in place before the rotation, as it would be in production.
|
|
firstPEM, firstPub, err := generate("rsa2048")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repo.Insert(ctx, domainName, "old", "rsa2048", firstPEM, firstPub); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
router := httppresentation.NewRouter(nil, func() error { return nil })
|
|
router.SetAdminDkimAPI(repo, generate, webmailSecret)
|
|
server := httptest.NewServer(router)
|
|
defer server.Close()
|
|
|
|
rotate := func(token, selector string) *http.Response {
|
|
body, _ := json.Marshal(map[string]string{
|
|
"domain": domainName, "selector": selector, "algorithm": "rsa2048",
|
|
})
|
|
req, _ := http.NewRequest(http.MethodPost, server.URL+"/api/v1/admin/dkim/rotate", bytes.NewReader(body))
|
|
req.Header.Set("Content-Type", "application/json")
|
|
if token != "" {
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return resp
|
|
}
|
|
|
|
// A user token must not reach an admin endpoint.
|
|
if resp := rotate(mintSession(t, "user@rotate.example", "user", time.Hour), "nope"); resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("a user token rotated a key: status %d", resp.StatusCode)
|
|
}
|
|
resp := rotate("", "nope")
|
|
if resp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("an anonymous caller rotated a key: status %d", resp.StatusCode)
|
|
}
|
|
|
|
adminToken := mintSession(t, "admin@rotate.example", "admin", time.Hour)
|
|
if resp := rotate(adminToken, "new"); resp.StatusCode != http.StatusOK {
|
|
t.Fatalf("rotate: status %d", resp.StatusCode)
|
|
}
|
|
|
|
// The point of the whole exercise: the signer can open the new key.
|
|
keys, err := repo.FindActiveKeys(ctx, domainName)
|
|
if err != nil {
|
|
t.Fatalf("the rotated key cannot be opened: %v", err)
|
|
}
|
|
if len(keys) != 1 || keys[0].Selector != "new" {
|
|
t.Fatalf("active keys = %+v, want exactly the new selector", keys)
|
|
}
|
|
if _, err := dkim.ParsePrivateKey(keys[0].PrivateKeyPEM); err != nil {
|
|
t.Fatalf("the rotated key is not a usable private key: %v", err)
|
|
}
|
|
|
|
// The old key is retired with an overlap, not deleted: mail already sent
|
|
// under it is still being verified.
|
|
var status string
|
|
var retireAfter *time.Time
|
|
if err := pool.QueryRow(ctx,
|
|
`SELECT status, retire_after FROM dkim_keys WHERE selector = 'old'`).Scan(&status, &retireAfter); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if status != "RETIRING" || retireAfter == nil || !retireAfter.After(time.Now()) {
|
|
t.Errorf("old key status=%q retire_after=%v, want RETIRING with a future overlap", status, retireAfter)
|
|
}
|
|
|
|
// A selector that would be invalid in DNS is refused.
|
|
if resp := rotate(adminToken, "bad selector!"); resp.StatusCode != http.StatusBadRequest {
|
|
t.Errorf("an invalid selector was accepted: status %d", resp.StatusCode)
|
|
}
|
|
}
|