[C7] domain ownership verification + readiness probes (internal/domain)

closed
#b5a31bd opened by agent Aug 26

Goal

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.com true, mail.example.com false, example.co.uk true, localhost/single-label false, trailing dot + uppercase normalization); Resolver adapter nil-safety.
  • [ ] Implement; add golang.org/x/net to 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_records output; 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: after domain.Ensure, call DnsRecords for 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 dnsZoneFile retrieval.
  • VerifyOwnership is constant-time and per-claim (no fixed token).
  • IsRootHandle returns 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 (bug a589d34) must expose which resolution path matched, not just the resolved DID.

1 Comment

BT b456a43 Sep 8

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 — Resolver interface (LookupTXT/LookupMX) + NetResolver(*net.Resolver) (nil-safe).
  • internal/domain/handle.go — IsRootHandle (PSL EffectiveTLDPlusOne), kept for mechanism C / subdomain fallback.
  • internal/domain/zonefile.go — Record + DnsRecords(ctx, client, domainID) (Registry Domain/get with properties:["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 — Prober interface (Verify(Target) state) + NetProber (real DNS) + DevProber (stub, always "active").
  • internal/verifier — background Service: Run(ctx) sweeps every verification.interval (default 5m); Sweep probes verifying domains, domain.Enables + marks active on readiness, and reaps claims older than verification.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.go drops Prober/StubProber (now dnsprober); domain_create.go custom mode → EnsureInactive + DKIM + prober.Verify (enable immediately if active); getDnsState unchanged except challenge removal.
  • config.go — Env (from ENV, default development) + Verification{Interval, ReapAfter}; router.go selects DevProber vs NetProber by Env and starts the verifier goroutine (stopped in Shutdown).
  • Lexicons — removed dnsChallenge + challenge fields (defs/create/getDnsState) and regenerated api/sovrn.
  • go.mod — added golang.org/x/net (publicsuffix).

Tests

  • go vet ./... clean; SOVRN_INTEGRATION=0 go test ./... all green.
  • New unit tests: domain primitives, EnsureInactive/Enable wire shape, DeleteForDomain path via verifier, dnsprober stub/net, verifier sweep (activate / leave-verifying / reap / at-deadline), store status lifecycle.
  • New integration test TestIntegrationAppViewCustomDomainDevGate (custom create → active via DevProber, Stalwart domain enabled, getDnsState returns MX/SPF/DKIM) — passes against the live harness.
  • Note: TestIntegrationAppPassword fails 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

  • Mechanism C (apex-handle piggyback) still deferred to the identity wrapper (_atproto TXT → DID, bug a589d34).
  • ADR-0005 amendment recorded in docs/adr/0005-domain-ownership-verification.md; docs/03 §4a annotated.