[C7] domain ownership verification + readiness probes (internal/domain)
closedGoal
Implement the two-gate domain proof from ADR-0005 and docs/03-provisioning.md §4a inside internal/domain, surfacing structured data for the future app-view orchestrator UI. Extends the existing Stalwart lifecycle package (bug 24dcaa6) with DNS-facing functions: the ownership gate (TXT challenge + apex-handle check) and the readiness gate (live-vs-expected DNS comparison), plus retrieval of Stalwart’s expected records.
Context
Stalwart does NO ownership verification and NO DNS health checking (verified against checkout: Domain create only checks name-collision; the only DNS lookup is wait_for_txt_propagation after its own DnsServer-API publishing — bug 24dcaa6). The control plane must prove zone control before provisioning and verify MX/SPF/DKIM before accepting mail. Docs: docs/adr/0005-domain-ownership-verification.md, docs/03-provisioning.md §4/§4a.
Decisions locked (owner, 2026-08-26)
- Mechanism A (TXT challenge
_sovrn-challenge.<domain>=sovrn-verification=<per-claim nonce>) primary; Mechanism C (apex-handle piggyback) optimization. - Mechanism C: handle must equal registrable domain (PSL
EffectiveTLDPlusOne(handle)==handle), proof ONLY via_atproto.<handle>DNS TXT. - First non-stdlib dep:
golang.org/x/net/publicsuffix(accepted in ADR-0005). - DKIM-as-proof REJECTED; DKIM stays in the readiness gate.
File structure & API contract
go.mod += golang.org/x/net (publicsuffix)
internal/domain/
resolver.go type Resolver interface { LookupTXT(ctx,name)([]string,error); LookupMX(ctx,name)([]*net.MX,error) }
func NetResolver(r *net.Resolver) Resolver // nil-safe, defaults net.DefaultResolver
handle.go func IsRootHandle(handle string) bool // lowercase + trim trailing dot, publicsuffix.EffectiveTLDPlusOne(handle)==handle
challenge.go type Challenge struct{ Domain, DID, Token string } // Token: 256-bit CSPRNG base64url
func MintChallenge(dnsName, did string) (Challenge, error)
func (ch Challenge) TXTName() string // "_sovrn-challenge." + Domain
func VerifyOwnership(ctx, r Resolver, ch Challenge) error // constant-time compare of TXT set; ErrNotVerified sentinel
zonefile.go type Record struct{ Name, Type string; Values []string } // Type: A|AAAA|MX|TXT|CNAME|SRV|...
func DnsRecords(ctx, c *stalwart.Client, domainID string) ([]Record, error)
// Registry Domain/get w/ properties ["dnsZoneFile"], parse BIND zone output
readiness.go type Status int // Missing|Mismatch|Ok|Informational
type RecordStatus struct{ Record Record; Live []string; Status Status }
type Report struct{ Domain string; Records []RecordStatus; Ready bool }
func ProbeReadiness(ctx, r Resolver, recs []Record) Report
// MX: live set contains expected exchange(s); DKIM TXT: exact match; SPF/DMARC/others: informational presence
internal/domain stays the home; DNS primitives are file-local, no new package. Resolver is injected for testability — real-DNS probing is unit-tested via fake resolver fixtures only (integration stays env-gated against the C1 harness).
Tasks
Task 1: resolver.go + handle.go (TDD)
- [ ] Failing tests: IsRootHandle cases (
example.comtrue,mail.example.comfalse,example.co.uktrue,localhost/single-label false, trailing dot + uppercase normalization); Resolver adapter nil-safety. - [ ] Implement; add
golang.org/x/netto go.mod. - [ ] Green; commit
feat(domain): Resolver + IsRootHandle.
Task 2: challenge.go (TDD)
- [ ] Failing tests: MintChallenge token uniqueness/entropy (two mints differ, non-empty, base64url); TXTName shape; VerifyOwnership exact-match passes, wrong token fails (ErrNotVerified), multi-string TXT set matches, missing record fails.
- [ ] Implement (crypto/rand, constant-time compare via subtle).
- [ ] Green; commit
feat(domain): ownership TXT challenge mint + verify.
Task 3: zonefile.go (TDD)
- [ ] Golden tests: parse BIND zone output fixture (comments, $ORIGIN/other directives, MX priority, quoted TXT, multi-line parenthesized records) into []Record. Get wire-shape test: asserts Domain/get called with properties [“dnsZoneFile”].
- [ ] Implement parser (tolerant of Stalwart’s
build_bind_dns_recordsoutput; capture real output from harness during integration). - [ ] Green; commit
feat(domain): DnsRecords via dnsZoneFile.
Task 4: readiness.go (TDD)
- [ ] Failing tests: MX present/points-at-us vs mismatched vs missing; DKIM exact-match vs mismatch; SPF informational; Ready=false until DKIM+MX Ok; empty input handling.
- [ ] Implement.
- [ ] Green; commit
feat(domain): ProbeReadiness.
Task 5: Integration story extension
- [ ] Extend
internal/integration/story_test.go: afterdomain.Ensure, callDnsRecordsfor the provisioned domain and assert a non-empty record set containing a DKIM TXT entry (live schema-hash-pinned assertion). Real-DNS probes remain excluded (no deterministic public DNS in CI). - [ ] Green; commit
test(integration): dnsZoneFile retrieval for provisioned domain.
Task 6: Close-out
- [ ]
go vet ./... && golangci-lint run && go test ./...green. - [ ] Post findings comment (BIND format surprises, property-name casing, publicsuffix edge cases) and leave open for owner review.
Acceptance criteria
- All unit tests green with fake resolvers; integration asserts live
dnsZoneFileretrieval. VerifyOwnershipis constant-time and per-claim (no fixed token).IsRootHandlereturns true only for PSL registrable domains.- Existing lifecycle/idempotency story (bug
24dcaa6) unaffected.
Risks to surface
- BIND zone-file format variance across Stalwart versions → schema-hash CI job should also pin a zone-file golden.
- publicsuffix list semantics (unlisted TLDs fall back to wildcard rule) — document behavior for unknown TLDs.
_atproto.<handle>TXT vs HTTPS well-known: only TXT qualifies for C; the identity wrapper (buga589d34) must expose which resolution path matched, not just the resolved DID.
1 Comment
Implemented
DNS readiness verification + dev stub + background verifier/reaper, per the revised plan (challenge dropped; DKIM-publication is the ownership proof — see ADR-0005 amendment).
New code
internal/domain/resolver.go—Resolverinterface (LookupTXT/LookupMX) +NetResolver(*net.Resolver)(nil-safe).internal/domain/handle.go—IsRootHandle(PSLEffectiveTLDPlusOne), kept for mechanism C / subdomain fallback.internal/domain/zonefile.go—Record+DnsRecords(ctx, client, domainID)(RegistryDomain/getwithproperties:["dnsZoneFile"], tolerant BIND parser).internal/domain/readiness.go—ProbeReadiness: MX must contain expected exchange, DKIM TXT (name contains_domainkey) exact-match, SPF/DMARC informational.Ready= MX && DKIM.internal/dnsprober—Proberinterface (Verify(Target) state) +NetProber(real DNS) +DevProber(stub, always"active").internal/verifier— backgroundService:Run(ctx)sweeps everyverification.interval(default 5m);Sweepprobesverifyingdomains,domain.Enables + marksactiveon readiness, and reaps claims older thanverification.reapafter(default 48h) by destroying DKIM sigs + domain in Stalwart, then deleting the DB row. Runs on a background context (logout-independent).Modified
internal/domain/domain.go—EnsureInactive(isEnabled=false) +Enable(isEnabled=true).internal/dkim/dkim.go—DeleteForDomain.internal/store—UpdateDomainStatus,DeleteDomain,ListDomainsByStatus.internal/appview—dns.godropsProber/StubProber(nowdnsprober);domain_create.gocustom mode →EnsureInactive+ DKIM +prober.Verify(enable immediately if active);getDnsStateunchanged except challenge removal.config.go—Env(fromENV, default development) +Verification{Interval, ReapAfter};router.goselectsDevProbervsNetProberbyEnvand starts the verifier goroutine (stopped inShutdown).dnsChallenge+challengefields (defs/create/getDnsState) and regeneratedapi/sovrn.go.mod— addedgolang.org/x/net(publicsuffix).Tests
go vet ./...clean;SOVRN_INTEGRATION=0 go test ./...all green.EnsureInactive/Enablewire shape,DeleteForDomainpath via verifier, dnsprober stub/net, verifier sweep (activate / leave-verifying / reap / at-deadline), store status lifecycle.TestIntegrationAppViewCustomDomainDevGate(custom create → active via DevProber, Stalwart domain enabled, getDnsState returns MX/SPF/DKIM) — passes against the live harness.TestIntegrationAppPasswordfails in the harness because Stalwart listens on 993⁄465 but the test dials 1143⁄1587 — pre-existing harness port mismatch, unrelated to this work.Not done / follow-ups
_atprotoTXT → DID, buga589d34).docs/adr/0005-domain-ownership-verification.md; docs/03 §4a annotated.