Files
LambdaMail/services/protocols/internal/presentation/http/admin_dkim_api.go
T
Lucas RayanandClaude Opus 5 30014f0f41 #21: fix the DKIM, onboarding and console defects found reviewing the new work
DKIM rotation broke the domain it was run on. It retired the live key and
inserted a placeholder as ACTIVE - private_key_enc was the literal string
"dummy-encrypted-key" - so FindActiveKeys then failed for the whole
domain and outbound mail stopped being signed, with the real key already
gone. Reproduced against a database before changing anything.

The root cause runs deeper: the auth service derives its encryption key
with a plain SHA-256 of the master key while the protocols service uses
HKDF with its own info string, so nothing the auth service seals can ever
be opened by the signer. Rotation therefore moves to the protocols
service, which owns that vault and the real key generator. It retires the
previous key with a seven-day overlap in the same transaction rather than
deleting it, because mail already sent under it is still being verified.

Domain onboarding had three defects in one function. It built the alias
destination by interpolating the domain name into the SQL text, which was
injectable; it reused one placeholder for a CITEXT and a VARCHAR column,
which made the statement fail and happened to hide the injection; and it
ignored its scope argument entirely, so any DOMAIN_ADMIN could create
domains. It also provisioned postmaster, abuse and security, missing the
dmarc and tlsrpt aliases that the published records point at - PLAN.md
section 7.4b is explicit that reports bounce without them.

The TLS panel returned fixed values: acme_status "VALID" and 72 days to
expiry, whatever the process actually held. It now reads the certificate
watcher, and a compile-time assertion keeps the wiring from silently
regressing into "disabled". The audit log and the readiness scorecard were
fixtures too, and their field names did not match the API they now read.

Two tests failed and a third asserted a fabricated constant: one depended
on test order through a shared fixture that had a second factor enrolled,
one used a fixed domain name and so passed once per database, and one
expected "records: 13" from a function that never touches DNS. All three
are fixed, and the suite now passes twice in a row against one database.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 13:33:33 -03:00

183 lines
5.7 KiB
Go

