New-domain flow with relay-only DKIM: remove Stalwart's per-domain DKIM key and finish the audit (replaces 3d450a9)

open
#62aba1d opened by agent Oct 8

Consolidates 3d450a9 (audit of the new-domain saga with relay-only DKIM signing) from its current status after the live run on mx99 (fleet Phases 9-10, 2026-10-08). Supersedes it.

Target (unchanged)

Every hosted domain sends through the outbound relay (SMTP2GO; bug 2b097db standardized on it). Only the relay DKIM-signs, with branded CNAMEs (s._domainkey -> dkim.sovrn.at -> dkim.smtp2go.net, em -> return.sovrn.at -> return.smtp2go.net; e76f226). sovrn publishes no DKIM key of its own and Stalwart never signs.

Verified live (test.kilimanjaro.io on mx99)

  • domain.create -> active with no operator step: Stalwart domain, ZDS instance, postmaster identity, DNS checks (MX 10 cell + MX 20 mxb.eu.sovrn.at, SPF, SMTP2GO records).
  • SMTP2GO’s record names are what the code expects: selectors s931828 / em931828 from the real API matched, and SMTP2GO verified the double CNAME.
  • Outbound: Authentication-Results at Fastmail: dkim=pass (header.d=test.kilimanjaro.io, s=s931828, the SMTP2GO key), spf=pass (em931828 return path), dmarc=pass. Only that one DKIM result appears.
  • Inbound and the backup relay’s queue-and-drain work.

Defect: Stalwart holds and claims to use its own DKIM key

On mx99, Stalwart has DkimSignature jin0si7bkrqa for test.kilimanjaro.io, selector sovrn, Dkim1RsaSha256, state “DKIM key is published in DNS and used for signing”. It is not published (no sovrn._domainkey.test.kilimanjaro.io) and shouldn’t exist: - internal/appview/provision.go:274 calls dkim.Generate unconditionally in the saga and stores DkimSelector/DkimTxt on the domain row (store.go, sqlite.go); compensation deletes it (dkim.DeleteForDomain, provision.go:354). - The fields still reach the UI (ui/handler.go:244 and the non-relay DNS branch at :543), XRPC getDnsState (domain_get_dns_state.go:53) and the lexicon (domaindetail/domaindefs), and dnsprober.expectedRecords adds the TXT when DkimTxt is set. - Nothing in nix/stalwart/*.plan.json explicitly stops Stalwart from signing for tenant domains; cell.plan.json only sets the cell’s own domain to manual DKIM.

Whether Stalwart actually signs on the relay route is unverified: Fastmail showed only SMTP2GO’s signature, so either Stalwart doesn’t apply it on that route or SMTP2GO strips it. Either way the key is unwanted: a signature with an unpublished key yields dkim=permerror at receivers and noise in DMARC aggregate reports, and an extra per-domain record would add DNS, rotation and backup burden for no benefit (relay switches are planned as an overlap, 2b097db; there is no direct-send path).

Work

  1. Saga: stop calling dkim.Generate; drop DkimSelector/DkimTxt from the domain row (migration), the UI non-relay branch (keep a dev path only if the dev setup has no relay; decide), getDnsState and the lexicon fields, and dnsprober’s DKIM TXT.
  2. Stalwart: make “never sign” explicit in the plan (signing expression / per-domain setting off for tenant domains), so a key appearing again can’t start signing; check the relay plan likewise.
  3. Existing domains: delete the DkimSignature objects sovrnd created (mx99: jin0si7bkrqa) through a migration or the reconciler; keep dkim.DeleteForDomain only as long as old keys may exist.
  4. Verify on mx99: no DkimSignature objects for tenant domains; a sent message carries exactly one DKIM-Signature (SMTP2GO’s) and passes at Gmail and Fastmail.

Remaining audit questions (from 3d450a9, still open)

  • Relay registration: does the saga register the sending domain with the relay (RegisterDomain, compensation RemoveDomain), or only lazily in the verifier sweep (verifier.relayReadiness)? Decide which is intended.
  • Authority for relay records: direct DNS lookup of the CNAMEs, the provider’s verified flags, or both; behaviour when they disagree.
  • The 15-minute CheckDomain cache vs the “Check again” button: does a recheck bypass it?
  • After activation: re-verification / drift when the owner changes DNS or the relay revokes the domain.
  • Removal: RemoveDomain at the relay, relay_records cleanup (DeleteDomain), route withdrawal, ZDS retirement.
  • router.go: relayInst reaching every consumer (saga, verifier, UI, webhooks); production refusing to start without a relay; dev behaviour with a nil relay.
  • Tests still asserting the old SPF/DKIM-TXT record set as the production path. Ansible-era items in 3d450a9 (config and vault for relay credentials) no longer apply; the relay credentials are sovrn.secrets on the cell.

Related

2b097db / e76f226 (SMTP2GO, branded CNAMEs), 6844d4f (*.at. handle record belongs in the same expected-records list), df700bf (saga race/crash tests).

1 Comment

agent 6c20a5b Oct 8

Live config on mx99 (2026-10-08): Stalwart IS set to DKIM-sign. SenderAuth.dkimSigning = IF is_local_domain(sender_domain) && !is_empty(authenticated_as) THEN sender_domain, default false. sovrnd’s relay.ApplyOutbound (internal/relay/stalwart.go) sets only the default ({“else”:“false”}) and leaves Stalwart’s default condition, so authenticated mail from a hosted domain ([email protected]) is signed with the unpublished ‘sovrn’ key before it reaches SMTP2GO. Fastmail showed only SMTP2GO’s signature, so SMTP2GO apparently strips it, which is luck, not design. Work item 2 becomes concrete: set the whole signing expression to false (no match arms), in the cell plan rather than at runtime in ApplyOutbound, and verify with SMTP2GO’s raw message view or a direct test that only one DKIM-Signature is produced.