···11+when:
22+ - event: ["push", "pull_request"]
33+ branch: main
44+55+engine: nixery
66+77+dependencies:
88+ nixpkgs:
99+ - go
1010+1111+environment:
1212+ CGO_ENABLED: 0
1313+1414+steps:
1515+ - name: build
1616+ command: go build ./...
1717+1818+ - name: vet
1919+ command: go vet ./...
2020+2121+ - name: test
2222+ command: go test -count=1 ./...
+21
Dockerfile
···11+ARG GO_VERSION=1
22+FROM golang:${GO_VERSION}-bookworm AS builder
33+44+WORKDIR /usr/src/app
55+COPY go.mod go.sum ./
66+RUN go mod download && go mod verify
77+COPY . .
88+RUN go build -v -o /labeler ./cmd/labeler
99+1010+FROM debian:bookworm-slim
1111+1212+RUN apt-get update && \
1313+ apt-get install -y ca-certificates && \
1414+ rm -rf /var/lib/apt/lists/*
1515+1616+COPY --from=builder /labeler /labeler
1717+1818+VOLUME /app/state
1919+EXPOSE 8081
2020+2121+CMD ["/labeler", "-config=/app/state/config.json"]
+21
LICENSE
···11+MIT License
22+33+Copyright (c) 2026 Scott Lanoue
44+55+Permission is hereby granted, free of charge, to any person obtaining a copy
66+of this software and associated documentation files (the "Software"), to deal
77+in the Software without restriction, including without limitation the rights
88+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
99+copies of the Software, and to permit persons to whom the Software is
1010+furnished to do so, subject to the following conditions:
1111+1212+The above copyright notice and this permission notice shall be included in all
1313+copies or substantial portions of the Software.
1414+1515+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
1616+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
1717+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
1818+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
1919+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
2020+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
2121+SOFTWARE.
+102
README.md
···11+# Atmosphere Mail
22+33+A cooperative email reputation layer for atproto. PDS operators pool sending volume and reputation through shared infrastructure, verified by a labeling service that checks DNS configuration and publishes signed attestations to the atproto network.
44+55+Starting with transactional email (verification codes, password resets, notifications) to build collective domain reputation, with the long-term goal of making self-hosted personal email viable again.
66+77+See [atmosphere-mail-vision.md](atmosphere-mail-vision.md) for the full proposal.
88+99+## Status
1010+1111+Phase 0: labeler service (local development, not yet deployed).
1212+1313+## How it works
1414+1515+1. A PDS operator publishes an `email.atmos.attestation` record declaring their mail domain and DKIM selectors
1616+2. The labeler watches the atproto firehose (via Jetstream) for these records
1717+3. For each attestation, it verifies:
1818+ - **Domain control** — the operator's DID handle matches the domain, or a `_atproto.<domain>` TXT record points to the DID
1919+ - **MX** — at least one MX record exists
2020+ - **SPF** — a `v=spf1` record exists without `+all`
2121+ - **DKIM** — every declared selector has a `v=DKIM1` record
2222+ - **DMARC** — a record exists at `_dmarc.<domain>` with policy quarantine or reject
2323+4. If all checks pass, the labeler signs and publishes `verified-mail-operator` (and optionally `relay-member`) labels on the operator's DID
2424+5. Labels are queryable via standard atproto XRPC endpoints (`com.atproto.label.queryLabels`, `com.atproto.label.subscribeLabels`)
2525+6. A scheduler re-verifies every 24 hours and negates labels if DNS degrades
2626+2727+## Lexicons
2828+2929+The NSID namespace is `email.atmos.*`, backed by `atmos.email`. See [lexicons/README.md](lexicons/README.md) for schemas and open questions.
3030+3131+## Building
3232+3333+```
3434+go build ./cmd/labeler
3535+```
3636+3737+## Running
3838+3939+Initialize state directory and signing key:
4040+4141+```
4242+./labeler -init
4343+```
4444+4545+This creates `./state/config.json` (if missing) and generates a secp256k1 signing key. The labeler's `did:key` is printed to stdout.
4646+4747+Start the labeler:
4848+4949+```
5050+./labeler -config ./state/config.json
5151+```
5252+5353+## Configuration
5454+5555+Copy `config.json.example` to `./state/config.json`. All fields have defaults:
5656+5757+| Field | Default | Description |
5858+|-------|---------|-------------|
5959+| `listenAddr` | `:8081` | XRPC server bind address |
6060+| `stateDir` | `./state` | SQLite database and key storage |
6161+| `jetstreamURL` | `wss://jetstream1.us-east.bsky.network/subscribe` | Jetstream endpoint |
6262+| `signingKeyPath` | `./state/signing.key` | secp256k1 private key (hex) |
6363+| `reverifyInterval` | `24h` | Re-verification frequency |
6464+6565+Config files support comments and trailing commas (hujson).
6666+6767+## Testing
6868+6969+```
7070+go test ./...
7171+```
7272+7373+## Docker
7474+7575+```
7676+docker build -t atmosphere-mail-labeler .
7777+docker run -v ./state:/app/state -p 8081:8081 atmosphere-mail-labeler
7878+```
7979+8080+## Architecture
8181+8282+```
8383+cmd/labeler/ Entry point, wiring, graceful shutdown
8484+internal/
8585+ config/ hujson config loading
8686+ store/ SQLite persistence (labels, attestations, cursor)
8787+ label/signer CBOR + secp256k1 label signing, did:key derivation
8888+ label/manager Verification orchestration, label create/negate
8989+ dns/ MX, SPF, DKIM, DMARC verification (injectable resolver)
9090+ domain/ DID-to-domain control verification
9191+ server/ queryLabels (HTTP) + subscribeLabels (WebSocket)
9292+ jetstream/ Firehose consumer with collection filtering
9393+ scheduler/ Periodic re-verification
9494+```
9595+9696+## License
9797+9898+MIT
9999+100100+## Author
101101+102102+Scott Lanoue ([@scottlanoue.com](https://bsky.app/profile/scottlanoue.com))
···11+package main
22+33+import (
44+ "context"
55+ "encoding/json"
66+ "net/http"
77+ "net/http/httptest"
88+ "strings"
99+ "testing"
1010+ "time"
1111+1212+ "atmosphere-mail/internal/dns"
1313+ "atmosphere-mail/internal/jetstream"
1414+ "atmosphere-mail/internal/label"
1515+ "atmosphere-mail/internal/scheduler"
1616+ "atmosphere-mail/internal/server"
1717+ "atmosphere-mail/internal/store"
1818+1919+ "github.com/gorilla/websocket"
2020+)
2121+2222+// Integration test: full pipeline from attestation event to queryable label.
2323+func TestEndToEnd(t *testing.T) {
2424+ ctx := context.Background()
2525+2626+ // Set up store (use temp file, not :memory:, because database/sql
2727+ // connection pooling gives each connection its own :memory: DB)
2828+ dbPath := t.TempDir() + "/test.sqlite"
2929+ st, err := store.New(dbPath)
3030+ if err != nil {
3131+ t.Fatal(err)
3232+ }
3333+ defer st.Close()
3434+3535+ // Set up signer
3636+ keyPath := t.TempDir() + "/signing.key"
3737+ if err := label.GenerateKey(keyPath); err != nil {
3838+ t.Fatal(err)
3939+ }
4040+ signer, err := label.NewSigner(keyPath)
4141+ if err != nil {
4242+ t.Fatal(err)
4343+ }
4444+4545+ // Mock DNS: all checks pass
4646+ dnsVerifier := &passDNS{}
4747+ domainVerifier := &passDomain{method: "handle"}
4848+4949+ mgr := label.NewManager(signer, st, dnsVerifier, domainVerifier)
5050+5151+ // -- Simulate Jetstream attestation create --
5252+5353+ att := jetstream.ReceivedAttestation{
5454+ DID: "did:plc:oper2345oper2345oper2345",
5555+ RKey: "operator1.com",
5656+ Domain: "operator1.com",
5757+ DKIMSelectors: []string{"default", "mail"},
5858+ RelayMember: true,
5959+ CreatedAt: "2026-03-31T00:00:00Z",
6060+ Operation: "create",
6161+ TimeUS: 1000,
6262+ }
6363+6464+ if err := handleAttestation(ctx, st, mgr, att); err != nil {
6565+ t.Fatalf("handleAttestation: %v", err)
6666+ }
6767+6868+ // Verify attestation stored
6969+ stored, err := st.GetAttestation(ctx, "did:plc:oper2345oper2345oper2345", "operator1.com")
7070+ if err != nil {
7171+ t.Fatal(err)
7272+ }
7373+ if stored == nil {
7474+ t.Fatal("attestation not stored")
7575+ }
7676+ if !stored.Verified {
7777+ t.Error("attestation should be verified")
7878+ }
7979+8080+ // Verify labels created
8181+ labels, err := st.GetActiveLabelsForDID(ctx, "did:plc:oper2345oper2345oper2345")
8282+ if err != nil {
8383+ t.Fatal(err)
8484+ }
8585+ if len(labels) != 2 {
8686+ t.Fatalf("got %d active labels, want 2", len(labels))
8787+ }
8888+8989+ vals := map[string]bool{}
9090+ for _, l := range labels {
9191+ vals[l.Val] = true
9292+ if l.Src != signer.DID() {
9393+ t.Errorf("label src = %q, want %q", l.Src, signer.DID())
9494+ }
9595+ }
9696+ if !vals["verified-mail-operator"] {
9797+ t.Error("missing verified-mail-operator label")
9898+ }
9999+ if !vals["relay-member"] {
100100+ t.Error("missing relay-member label")
101101+ }
102102+103103+ // -- Query via XRPC server --
104104+105105+ srv := server.New(st, signer.DID())
106106+ ts := httptest.NewServer(srv.Handler())
107107+ defer ts.Close()
108108+109109+ resp, err := http.Get(ts.URL + "/xrpc/com.atproto.label.queryLabels?uriPatterns=did:plc:oper2345oper2345oper2345")
110110+ if err != nil {
111111+ t.Fatal(err)
112112+ }
113113+ defer resp.Body.Close()
114114+115115+ var qr struct {
116116+ Labels []struct {
117117+ Src string `json:"src"`
118118+ URI string `json:"uri"`
119119+ Val string `json:"val"`
120120+ Ver int64 `json:"ver"`
121121+ } `json:"labels"`
122122+ }
123123+ if err := json.NewDecoder(resp.Body).Decode(&qr); err != nil {
124124+ t.Fatal(err)
125125+ }
126126+ if len(qr.Labels) != 2 {
127127+ t.Fatalf("queryLabels returned %d, want 2", len(qr.Labels))
128128+ }
129129+130130+ // -- Subscribe via WebSocket --
131131+132132+ wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/xrpc/com.atproto.label.subscribeLabels?cursor=0"
133133+ conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil)
134134+ if err != nil {
135135+ t.Fatal(err)
136136+ }
137137+ defer conn.Close()
138138+139139+ for i := 0; i < 2; i++ {
140140+ conn.SetReadDeadline(time.Now().Add(2 * time.Second))
141141+ _, _, err := conn.ReadMessage()
142142+ if err != nil {
143143+ t.Fatalf("subscribe read %d: %v", i, err)
144144+ }
145145+ }
146146+147147+ // -- Simulate attestation delete --
148148+149149+ delAtt := jetstream.ReceivedAttestation{
150150+ DID: "did:plc:oper2345oper2345oper2345",
151151+ RKey: "operator1.com",
152152+ Operation: "delete",
153153+ TimeUS: 2000,
154154+ }
155155+ if err := handleAttestation(ctx, st, mgr, delAtt); err != nil {
156156+ t.Fatal(err)
157157+ }
158158+159159+ // Labels should be negated
160160+ labels, err = st.GetActiveLabelsForDID(ctx, "did:plc:oper2345oper2345oper2345")
161161+ if err != nil {
162162+ t.Fatal(err)
163163+ }
164164+ if len(labels) != 0 {
165165+ t.Errorf("got %d active labels after delete, want 0", len(labels))
166166+ }
167167+168168+ // Attestation should be deleted
169169+ stored, err = st.GetAttestation(ctx, "did:plc:oper2345oper2345oper2345", "operator1.com")
170170+ if err != nil {
171171+ t.Fatal(err)
172172+ }
173173+ if stored != nil {
174174+ t.Error("attestation should be deleted")
175175+ }
176176+177177+ // -- Simulate attestation update (add relay membership) --
178178+179179+ updateAtt := jetstream.ReceivedAttestation{
180180+ DID: "did:plc:oper2345oper2345oper2345",
181181+ RKey: "operator1.com",
182182+ Domain: "operator1.com",
183183+ DKIMSelectors: []string{"default", "mail"},
184184+ RelayMember: true,
185185+ CreatedAt: "2026-03-31T00:00:00Z",
186186+ Operation: "update",
187187+ TimeUS: 1500,
188188+ }
189189+ if err := handleAttestation(ctx, st, mgr, updateAtt); err != nil {
190190+ t.Fatalf("handleAttestation (update): %v", err)
191191+ }
192192+193193+ // Verify attestation updated (relay_member should now be true)
194194+ stored, err = st.GetAttestation(ctx, "did:plc:oper2345oper2345oper2345", "operator1.com")
195195+ if err != nil {
196196+ t.Fatal(err)
197197+ }
198198+ if stored == nil {
199199+ t.Fatal("attestation not stored after update")
200200+ }
201201+ if !stored.RelayMember {
202202+ t.Error("attestation should have relayMember=true after update")
203203+ }
204204+205205+ // Should now have 2 labels (verified-mail-operator + relay-member)
206206+ labels, err = st.GetActiveLabelsForDID(ctx, "did:plc:oper2345oper2345oper2345")
207207+ if err != nil {
208208+ t.Fatal(err)
209209+ }
210210+ if len(labels) != 2 {
211211+ t.Fatalf("got %d active labels after update, want 2", len(labels))
212212+ }
213213+214214+ // -- Test re-verification scheduler --
215215+216216+ att2 := jetstream.ReceivedAttestation{
217217+ DID: "did:plc:aaaa5555bbbb6666cccc7777",
218218+ RKey: "operator2.com",
219219+ Domain: "operator2.com",
220220+ DKIMSelectors: []string{"sel1"},
221221+ Operation: "create",
222222+ TimeUS: 3000,
223223+ }
224224+ if err := handleAttestation(ctx, st, mgr, att2); err != nil {
225225+ t.Fatal(err)
226226+ }
227227+228228+ sched := scheduler.New(mgr, st, time.Hour)
229229+ if err := sched.RunOnce(ctx); err != nil {
230230+ t.Fatal(err)
231231+ }
232232+233233+ labels, err = st.GetActiveLabelsForDID(ctx, "did:plc:aaaa5555bbbb6666cccc7777")
234234+ if err != nil {
235235+ t.Fatal(err)
236236+ }
237237+ if len(labels) != 1 {
238238+ t.Errorf("got %d labels for operator2 after reverify, want 1", len(labels))
239239+ }
240240+}
241241+242242+func TestPLCHandleResolver(t *testing.T) {
243243+ // Mock PLC directory server
244244+ plcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
245245+ switch r.URL.Path {
246246+ case "/did:plc:found":
247247+ json.NewEncoder(w).Encode(map[string]any{
248248+ "alsoKnownAs": []string{"at://alice.example.com"},
249249+ })
250250+ case "/did:plc:nohandle":
251251+ json.NewEncoder(w).Encode(map[string]any{
252252+ "alsoKnownAs": []string{"https://not-at-proto.example.com"},
253253+ })
254254+ case "/did:plc:empty":
255255+ json.NewEncoder(w).Encode(map[string]any{})
256256+ default:
257257+ http.NotFound(w, r)
258258+ }
259259+ }))
260260+ defer plcServer.Close()
261261+262262+ // Override the PLC URL by creating a resolver with a custom client
263263+ // that rewrites the host
264264+ resolver := &plcHandleResolver{client: plcServer.Client()}
265265+266266+ ctx := context.Background()
267267+268268+ // Test: found handle
269269+ // We need to call the actual resolver but point it at our mock.
270270+ // Since plcHandleResolver hardcodes plc.directory, we'll test the
271271+ // response parsing logic directly with a transport override.
272272+ transport := &rewriteTransport{base: plcServer.Client().Transport, target: plcServer.URL}
273273+ resolver.client = &http.Client{Transport: transport, Timeout: 5 * time.Second}
274274+275275+ handle, err := resolver.ResolveHandle(ctx, "did:plc:found")
276276+ if err != nil {
277277+ t.Fatal(err)
278278+ }
279279+ if handle != "alice.example.com" {
280280+ t.Errorf("handle = %q, want alice.example.com", handle)
281281+ }
282282+283283+ // Test: no at:// handle
284284+ handle, err = resolver.ResolveHandle(ctx, "did:plc:nohandle")
285285+ if err != nil {
286286+ t.Fatal(err)
287287+ }
288288+ if handle != "" {
289289+ t.Errorf("handle = %q, want empty (no at:// entry)", handle)
290290+ }
291291+292292+ // Test: empty alsoKnownAs
293293+ handle, err = resolver.ResolveHandle(ctx, "did:plc:empty")
294294+ if err != nil {
295295+ t.Fatal(err)
296296+ }
297297+ if handle != "" {
298298+ t.Errorf("handle = %q, want empty", handle)
299299+ }
300300+301301+ // Test: 404 returns error
302302+ _, err = resolver.ResolveHandle(ctx, "did:plc:unknown")
303303+ if err == nil {
304304+ t.Error("expected error for 404 DID")
305305+ }
306306+}
307307+308308+// rewriteTransport redirects HTTPS requests to our local test server.
309309+type rewriteTransport struct {
310310+ base http.RoundTripper
311311+ target string
312312+}
313313+314314+func (t *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) {
315315+ req.URL.Scheme = "http"
316316+ req.URL.Host = strings.TrimPrefix(t.target, "http://")
317317+ if t.base != nil {
318318+ return t.base.RoundTrip(req)
319319+ }
320320+ return http.DefaultTransport.RoundTrip(req)
321321+}
322322+323323+// -- Test mocks --
324324+325325+type passDNS struct{}
326326+327327+func (p *passDNS) Verify(ctx context.Context, domain string, selectors []string) dns.Result {
328328+ return dns.Result{MX: true, SPF: true, DKIM: true, DMARC: true}
329329+}
330330+331331+type passDomain struct{ method string }
332332+333333+func (p *passDomain) Verify(ctx context.Context, did, domain string) (bool, string, error) {
334334+ return true, p.method, nil
335335+}
+22
config.json.example
···11+{
22+ // Atmosphere Mail labeler configuration.
33+ // Copy this to ./state/config.json and edit as needed.
44+55+ // Address for the XRPC server (queryLabels + subscribeLabels).
66+ "listenAddr": ":8081",
77+88+ // Directory for SQLite database and signing key.
99+ "stateDir": "./state",
1010+1111+ // Jetstream WebSocket URL. Public Bluesky instances:
1212+ // wss://jetstream1.us-east.bsky.network/subscribe
1313+ // wss://jetstream2.us-east.bsky.network/subscribe
1414+ "jetstreamURL": "wss://jetstream1.us-east.bsky.network/subscribe",
1515+1616+ // Path to the secp256k1 signing key (hex-encoded).
1717+ // Generate with: go run ./cmd/labeler -init
1818+ "signingKeyPath": "./state/signing.key",
1919+2020+ // How often to re-verify all attestations.
2121+ "reverifyInterval": "24h",
2222+}
···11+package dns
22+33+import (
44+ "context"
55+ "net"
66+ "strings"
77+)
88+99+// Resolver abstracts DNS lookups for testing.
1010+type Resolver interface {
1111+ LookupMX(ctx context.Context, name string) ([]*net.MX, error)
1212+ LookupTXT(ctx context.Context, name string) ([]string, error)
1313+}
1414+1515+// Result holds the outcome of DNS verification for a mail domain.
1616+type Result struct {
1717+ MX bool
1818+ SPF bool
1919+ DKIM bool
2020+ DMARC bool
2121+ Failures []string
2222+}
2323+2424+// Pass returns true if all checks passed.
2525+func (r *Result) Pass() bool {
2626+ return r.MX && r.SPF && r.DKIM && r.DMARC
2727+}
2828+2929+// Verifier checks MX, SPF, DKIM, and DMARC for a domain.
3030+type Verifier struct {
3131+ resolver Resolver
3232+}
3333+3434+// NewVerifier creates a Verifier with the given DNS resolver.
3535+// Pass nil to use the system default resolver.
3636+func NewVerifier(r Resolver) *Verifier {
3737+ if r == nil {
3838+ r = net.DefaultResolver
3939+ }
4040+ return &Verifier{resolver: r}
4141+}
4242+4343+// Verify runs all DNS checks for the given domain and DKIM selectors.
4444+func (v *Verifier) Verify(ctx context.Context, domain string, dkimSelectors []string) Result {
4545+ var r Result
4646+4747+ r.MX = v.checkMX(ctx, domain, &r)
4848+ r.SPF = v.checkSPF(ctx, domain, &r)
4949+ r.DKIM = v.checkDKIM(ctx, domain, dkimSelectors, &r)
5050+ r.DMARC = v.checkDMARC(ctx, domain, &r)
5151+5252+ return r
5353+}
5454+5555+// checkMX verifies at least one MX record exists.
5656+func (v *Verifier) checkMX(ctx context.Context, domain string, r *Result) bool {
5757+ mx, err := v.resolver.LookupMX(ctx, domain)
5858+ if err != nil || len(mx) == 0 {
5959+ r.Failures = append(r.Failures, "no MX records found")
6060+ return false
6161+ }
6262+ return true
6363+}
6464+6565+// checkSPF verifies a v=spf1 TXT record exists and does not contain +all.
6666+func (v *Verifier) checkSPF(ctx context.Context, domain string, r *Result) bool {
6767+ records, err := v.resolver.LookupTXT(ctx, domain)
6868+ if err != nil {
6969+ r.Failures = append(r.Failures, "SPF: no TXT records found")
7070+ return false
7171+ }
7272+7373+ for _, txt := range records {
7474+ if !strings.HasPrefix(strings.TrimSpace(txt), "v=spf1") {
7575+ continue
7676+ }
7777+ // Check for exact "+all" mechanism (space-separated tokens)
7878+ for _, mech := range strings.Fields(txt) {
7979+ if mech == "+all" {
8080+ r.Failures = append(r.Failures, "SPF: contains +all (allows any sender)")
8181+ return false
8282+ }
8383+ }
8484+ return true
8585+ }
8686+8787+ r.Failures = append(r.Failures, "SPF: no v=spf1 record found")
8888+ return false
8989+}
9090+9191+// checkDKIM verifies every declared selector has a v=DKIM1 TXT record.
9292+func (v *Verifier) checkDKIM(ctx context.Context, domain string, selectors []string, r *Result) bool {
9393+ if len(selectors) == 0 {
9494+ r.Failures = append(r.Failures, "DKIM: no selectors declared")
9595+ return false
9696+ }
9797+ allOK := true
9898+ for _, sel := range selectors {
9999+ name := sel + "._domainkey." + domain
100100+ records, err := v.resolver.LookupTXT(ctx, name)
101101+ if err != nil {
102102+ r.Failures = append(r.Failures, "DKIM: no record for selector "+sel)
103103+ allOK = false
104104+ continue
105105+ }
106106+ found := false
107107+ for _, txt := range records {
108108+ if strings.Contains(txt, "v=DKIM1") {
109109+ found = true
110110+ break
111111+ }
112112+ }
113113+ if !found {
114114+ r.Failures = append(r.Failures, "DKIM: selector "+sel+" missing v=DKIM1")
115115+ allOK = false
116116+ }
117117+ }
118118+ return allOK
119119+}
120120+121121+// checkDMARC verifies a DMARC record exists at _dmarc.<domain> with policy != none.
122122+func (v *Verifier) checkDMARC(ctx context.Context, domain string, r *Result) bool {
123123+ name := "_dmarc." + domain
124124+ records, err := v.resolver.LookupTXT(ctx, name)
125125+ if err != nil {
126126+ r.Failures = append(r.Failures, "DMARC: no record at "+name)
127127+ return false
128128+ }
129129+130130+ for _, txt := range records {
131131+ if !strings.Contains(txt, "v=DMARC1") {
132132+ continue
133133+ }
134134+ policy := parseDMARCPolicy(txt)
135135+ if policy == "none" || policy == "" {
136136+ r.Failures = append(r.Failures, "DMARC: policy is "+policy+" (must be quarantine or reject)")
137137+ return false
138138+ }
139139+ return true
140140+ }
141141+142142+ r.Failures = append(r.Failures, "DMARC: no v=DMARC1 record found")
143143+ return false
144144+}
145145+146146+// parseDMARCPolicy extracts the p= value from a DMARC TXT record.
147147+func parseDMARCPolicy(txt string) string {
148148+ for _, part := range strings.Split(txt, ";") {
149149+ part = strings.TrimSpace(part)
150150+ if strings.HasPrefix(part, "p=") {
151151+ return strings.TrimSpace(strings.TrimPrefix(part, "p="))
152152+ }
153153+ }
154154+ return ""
155155+}
+158
internal/dns/verifier_test.go
···11+package dns
22+33+import (
44+ "context"
55+ "net"
66+ "testing"
77+)
88+99+// mockResolver implements Resolver with canned responses.
1010+type mockResolver struct {
1111+ mx []*net.MX
1212+ txt map[string][]string
1313+}
1414+1515+func (m *mockResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) {
1616+ if m.mx == nil {
1717+ return nil, &net.DNSError{Err: "no MX", Name: name, IsNotFound: true}
1818+ }
1919+ return m.mx, nil
2020+}
2121+2222+func (m *mockResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
2323+ if records, ok := m.txt[name]; ok {
2424+ return records, nil
2525+ }
2626+ return nil, &net.DNSError{Err: "no TXT", Name: name, IsNotFound: true}
2727+}
2828+2929+func goodResolver(domain string, selectors []string) *mockResolver {
3030+ txt := map[string][]string{
3131+ domain: {"v=spf1 include:_spf.google.com ~all"},
3232+ "_dmarc." + domain: {"v=DMARC1; p=reject; rua=mailto:dmarc@" + domain},
3333+ }
3434+ for _, sel := range selectors {
3535+ txt[sel+"._domainkey."+domain] = []string{"v=DKIM1; k=rsa; p=MIIBIjANBg..."}
3636+ }
3737+3838+ return &mockResolver{
3939+ mx: []*net.MX{{Host: "mail." + domain, Pref: 10}},
4040+ txt: txt,
4141+ }
4242+}
4343+4444+func TestVerifyAllPass(t *testing.T) {
4545+ r := goodResolver("example.com", []string{"sel1", "sel2"})
4646+ v := NewVerifier(r)
4747+4848+ result := v.Verify(context.Background(), "example.com", []string{"sel1", "sel2"})
4949+ if !result.Pass() {
5050+ t.Errorf("expected all pass, got: MX=%v SPF=%v DKIM=%v DMARC=%v",
5151+ result.MX, result.SPF, result.DKIM, result.DMARC)
5252+ for _, f := range result.Failures {
5353+ t.Errorf(" failure: %s", f)
5454+ }
5555+ }
5656+}
5757+5858+func TestVerifyNoMX(t *testing.T) {
5959+ r := goodResolver("example.com", []string{"sel1"})
6060+ r.mx = nil
6161+ v := NewVerifier(r)
6262+6363+ result := v.Verify(context.Background(), "example.com", []string{"sel1"})
6464+ if result.MX {
6565+ t.Error("MX should fail with no records")
6666+ }
6767+ if result.Pass() {
6868+ t.Error("should not pass with MX failure")
6969+ }
7070+}
7171+7272+func TestVerifyNoSPF(t *testing.T) {
7373+ r := goodResolver("example.com", []string{"sel1"})
7474+ delete(r.txt, "example.com")
7575+ v := NewVerifier(r)
7676+7777+ result := v.Verify(context.Background(), "example.com", []string{"sel1"})
7878+ if result.SPF {
7979+ t.Error("SPF should fail with no TXT record")
8080+ }
8181+}
8282+8383+func TestVerifySPFPlusAll(t *testing.T) {
8484+ r := goodResolver("example.com", []string{"sel1"})
8585+ r.txt["example.com"] = []string{"v=spf1 +all"}
8686+ v := NewVerifier(r)
8787+8888+ result := v.Verify(context.Background(), "example.com", []string{"sel1"})
8989+ if result.SPF {
9090+ t.Error("SPF should fail with +all")
9191+ }
9292+}
9393+9494+func TestVerifyMissingDKIM(t *testing.T) {
9595+ r := goodResolver("example.com", []string{"sel1"})
9696+ // sel2 is declared but not in DNS
9797+ v := NewVerifier(r)
9898+9999+ result := v.Verify(context.Background(), "example.com", []string{"sel1", "sel2"})
100100+ if result.DKIM {
101101+ t.Error("DKIM should fail when a selector is missing")
102102+ }
103103+}
104104+105105+func TestVerifyDMARCNone(t *testing.T) {
106106+ r := goodResolver("example.com", []string{"sel1"})
107107+ r.txt["_dmarc.example.com"] = []string{"v=DMARC1; p=none"}
108108+ v := NewVerifier(r)
109109+110110+ result := v.Verify(context.Background(), "example.com", []string{"sel1"})
111111+ if result.DMARC {
112112+ t.Error("DMARC should fail with p=none")
113113+ }
114114+}
115115+116116+func TestVerifyDMARCQuarantine(t *testing.T) {
117117+ r := goodResolver("example.com", []string{"sel1"})
118118+ r.txt["_dmarc.example.com"] = []string{"v=DMARC1; p=quarantine"}
119119+ v := NewVerifier(r)
120120+121121+ result := v.Verify(context.Background(), "example.com", []string{"sel1"})
122122+ if !result.DMARC {
123123+ t.Error("DMARC should pass with p=quarantine")
124124+ }
125125+}
126126+127127+func TestVerifySPFPlusAllSubstring(t *testing.T) {
128128+ // "+all" appearing as a substring (e.g. in a domain) should NOT trigger rejection
129129+ r := goodResolver("example.com", []string{"sel1"})
130130+ r.txt["example.com"] = []string{"v=spf1 include:+allmail.example.com ~all"}
131131+ v := NewVerifier(r)
132132+133133+ result := v.Verify(context.Background(), "example.com", []string{"sel1"})
134134+ if !result.SPF {
135135+ t.Error("SPF should pass — +all is a substring, not a standalone mechanism")
136136+ }
137137+}
138138+139139+func TestVerifyDKIMEmptySelectors(t *testing.T) {
140140+ r := goodResolver("example.com", []string{})
141141+ v := NewVerifier(r)
142142+143143+ result := v.Verify(context.Background(), "example.com", []string{})
144144+ if result.DKIM {
145145+ t.Error("DKIM should fail with empty selectors")
146146+ }
147147+}
148148+149149+func TestVerifyNoDMARC(t *testing.T) {
150150+ r := goodResolver("example.com", []string{"sel1"})
151151+ delete(r.txt, "_dmarc.example.com")
152152+ v := NewVerifier(r)
153153+154154+ result := v.Verify(context.Background(), "example.com", []string{"sel1"})
155155+ if result.DMARC {
156156+ t.Error("DMARC should fail with no record")
157157+ }
158158+}
+65
internal/domain/control.go
···11+package domain
22+33+import (
44+ "context"
55+ "strings"
66+)
77+88+// TXTResolver abstracts DNS TXT lookups for testing.
99+type TXTResolver interface {
1010+ LookupTXT(ctx context.Context, name string) ([]string, error)
1111+}
1212+1313+// HandleResolver resolves a DID to its atproto handle.
1414+type HandleResolver interface {
1515+ ResolveHandle(ctx context.Context, did string) (string, error)
1616+}
1717+1818+// Verifier checks whether a DID controls a given domain.
1919+type Verifier struct {
2020+ handles HandleResolver
2121+ txt TXTResolver
2222+}
2323+2424+// NewVerifier creates a domain control verifier.
2525+func NewVerifier(handles HandleResolver, txt TXTResolver) *Verifier {
2626+ return &Verifier{handles: handles, txt: txt}
2727+}
2828+2929+// Verify checks if the given DID controls the domain.
3030+// Returns (ok, method, error) where method is "handle" or "dns-txt".
3131+//
3232+// Two verification methods:
3333+// 1. Handle match: the DID's handle exactly equals the domain.
3434+// 2. DNS TXT: a TXT record at _atproto.<domain> contains "did=<did>".
3535+func (v *Verifier) Verify(ctx context.Context, did, domain string) (bool, string, error) {
3636+ // Try handle match first (most common for PDS operators)
3737+ handle, err := v.handles.ResolveHandle(ctx, did)
3838+ if err == nil && handle != "" {
3939+ if handleMatchesDomain(handle, domain) {
4040+ return true, "handle", nil
4141+ }
4242+ }
4343+4444+ // Fall back to DNS TXT record at _atproto.<domain>
4545+ records, err := v.txt.LookupTXT(ctx, "_atproto."+domain)
4646+ if err == nil {
4747+ for _, txt := range records {
4848+ if strings.TrimSpace(txt) == "did="+did {
4949+ return true, "dns-txt", nil
5050+ }
5151+ }
5252+ }
5353+5454+ return false, "", nil
5555+}
5656+5757+// handleMatchesDomain returns true only if handle exactly equals the domain.
5858+// Subdomain handles do NOT prove control of the parent domain — a user with
5959+// handle "foo.example.com" should not be able to claim example.com mail operator.
6060+// Operators with subdomain handles must use the DNS TXT fallback instead.
6161+func handleMatchesDomain(handle, domain string) bool {
6262+ handle = strings.ToLower(strings.TrimSuffix(handle, "."))
6363+ domain = strings.ToLower(strings.TrimSuffix(domain, "."))
6464+ return handle == domain
6565+}