package httppresentation
import (
"encoding/json"
"net/http"
"strings"
"time"
"lambdamail/protocols/internal/infrastructure/postgres"
)
// dkimOverlap is how long the previous key stays resolvable after a rotation.
// Mail already in flight was signed with it and receivers may still be
// fetching the selector, so removing it at once would fail DKIM for messages
// that were valid when they were sent (PLAN.md section 5).
const dkimOverlap = 7 * 24 * time.Hour
// KeyGenerator produces a new DKIM key pair. It is injected so this package
// does not depend on the crypto implementation.
type KeyGenerator func(algorithm string) (privateKeyPEM []byte, publicKeyBase64 string, err error)
// adminDkimAPI serves DKIM key management.
//
// It lives in this service, not in the auth service, because the private key
// has to be sealed with the vault this process owns: the two services derive
// their keys differently, so a key written elsewhere could never be opened
// here - and a DKIM key that cannot be opened means unsigned outbound mail.
type adminDkimAPI struct {
repo *postgres.DkimRepository
generate KeyGenerator
sessions *WebSessionVerifier
}
// bearerOrCookie reads the session token from either transport.
func bearerOrCookie(r *http.Request, cookieName string) string {
if header := r.Header.Get("Authorization"); strings.HasPrefix(header, "Bearer ") {
return strings.TrimPrefix(header, "Bearer ")
}
if cookie, err := r.Cookie(cookieName); err == nil {
return cookie.Value
}
return ""
}
func (a *adminDkimAPI) authenticate(w http.ResponseWriter, r *http.Request) bool {
token := bearerOrCookie(r, "lm_admin_session")
if token == "" {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Admin session required")
return false
}
if _, err := a.sessions.RequireSurface(token, "admin"); err != nil {
writeError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Admin session required")
return false
}
return true
}
func (a *adminDkimAPI) handleKeys(w http.ResponseWriter, r *http.Request) {
if !a.authenticate(w, r) {
return
}
domain := r.URL.Query().Get("domain")
if domain == "" {
writeError(w, http.StatusBadRequest, "DOMAIN_REQUIRED", "A domain is required")
return
}
keys, err := a.repo.ListKeys(r.Context(), domain)
if err != nil {
writeError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "Could not list keys")
return
}
writeJSON(w, http.StatusOK, keys)
}
func (a *adminDkimAPI) handleRotate(w http.ResponseWriter, r *http.Request) {
if !a.authenticate(w, r) {
return
}
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "POST required")
return
}
var body struct {
Domain string `json:"domain"`
Selector string `json:"selector"`
Algorithm string `json:"algorithm"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "BAD_REQUEST", "Invalid JSON")
return
}
if body.Algorithm != "rsa2048" && body.Algorithm != "ed25519" {
writeError(w, http.StatusBadRequest, "INVALID_ALGORITHM", "Algorithm must be rsa2048 or ed25519")
return
}
if body.Domain == "" {
writeError(w, http.StatusBadRequest, "DOMAIN_REQUIRED", "A domain is required")
return
}
// A selector becomes part of a DNS name, so it is restricted to what is
// valid in one rather than trusted from the request.
if !isValidSelector(body.Selector) {
writeError(w, http.StatusBadRequest, "INVALID_SELECTOR",
"A selector may contain letters, digits and hyphens, up to 63 characters")
return
}
privateKeyPEM, publicKey, err := a.generate(body.Algorithm)
if err != nil {
writeError(w, http.StatusInternalServerError, "KEYGEN_FAILED", "Could not generate a key")
return
}
if err := a.repo.Rotate(r.Context(), body.Domain, body.Selector, body.Algorithm,
privateKeyPEM, dkimPublicRecord(body.Algorithm, publicKey), dkimOverlap); err != nil {
writeError(w, http.StatusInternalServerError, "ROTATE_FAILED", "Could not rotate the key")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"domain": body.Domain,
"selector": body.Selector,
"algorithm": body.Algorithm,
"public_key_record": dkimPublicRecord(body.Algorithm, publicKey),
"overlap_days": int(dkimOverlap.Hours() / 24),
})
}
// dkimPublicRecord renders the TXT value a resolver expects.
func dkimPublicRecord(algorithm, publicKeyBase64 string) string {
keyType := "rsa"
if algorithm == "ed25519" {
keyType = "ed25519"
}
return "v=DKIM1; k=" + keyType + "; p=" + publicKeyBase64
}
func isValidSelector(selector string) bool {
if selector == "" || len(selector) > 63 {
return false
}
for _, r := range selector {
switch {
case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z', r >= '0' && r <= '9', r == '-':
default:
return false
}
}
return true
}
// SetAdminDkimAPI wires DKIM management. Without a session secret the routes
// answer 503 rather than opening unauthenticated.
func (r *Router) SetAdminDkimAPI(repo *postgres.DkimRepository, generate KeyGenerator, sessionSecret string) {
if repo == nil || generate == nil || sessionSecret == "" {
return
}
r.dkim = &adminDkimAPI{repo: repo, generate: generate, sessions: NewWebSessionVerifier(sessionSecret)}
}
func (r *Router) handleAdminDkimKeys(w http.ResponseWriter, req *http.Request) {
if r.dkim == nil {
writeError(w, http.StatusServiceUnavailable, "DKIM_API_DISABLED",
"DKIM management needs JWT_SECRET and a master key")
return
}
r.dkim.handleKeys(w, req)
}
func (r *Router) handleAdminDkimRotate(w http.ResponseWriter, req *http.Request) {
if r.dkim == nil {
writeError(w, http.StatusServiceUnavailable, "DKIM_API_DISABLED",
"DKIM management needs JWT_SECRET and a master key")
return
}
r.dkim.handleRotate(w, req)
}