Cooperative email for PDS operators
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

Phase 0: atmosphere labeler service

+5766
+5
.gitignore
··· 1 + /labeler 2 + state/ 3 + *.sqlite 4 + *.sqlite-wal 5 + *.sqlite-shm
+22
.tangled/workflows/test.yaml
··· 1 + when: 2 + - event: ["push", "pull_request"] 3 + branch: main 4 + 5 + engine: nixery 6 + 7 + dependencies: 8 + nixpkgs: 9 + - go 10 + 11 + environment: 12 + CGO_ENABLED: 0 13 + 14 + steps: 15 + - name: build 16 + command: go build ./... 17 + 18 + - name: vet 19 + command: go vet ./... 20 + 21 + - name: test 22 + command: go test -count=1 ./...
+21
Dockerfile
··· 1 + ARG GO_VERSION=1 2 + FROM golang:${GO_VERSION}-bookworm AS builder 3 + 4 + WORKDIR /usr/src/app 5 + COPY go.mod go.sum ./ 6 + RUN go mod download && go mod verify 7 + COPY . . 8 + RUN go build -v -o /labeler ./cmd/labeler 9 + 10 + FROM debian:bookworm-slim 11 + 12 + RUN apt-get update && \ 13 + apt-get install -y ca-certificates && \ 14 + rm -rf /var/lib/apt/lists/* 15 + 16 + COPY --from=builder /labeler /labeler 17 + 18 + VOLUME /app/state 19 + EXPOSE 8081 20 + 21 + CMD ["/labeler", "-config=/app/state/config.json"]
+21
LICENSE
··· 1 + MIT License 2 + 3 + Copyright (c) 2026 Scott Lanoue 4 + 5 + Permission is hereby granted, free of charge, to any person obtaining a copy 6 + of this software and associated documentation files (the "Software"), to deal 7 + in the Software without restriction, including without limitation the rights 8 + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 + copies of the Software, and to permit persons to whom the Software is 10 + furnished to do so, subject to the following conditions: 11 + 12 + The above copyright notice and this permission notice shall be included in all 13 + copies or substantial portions of the Software. 14 + 15 + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 + SOFTWARE.
+102
README.md
··· 1 + # Atmosphere Mail 2 + 3 + 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. 4 + 5 + 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. 6 + 7 + See [atmosphere-mail-vision.md](atmosphere-mail-vision.md) for the full proposal. 8 + 9 + ## Status 10 + 11 + Phase 0: labeler service (local development, not yet deployed). 12 + 13 + ## How it works 14 + 15 + 1. A PDS operator publishes an `email.atmos.attestation` record declaring their mail domain and DKIM selectors 16 + 2. The labeler watches the atproto firehose (via Jetstream) for these records 17 + 3. For each attestation, it verifies: 18 + - **Domain control** — the operator's DID handle matches the domain, or a `_atproto.<domain>` TXT record points to the DID 19 + - **MX** — at least one MX record exists 20 + - **SPF** — a `v=spf1` record exists without `+all` 21 + - **DKIM** — every declared selector has a `v=DKIM1` record 22 + - **DMARC** — a record exists at `_dmarc.<domain>` with policy quarantine or reject 23 + 4. If all checks pass, the labeler signs and publishes `verified-mail-operator` (and optionally `relay-member`) labels on the operator's DID 24 + 5. Labels are queryable via standard atproto XRPC endpoints (`com.atproto.label.queryLabels`, `com.atproto.label.subscribeLabels`) 25 + 6. A scheduler re-verifies every 24 hours and negates labels if DNS degrades 26 + 27 + ## Lexicons 28 + 29 + The NSID namespace is `email.atmos.*`, backed by `atmos.email`. See [lexicons/README.md](lexicons/README.md) for schemas and open questions. 30 + 31 + ## Building 32 + 33 + ``` 34 + go build ./cmd/labeler 35 + ``` 36 + 37 + ## Running 38 + 39 + Initialize state directory and signing key: 40 + 41 + ``` 42 + ./labeler -init 43 + ``` 44 + 45 + This creates `./state/config.json` (if missing) and generates a secp256k1 signing key. The labeler's `did:key` is printed to stdout. 46 + 47 + Start the labeler: 48 + 49 + ``` 50 + ./labeler -config ./state/config.json 51 + ``` 52 + 53 + ## Configuration 54 + 55 + Copy `config.json.example` to `./state/config.json`. All fields have defaults: 56 + 57 + | Field | Default | Description | 58 + |-------|---------|-------------| 59 + | `listenAddr` | `:8081` | XRPC server bind address | 60 + | `stateDir` | `./state` | SQLite database and key storage | 61 + | `jetstreamURL` | `wss://jetstream1.us-east.bsky.network/subscribe` | Jetstream endpoint | 62 + | `signingKeyPath` | `./state/signing.key` | secp256k1 private key (hex) | 63 + | `reverifyInterval` | `24h` | Re-verification frequency | 64 + 65 + Config files support comments and trailing commas (hujson). 66 + 67 + ## Testing 68 + 69 + ``` 70 + go test ./... 71 + ``` 72 + 73 + ## Docker 74 + 75 + ``` 76 + docker build -t atmosphere-mail-labeler . 77 + docker run -v ./state:/app/state -p 8081:8081 atmosphere-mail-labeler 78 + ``` 79 + 80 + ## Architecture 81 + 82 + ``` 83 + cmd/labeler/ Entry point, wiring, graceful shutdown 84 + internal/ 85 + config/ hujson config loading 86 + store/ SQLite persistence (labels, attestations, cursor) 87 + label/signer CBOR + secp256k1 label signing, did:key derivation 88 + label/manager Verification orchestration, label create/negate 89 + dns/ MX, SPF, DKIM, DMARC verification (injectable resolver) 90 + domain/ DID-to-domain control verification 91 + server/ queryLabels (HTTP) + subscribeLabels (WebSocket) 92 + jetstream/ Firehose consumer with collection filtering 93 + scheduler/ Periodic re-verification 94 + ``` 95 + 96 + ## License 97 + 98 + MIT 99 + 100 + ## Author 101 + 102 + Scott Lanoue ([@scottlanoue.com](https://bsky.app/profile/scottlanoue.com))
+274
cmd/labeler/main.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "flag" 7 + "fmt" 8 + "io" 9 + "log" 10 + "net" 11 + "net/http" 12 + neturl "net/url" 13 + "os" 14 + "os/signal" 15 + "path/filepath" 16 + "strings" 17 + "syscall" 18 + "time" 19 + 20 + "atmosphere-mail/internal/config" 21 + "atmosphere-mail/internal/dns" 22 + "atmosphere-mail/internal/domain" 23 + "atmosphere-mail/internal/jetstream" 24 + "atmosphere-mail/internal/label" 25 + "atmosphere-mail/internal/scheduler" 26 + "atmosphere-mail/internal/server" 27 + "atmosphere-mail/internal/store" 28 + ) 29 + 30 + var ( 31 + flagConfigPath = flag.String("config", "./state/config.json", "path to config file") 32 + flagInit = flag.Bool("init", false, "initialize state directory and signing key, then exit") 33 + ) 34 + 35 + func main() { 36 + flag.Parse() 37 + 38 + if *flagInit { 39 + if err := initState(*flagConfigPath); err != nil { 40 + log.Fatalf("init: %v", err) 41 + } 42 + return 43 + } 44 + 45 + cfg, err := config.Load(*flagConfigPath) 46 + if err != nil { 47 + log.Fatalf("load config: %v", err) 48 + } 49 + 50 + if err := os.MkdirAll(cfg.StateDir, 0700); err != nil { 51 + log.Fatalf("create state dir: %v", err) 52 + } 53 + 54 + // Open store 55 + dbPath := cfg.StateDir + "/labels.sqlite" 56 + st, err := store.New(dbPath) 57 + if err != nil { 58 + log.Fatalf("open store: %v", err) 59 + } 60 + defer st.Close() 61 + 62 + // Load signing key 63 + signer, err := label.NewSigner(cfg.SigningKeyPath) 64 + if err != nil { 65 + log.Fatalf("load signing key: %v", err) 66 + } 67 + log.Printf("labeler DID: %s", signer.DID()) 68 + 69 + // Set up verification 70 + resolver := net.DefaultResolver 71 + dnsVerifier := dns.NewVerifier(resolver) 72 + handleResolver := &plcHandleResolver{client: &http.Client{Timeout: 10 * time.Second}} 73 + domainVerifier := domain.NewVerifier(handleResolver, resolver) 74 + 75 + // Label manager 76 + mgr := label.NewManager(signer, st, dnsVerifier, domainVerifier) 77 + 78 + // Context for graceful shutdown 79 + ctx, cancel := context.WithCancel(context.Background()) 80 + defer cancel() 81 + 82 + sigCh := make(chan os.Signal, 1) 83 + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) 84 + go func() { 85 + sig := <-sigCh 86 + log.Printf("received %s, shutting down", sig) 87 + cancel() 88 + }() 89 + 90 + // Resume cursor 91 + cursor, err := st.GetCursor(ctx) 92 + if err != nil { 93 + log.Fatalf("get cursor: %v", err) 94 + } 95 + if cursor > 0 { 96 + log.Printf("resuming from cursor %d", cursor) 97 + } 98 + 99 + // Jetstream consumer 100 + consumer := jetstream.NewConsumer(cfg.JetstreamURL, func(att jetstream.ReceivedAttestation) error { 101 + return handleAttestation(ctx, st, mgr, att) 102 + }) 103 + consumer.SetCursor(cursor) 104 + 105 + // Start XRPC server 106 + srv := server.New(st, signer.DID()) 107 + httpServer := &http.Server{ 108 + Addr: cfg.ListenAddr, 109 + Handler: srv.Handler(), 110 + ReadTimeout: 10 * time.Second, 111 + WriteTimeout: 30 * time.Second, 112 + IdleTimeout: 120 * time.Second, 113 + } 114 + go func() { 115 + log.Printf("XRPC server listening on %s", cfg.ListenAddr) 116 + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { 117 + log.Fatalf("http server: %v", err) 118 + } 119 + }() 120 + 121 + // Start re-verification scheduler 122 + sched := scheduler.New(mgr, st, cfg.ReverifyInterval) 123 + go func() { 124 + if err := sched.Run(ctx); err != nil && ctx.Err() == nil { 125 + log.Printf("scheduler: %v", err) 126 + } 127 + }() 128 + 129 + // Start Jetstream consumer (blocks until context cancelled) 130 + go func() { 131 + if err := consumer.Run(ctx); err != nil && ctx.Err() == nil { 132 + log.Printf("jetstream: %v", err) 133 + } 134 + }() 135 + 136 + // Periodic cursor save 137 + go func() { 138 + ticker := time.NewTicker(10 * time.Second) 139 + defer ticker.Stop() 140 + for { 141 + select { 142 + case <-ctx.Done(): 143 + return 144 + case <-ticker.C: 145 + if err := st.SetCursor(ctx, consumer.Cursor()); err != nil { 146 + log.Printf("save cursor: %v", err) 147 + } 148 + } 149 + } 150 + }() 151 + 152 + <-ctx.Done() 153 + 154 + // Graceful shutdown: close WebSockets first, then HTTP server 155 + srv.ShutdownWebSockets() 156 + 157 + shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second) 158 + defer shutdownCancel() 159 + httpServer.Shutdown(shutdownCtx) 160 + 161 + // Final cursor save 162 + if err := st.SetCursor(context.Background(), consumer.Cursor()); err != nil { 163 + log.Printf("final cursor save: %v", err) 164 + } 165 + 166 + log.Println("shutdown complete") 167 + } 168 + 169 + func initState(configPath string) error { 170 + cfg, err := config.Load(configPath) 171 + if err != nil { 172 + // Create default config if it doesn't exist 173 + // Use filepath.Dir to put config next to the config file's directory 174 + configDir := filepath.Dir(configPath) 175 + stateDir := filepath.Join(configDir, "state") 176 + if err := os.MkdirAll(stateDir, 0700); err != nil { 177 + return err 178 + } 179 + if err := os.WriteFile(configPath, []byte("{}\n"), 0600); err != nil { 180 + return err 181 + } 182 + cfg, err = config.Load(configPath) 183 + if err != nil { 184 + return err 185 + } 186 + } 187 + 188 + if err := os.MkdirAll(cfg.StateDir, 0700); err != nil { 189 + return err 190 + } 191 + 192 + if _, err := os.Stat(cfg.SigningKeyPath); os.IsNotExist(err) { 193 + log.Printf("generating signing key at %s", cfg.SigningKeyPath) 194 + if err := label.GenerateKey(cfg.SigningKeyPath); err != nil { 195 + return err 196 + } 197 + } else { 198 + log.Printf("signing key already exists at %s", cfg.SigningKeyPath) 199 + } 200 + 201 + signer, err := label.NewSigner(cfg.SigningKeyPath) 202 + if err != nil { 203 + return err 204 + } 205 + log.Printf("labeler DID: %s", signer.DID()) 206 + log.Println("init complete") 207 + return nil 208 + } 209 + 210 + func handleAttestation(ctx context.Context, st *store.Store, mgr *label.Manager, att jetstream.ReceivedAttestation) error { 211 + switch att.Operation { 212 + case "create", "update": 213 + // Validate before storing — don't persist invalid data 214 + if err := label.ValidateAttestation(att.DID, att.Domain, att.DKIMSelectors); err != nil { 215 + log.Printf("dropping invalid attestation from %s: %v", att.DID, err) 216 + return nil 217 + } 218 + storeAtt := &store.Attestation{ 219 + DID: att.DID, 220 + RKey: att.RKey, 221 + Domain: att.Domain, 222 + DKIMSelectors: att.DKIMSelectors, 223 + RelayMember: att.RelayMember, 224 + CreatedAt: time.Now().UTC(), 225 + } 226 + if err := st.UpsertAttestation(ctx, storeAtt); err != nil { 227 + return err 228 + } 229 + return mgr.ProcessAttestation(ctx, storeAtt) 230 + 231 + case "delete": 232 + if err := st.DeleteAttestationByRKey(ctx, att.DID, att.RKey); err != nil { 233 + return err 234 + } 235 + return mgr.ReconcileLabels(ctx, att.DID) 236 + } 237 + return nil 238 + } 239 + 240 + // plcHandleResolver resolves a DID to its atproto handle via the PLC directory. 241 + type plcHandleResolver struct { 242 + client *http.Client 243 + } 244 + 245 + func (r *plcHandleResolver) ResolveHandle(ctx context.Context, did string) (string, error) { 246 + url := "https://plc.directory/" + neturl.PathEscape(did) 247 + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 248 + if err != nil { 249 + return "", err 250 + } 251 + resp, err := r.client.Do(req) 252 + if err != nil { 253 + return "", fmt.Errorf("plc lookup %s: %v", did, err) 254 + } 255 + defer resp.Body.Close() 256 + 257 + if resp.StatusCode != http.StatusOK { 258 + return "", fmt.Errorf("plc lookup %s: status %d", did, resp.StatusCode) 259 + } 260 + 261 + var doc struct { 262 + AlsoKnownAs []string `json:"alsoKnownAs"` 263 + } 264 + if err := json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&doc); err != nil { 265 + return "", fmt.Errorf("plc decode %s: %v", did, err) 266 + } 267 + 268 + for _, aka := range doc.AlsoKnownAs { 269 + if strings.HasPrefix(aka, "at://") { 270 + return strings.TrimPrefix(aka, "at://"), nil 271 + } 272 + } 273 + return "", nil 274 + }
+335
cmd/labeler/main_test.go
··· 1 + package main 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "net/http" 7 + "net/http/httptest" 8 + "strings" 9 + "testing" 10 + "time" 11 + 12 + "atmosphere-mail/internal/dns" 13 + "atmosphere-mail/internal/jetstream" 14 + "atmosphere-mail/internal/label" 15 + "atmosphere-mail/internal/scheduler" 16 + "atmosphere-mail/internal/server" 17 + "atmosphere-mail/internal/store" 18 + 19 + "github.com/gorilla/websocket" 20 + ) 21 + 22 + // Integration test: full pipeline from attestation event to queryable label. 23 + func TestEndToEnd(t *testing.T) { 24 + ctx := context.Background() 25 + 26 + // Set up store (use temp file, not :memory:, because database/sql 27 + // connection pooling gives each connection its own :memory: DB) 28 + dbPath := t.TempDir() + "/test.sqlite" 29 + st, err := store.New(dbPath) 30 + if err != nil { 31 + t.Fatal(err) 32 + } 33 + defer st.Close() 34 + 35 + // Set up signer 36 + keyPath := t.TempDir() + "/signing.key" 37 + if err := label.GenerateKey(keyPath); err != nil { 38 + t.Fatal(err) 39 + } 40 + signer, err := label.NewSigner(keyPath) 41 + if err != nil { 42 + t.Fatal(err) 43 + } 44 + 45 + // Mock DNS: all checks pass 46 + dnsVerifier := &passDNS{} 47 + domainVerifier := &passDomain{method: "handle"} 48 + 49 + mgr := label.NewManager(signer, st, dnsVerifier, domainVerifier) 50 + 51 + // -- Simulate Jetstream attestation create -- 52 + 53 + att := jetstream.ReceivedAttestation{ 54 + DID: "did:plc:oper2345oper2345oper2345", 55 + RKey: "operator1.com", 56 + Domain: "operator1.com", 57 + DKIMSelectors: []string{"default", "mail"}, 58 + RelayMember: true, 59 + CreatedAt: "2026-03-31T00:00:00Z", 60 + Operation: "create", 61 + TimeUS: 1000, 62 + } 63 + 64 + if err := handleAttestation(ctx, st, mgr, att); err != nil { 65 + t.Fatalf("handleAttestation: %v", err) 66 + } 67 + 68 + // Verify attestation stored 69 + stored, err := st.GetAttestation(ctx, "did:plc:oper2345oper2345oper2345", "operator1.com") 70 + if err != nil { 71 + t.Fatal(err) 72 + } 73 + if stored == nil { 74 + t.Fatal("attestation not stored") 75 + } 76 + if !stored.Verified { 77 + t.Error("attestation should be verified") 78 + } 79 + 80 + // Verify labels created 81 + labels, err := st.GetActiveLabelsForDID(ctx, "did:plc:oper2345oper2345oper2345") 82 + if err != nil { 83 + t.Fatal(err) 84 + } 85 + if len(labels) != 2 { 86 + t.Fatalf("got %d active labels, want 2", len(labels)) 87 + } 88 + 89 + vals := map[string]bool{} 90 + for _, l := range labels { 91 + vals[l.Val] = true 92 + if l.Src != signer.DID() { 93 + t.Errorf("label src = %q, want %q", l.Src, signer.DID()) 94 + } 95 + } 96 + if !vals["verified-mail-operator"] { 97 + t.Error("missing verified-mail-operator label") 98 + } 99 + if !vals["relay-member"] { 100 + t.Error("missing relay-member label") 101 + } 102 + 103 + // -- Query via XRPC server -- 104 + 105 + srv := server.New(st, signer.DID()) 106 + ts := httptest.NewServer(srv.Handler()) 107 + defer ts.Close() 108 + 109 + resp, err := http.Get(ts.URL + "/xrpc/com.atproto.label.queryLabels?uriPatterns=did:plc:oper2345oper2345oper2345") 110 + if err != nil { 111 + t.Fatal(err) 112 + } 113 + defer resp.Body.Close() 114 + 115 + var qr struct { 116 + Labels []struct { 117 + Src string `json:"src"` 118 + URI string `json:"uri"` 119 + Val string `json:"val"` 120 + Ver int64 `json:"ver"` 121 + } `json:"labels"` 122 + } 123 + if err := json.NewDecoder(resp.Body).Decode(&qr); err != nil { 124 + t.Fatal(err) 125 + } 126 + if len(qr.Labels) != 2 { 127 + t.Fatalf("queryLabels returned %d, want 2", len(qr.Labels)) 128 + } 129 + 130 + // -- Subscribe via WebSocket -- 131 + 132 + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/xrpc/com.atproto.label.subscribeLabels?cursor=0" 133 + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) 134 + if err != nil { 135 + t.Fatal(err) 136 + } 137 + defer conn.Close() 138 + 139 + for i := 0; i < 2; i++ { 140 + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) 141 + _, _, err := conn.ReadMessage() 142 + if err != nil { 143 + t.Fatalf("subscribe read %d: %v", i, err) 144 + } 145 + } 146 + 147 + // -- Simulate attestation delete -- 148 + 149 + delAtt := jetstream.ReceivedAttestation{ 150 + DID: "did:plc:oper2345oper2345oper2345", 151 + RKey: "operator1.com", 152 + Operation: "delete", 153 + TimeUS: 2000, 154 + } 155 + if err := handleAttestation(ctx, st, mgr, delAtt); err != nil { 156 + t.Fatal(err) 157 + } 158 + 159 + // Labels should be negated 160 + labels, err = st.GetActiveLabelsForDID(ctx, "did:plc:oper2345oper2345oper2345") 161 + if err != nil { 162 + t.Fatal(err) 163 + } 164 + if len(labels) != 0 { 165 + t.Errorf("got %d active labels after delete, want 0", len(labels)) 166 + } 167 + 168 + // Attestation should be deleted 169 + stored, err = st.GetAttestation(ctx, "did:plc:oper2345oper2345oper2345", "operator1.com") 170 + if err != nil { 171 + t.Fatal(err) 172 + } 173 + if stored != nil { 174 + t.Error("attestation should be deleted") 175 + } 176 + 177 + // -- Simulate attestation update (add relay membership) -- 178 + 179 + updateAtt := jetstream.ReceivedAttestation{ 180 + DID: "did:plc:oper2345oper2345oper2345", 181 + RKey: "operator1.com", 182 + Domain: "operator1.com", 183 + DKIMSelectors: []string{"default", "mail"}, 184 + RelayMember: true, 185 + CreatedAt: "2026-03-31T00:00:00Z", 186 + Operation: "update", 187 + TimeUS: 1500, 188 + } 189 + if err := handleAttestation(ctx, st, mgr, updateAtt); err != nil { 190 + t.Fatalf("handleAttestation (update): %v", err) 191 + } 192 + 193 + // Verify attestation updated (relay_member should now be true) 194 + stored, err = st.GetAttestation(ctx, "did:plc:oper2345oper2345oper2345", "operator1.com") 195 + if err != nil { 196 + t.Fatal(err) 197 + } 198 + if stored == nil { 199 + t.Fatal("attestation not stored after update") 200 + } 201 + if !stored.RelayMember { 202 + t.Error("attestation should have relayMember=true after update") 203 + } 204 + 205 + // Should now have 2 labels (verified-mail-operator + relay-member) 206 + labels, err = st.GetActiveLabelsForDID(ctx, "did:plc:oper2345oper2345oper2345") 207 + if err != nil { 208 + t.Fatal(err) 209 + } 210 + if len(labels) != 2 { 211 + t.Fatalf("got %d active labels after update, want 2", len(labels)) 212 + } 213 + 214 + // -- Test re-verification scheduler -- 215 + 216 + att2 := jetstream.ReceivedAttestation{ 217 + DID: "did:plc:aaaa5555bbbb6666cccc7777", 218 + RKey: "operator2.com", 219 + Domain: "operator2.com", 220 + DKIMSelectors: []string{"sel1"}, 221 + Operation: "create", 222 + TimeUS: 3000, 223 + } 224 + if err := handleAttestation(ctx, st, mgr, att2); err != nil { 225 + t.Fatal(err) 226 + } 227 + 228 + sched := scheduler.New(mgr, st, time.Hour) 229 + if err := sched.RunOnce(ctx); err != nil { 230 + t.Fatal(err) 231 + } 232 + 233 + labels, err = st.GetActiveLabelsForDID(ctx, "did:plc:aaaa5555bbbb6666cccc7777") 234 + if err != nil { 235 + t.Fatal(err) 236 + } 237 + if len(labels) != 1 { 238 + t.Errorf("got %d labels for operator2 after reverify, want 1", len(labels)) 239 + } 240 + } 241 + 242 + func TestPLCHandleResolver(t *testing.T) { 243 + // Mock PLC directory server 244 + plcServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 245 + switch r.URL.Path { 246 + case "/did:plc:found": 247 + json.NewEncoder(w).Encode(map[string]any{ 248 + "alsoKnownAs": []string{"at://alice.example.com"}, 249 + }) 250 + case "/did:plc:nohandle": 251 + json.NewEncoder(w).Encode(map[string]any{ 252 + "alsoKnownAs": []string{"https://not-at-proto.example.com"}, 253 + }) 254 + case "/did:plc:empty": 255 + json.NewEncoder(w).Encode(map[string]any{}) 256 + default: 257 + http.NotFound(w, r) 258 + } 259 + })) 260 + defer plcServer.Close() 261 + 262 + // Override the PLC URL by creating a resolver with a custom client 263 + // that rewrites the host 264 + resolver := &plcHandleResolver{client: plcServer.Client()} 265 + 266 + ctx := context.Background() 267 + 268 + // Test: found handle 269 + // We need to call the actual resolver but point it at our mock. 270 + // Since plcHandleResolver hardcodes plc.directory, we'll test the 271 + // response parsing logic directly with a transport override. 272 + transport := &rewriteTransport{base: plcServer.Client().Transport, target: plcServer.URL} 273 + resolver.client = &http.Client{Transport: transport, Timeout: 5 * time.Second} 274 + 275 + handle, err := resolver.ResolveHandle(ctx, "did:plc:found") 276 + if err != nil { 277 + t.Fatal(err) 278 + } 279 + if handle != "alice.example.com" { 280 + t.Errorf("handle = %q, want alice.example.com", handle) 281 + } 282 + 283 + // Test: no at:// handle 284 + handle, err = resolver.ResolveHandle(ctx, "did:plc:nohandle") 285 + if err != nil { 286 + t.Fatal(err) 287 + } 288 + if handle != "" { 289 + t.Errorf("handle = %q, want empty (no at:// entry)", handle) 290 + } 291 + 292 + // Test: empty alsoKnownAs 293 + handle, err = resolver.ResolveHandle(ctx, "did:plc:empty") 294 + if err != nil { 295 + t.Fatal(err) 296 + } 297 + if handle != "" { 298 + t.Errorf("handle = %q, want empty", handle) 299 + } 300 + 301 + // Test: 404 returns error 302 + _, err = resolver.ResolveHandle(ctx, "did:plc:unknown") 303 + if err == nil { 304 + t.Error("expected error for 404 DID") 305 + } 306 + } 307 + 308 + // rewriteTransport redirects HTTPS requests to our local test server. 309 + type rewriteTransport struct { 310 + base http.RoundTripper 311 + target string 312 + } 313 + 314 + func (t *rewriteTransport) RoundTrip(req *http.Request) (*http.Response, error) { 315 + req.URL.Scheme = "http" 316 + req.URL.Host = strings.TrimPrefix(t.target, "http://") 317 + if t.base != nil { 318 + return t.base.RoundTrip(req) 319 + } 320 + return http.DefaultTransport.RoundTrip(req) 321 + } 322 + 323 + // -- Test mocks -- 324 + 325 + type passDNS struct{} 326 + 327 + func (p *passDNS) Verify(ctx context.Context, domain string, selectors []string) dns.Result { 328 + return dns.Result{MX: true, SPF: true, DKIM: true, DMARC: true} 329 + } 330 + 331 + type passDomain struct{ method string } 332 + 333 + func (p *passDomain) Verify(ctx context.Context, did, domain string) (bool, string, error) { 334 + return true, p.method, nil 335 + }
+22
config.json.example
··· 1 + { 2 + // Atmosphere Mail labeler configuration. 3 + // Copy this to ./state/config.json and edit as needed. 4 + 5 + // Address for the XRPC server (queryLabels + subscribeLabels). 6 + "listenAddr": ":8081", 7 + 8 + // Directory for SQLite database and signing key. 9 + "stateDir": "./state", 10 + 11 + // Jetstream WebSocket URL. Public Bluesky instances: 12 + // wss://jetstream1.us-east.bsky.network/subscribe 13 + // wss://jetstream2.us-east.bsky.network/subscribe 14 + "jetstreamURL": "wss://jetstream1.us-east.bsky.network/subscribe", 15 + 16 + // Path to the secp256k1 signing key (hex-encoded). 17 + // Generate with: go run ./cmd/labeler -init 18 + "signingKeyPath": "./state/signing.key", 19 + 20 + // How often to re-verify all attestations. 21 + "reverifyInterval": "24h", 22 + }
+27
go.mod
··· 1 + module atmosphere-mail 2 + 3 + go 1.25.0 4 + 5 + require ( 6 + github.com/fxamacker/cbor/v2 v2.9.1 7 + github.com/gorilla/websocket v1.5.3 8 + github.com/mr-tron/base58 v1.3.0 9 + github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd 10 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b 11 + modernc.org/sqlite v1.48.1 12 + ) 13 + 14 + require ( 15 + github.com/dustin/go-humanize v1.0.1 // indirect 16 + github.com/google/uuid v1.6.0 // indirect 17 + github.com/mattn/go-isatty v0.0.20 // indirect 18 + github.com/ncruces/go-strftime v1.0.0 // indirect 19 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 20 + github.com/x448/float16 v0.8.4 // indirect 21 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 22 + golang.org/x/crypto v0.11.0 // indirect 23 + golang.org/x/sys v0.42.0 // indirect 24 + modernc.org/libc v1.70.0 // indirect 25 + modernc.org/mathutil v1.7.1 // indirect 26 + modernc.org/memory v1.11.0 // indirect 27 + )
+81
go.sum
··· 1 + github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 + github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 + github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 4 + github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 5 + github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ= 6 + github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 7 + github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= 8 + github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 9 + github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= 10 + github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 11 + github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 12 + github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 13 + github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= 14 + github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= 15 + github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 16 + github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 17 + github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 18 + github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 19 + github.com/mr-tron/base58 v1.2.0 h1:T/HDJBh4ZCPbU39/+c3rRvE0uKBQlU27+QI8LJ4t64o= 20 + github.com/mr-tron/base58 v1.2.0/go.mod h1:BinMc/sQntlIE1frQmRFPUoPA1Zkr8VRgBdjWI2mNwc= 21 + github.com/mr-tron/base58 v1.3.0 h1:K6Y13R2h+dku0wOqKtecgRnBUBPrZzLZy5aIj8lCcJI= 22 + github.com/mr-tron/base58 v1.3.0/go.mod h1:2BuubE67DCSWwVfx37JWNG8emOC0sHEU4/HpcYgCLX8= 23 + github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= 24 + github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 25 + github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 26 + github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 27 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 28 + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 29 + github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= 30 + github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 31 + github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd h1:Rf9uhF1+VJ7ZHqxrG8pJ6YacmHvVCmByDmGbAWCc/gA= 32 + github.com/tailscale/hujson v0.0.0-20260302212456-ecc657c15afd/go.mod h1:EbW0wDK/qEUYI0A5bqq0C2kF8JTQwWONmGDBbzsxxHo= 33 + github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 34 + github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 35 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRySiX3drau9C6Q5CAbNIApmLdat5jPMqChvDA= 36 + gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8= 37 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q= 38 + gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I= 39 + golang.org/x/crypto v0.11.0 h1:6Ewdq3tDic1mg5xRO4milcWCfMVQhI4NkqWWvqejpuA= 40 + golang.org/x/crypto v0.11.0/go.mod h1:xgJhtzW8F9jGdVFWZESrid1U1bjeNy4zgy5cRr/CIio= 41 + golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= 42 + golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= 43 + golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= 44 + golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 45 + golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 46 + golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= 47 + golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 48 + golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= 49 + golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= 50 + gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 51 + gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 52 + modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= 53 + modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= 54 + modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= 55 + modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= 56 + modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= 57 + modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= 58 + modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= 59 + modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= 60 + modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= 61 + modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= 62 + modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= 63 + modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= 64 + modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= 65 + modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= 66 + modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= 67 + modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 68 + modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= 69 + modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= 70 + modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= 71 + modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 72 + modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= 73 + modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= 74 + modernc.org/sqlite v1.48.0 h1:ElZyLop3Q2mHYk5IFPPXADejZrlHu7APbpB0sF78bq4= 75 + modernc.org/sqlite v1.48.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= 76 + modernc.org/sqlite v1.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA= 77 + modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= 78 + modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= 79 + modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 80 + modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 81 + modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+79
internal/config/config.go
··· 1 + package config 2 + 3 + import ( 4 + "encoding/json" 5 + "fmt" 6 + "os" 7 + "time" 8 + 9 + "github.com/tailscale/hujson" 10 + ) 11 + 12 + type Config struct { 13 + ListenAddr string `json:"listenAddr"` 14 + StateDir string `json:"stateDir"` 15 + JetstreamURL string `json:"jetstreamURL"` 16 + SigningKeyPath string `json:"signingKeyPath"` 17 + ReverifyInterval time.Duration `json:"reverifyInterval"` 18 + } 19 + 20 + type configJSON struct { 21 + ListenAddr string `json:"listenAddr"` 22 + StateDir string `json:"stateDir"` 23 + JetstreamURL string `json:"jetstreamURL"` 24 + SigningKeyPath string `json:"signingKeyPath"` 25 + ReverifyInterval string `json:"reverifyInterval"` 26 + } 27 + 28 + func Load(path string) (*Config, error) { 29 + data, err := os.ReadFile(path) 30 + if err != nil { 31 + return nil, fmt.Errorf("cannot read config %s: %v", path, err) 32 + } 33 + 34 + data, err = hujson.Standardize(data) 35 + if err != nil { 36 + return nil, fmt.Errorf("cannot parse config %s: %v", path, err) 37 + } 38 + 39 + var raw configJSON 40 + if err := json.Unmarshal(data, &raw); err != nil { 41 + return nil, fmt.Errorf("cannot unmarshal config %s: %v", path, err) 42 + } 43 + 44 + cfg := &Config{ 45 + ListenAddr: raw.ListenAddr, 46 + StateDir: raw.StateDir, 47 + JetstreamURL: raw.JetstreamURL, 48 + SigningKeyPath: raw.SigningKeyPath, 49 + } 50 + 51 + if raw.ReverifyInterval != "" { 52 + d, err := time.ParseDuration(raw.ReverifyInterval) 53 + if err != nil { 54 + return nil, fmt.Errorf("invalid reverifyInterval %q: %v", raw.ReverifyInterval, err) 55 + } 56 + cfg.ReverifyInterval = d 57 + } 58 + 59 + cfg.applyDefaults() 60 + return cfg, nil 61 + } 62 + 63 + func (c *Config) applyDefaults() { 64 + if c.ListenAddr == "" { 65 + c.ListenAddr = ":8081" 66 + } 67 + if c.StateDir == "" { 68 + c.StateDir = "./state" 69 + } 70 + if c.JetstreamURL == "" { 71 + c.JetstreamURL = "wss://jetstream1.us-east.bsky.network/subscribe" 72 + } 73 + if c.SigningKeyPath == "" { 74 + c.SigningKeyPath = c.StateDir + "/signing.key" 75 + } 76 + if c.ReverifyInterval == 0 { 77 + c.ReverifyInterval = 24 * time.Hour 78 + } 79 + }
+91
internal/config/config_test.go
··· 1 + package config 2 + 3 + import ( 4 + "os" 5 + "path/filepath" 6 + "testing" 7 + "time" 8 + ) 9 + 10 + func TestLoad(t *testing.T) { 11 + dir := t.TempDir() 12 + path := filepath.Join(dir, "config.json") 13 + 14 + data := []byte(`{ 15 + // labeler configuration 16 + "listenAddr": ":8081", 17 + "stateDir": "./state", 18 + "jetstreamURL": "wss://jetstream1.us-east.bsky.network/subscribe", 19 + "signingKeyPath": "./state/signing.key", 20 + "reverifyInterval": "24h", 21 + }`) 22 + if err := os.WriteFile(path, data, 0600); err != nil { 23 + t.Fatal(err) 24 + } 25 + 26 + cfg, err := Load(path) 27 + if err != nil { 28 + t.Fatalf("Load: %v", err) 29 + } 30 + 31 + if cfg.ListenAddr != ":8081" { 32 + t.Errorf("ListenAddr = %q, want %q", cfg.ListenAddr, ":8081") 33 + } 34 + if cfg.StateDir != "./state" { 35 + t.Errorf("StateDir = %q, want %q", cfg.StateDir, "./state") 36 + } 37 + if cfg.JetstreamURL != "wss://jetstream1.us-east.bsky.network/subscribe" { 38 + t.Errorf("JetstreamURL = %q", cfg.JetstreamURL) 39 + } 40 + if cfg.SigningKeyPath != "./state/signing.key" { 41 + t.Errorf("SigningKeyPath = %q", cfg.SigningKeyPath) 42 + } 43 + if cfg.ReverifyInterval != 24*time.Hour { 44 + t.Errorf("ReverifyInterval = %v, want 24h", cfg.ReverifyInterval) 45 + } 46 + } 47 + 48 + func TestLoadDefaults(t *testing.T) { 49 + dir := t.TempDir() 50 + path := filepath.Join(dir, "config.json") 51 + 52 + if err := os.WriteFile(path, []byte(`{}`), 0600); err != nil { 53 + t.Fatal(err) 54 + } 55 + 56 + cfg, err := Load(path) 57 + if err != nil { 58 + t.Fatalf("Load: %v", err) 59 + } 60 + 61 + if cfg.ListenAddr != ":8081" { 62 + t.Errorf("default ListenAddr = %q, want %q", cfg.ListenAddr, ":8081") 63 + } 64 + if cfg.StateDir != "./state" { 65 + t.Errorf("default StateDir = %q, want %q", cfg.StateDir, "./state") 66 + } 67 + if cfg.ReverifyInterval != 24*time.Hour { 68 + t.Errorf("default ReverifyInterval = %v, want 24h", cfg.ReverifyInterval) 69 + } 70 + } 71 + 72 + func TestLoadMissingFile(t *testing.T) { 73 + _, err := Load("/nonexistent/path/config.json") 74 + if err == nil { 75 + t.Fatal("expected error for missing file") 76 + } 77 + } 78 + 79 + func TestLoadInvalidJSON(t *testing.T) { 80 + dir := t.TempDir() 81 + path := filepath.Join(dir, "config.json") 82 + 83 + if err := os.WriteFile(path, []byte(`{not json`), 0600); err != nil { 84 + t.Fatal(err) 85 + } 86 + 87 + _, err := Load(path) 88 + if err == nil { 89 + t.Fatal("expected error for invalid JSON") 90 + } 91 + }
+155
internal/dns/verifier.go
··· 1 + package dns 2 + 3 + import ( 4 + "context" 5 + "net" 6 + "strings" 7 + ) 8 + 9 + // Resolver abstracts DNS lookups for testing. 10 + type Resolver interface { 11 + LookupMX(ctx context.Context, name string) ([]*net.MX, error) 12 + LookupTXT(ctx context.Context, name string) ([]string, error) 13 + } 14 + 15 + // Result holds the outcome of DNS verification for a mail domain. 16 + type Result struct { 17 + MX bool 18 + SPF bool 19 + DKIM bool 20 + DMARC bool 21 + Failures []string 22 + } 23 + 24 + // Pass returns true if all checks passed. 25 + func (r *Result) Pass() bool { 26 + return r.MX && r.SPF && r.DKIM && r.DMARC 27 + } 28 + 29 + // Verifier checks MX, SPF, DKIM, and DMARC for a domain. 30 + type Verifier struct { 31 + resolver Resolver 32 + } 33 + 34 + // NewVerifier creates a Verifier with the given DNS resolver. 35 + // Pass nil to use the system default resolver. 36 + func NewVerifier(r Resolver) *Verifier { 37 + if r == nil { 38 + r = net.DefaultResolver 39 + } 40 + return &Verifier{resolver: r} 41 + } 42 + 43 + // Verify runs all DNS checks for the given domain and DKIM selectors. 44 + func (v *Verifier) Verify(ctx context.Context, domain string, dkimSelectors []string) Result { 45 + var r Result 46 + 47 + r.MX = v.checkMX(ctx, domain, &r) 48 + r.SPF = v.checkSPF(ctx, domain, &r) 49 + r.DKIM = v.checkDKIM(ctx, domain, dkimSelectors, &r) 50 + r.DMARC = v.checkDMARC(ctx, domain, &r) 51 + 52 + return r 53 + } 54 + 55 + // checkMX verifies at least one MX record exists. 56 + func (v *Verifier) checkMX(ctx context.Context, domain string, r *Result) bool { 57 + mx, err := v.resolver.LookupMX(ctx, domain) 58 + if err != nil || len(mx) == 0 { 59 + r.Failures = append(r.Failures, "no MX records found") 60 + return false 61 + } 62 + return true 63 + } 64 + 65 + // checkSPF verifies a v=spf1 TXT record exists and does not contain +all. 66 + func (v *Verifier) checkSPF(ctx context.Context, domain string, r *Result) bool { 67 + records, err := v.resolver.LookupTXT(ctx, domain) 68 + if err != nil { 69 + r.Failures = append(r.Failures, "SPF: no TXT records found") 70 + return false 71 + } 72 + 73 + for _, txt := range records { 74 + if !strings.HasPrefix(strings.TrimSpace(txt), "v=spf1") { 75 + continue 76 + } 77 + // Check for exact "+all" mechanism (space-separated tokens) 78 + for _, mech := range strings.Fields(txt) { 79 + if mech == "+all" { 80 + r.Failures = append(r.Failures, "SPF: contains +all (allows any sender)") 81 + return false 82 + } 83 + } 84 + return true 85 + } 86 + 87 + r.Failures = append(r.Failures, "SPF: no v=spf1 record found") 88 + return false 89 + } 90 + 91 + // checkDKIM verifies every declared selector has a v=DKIM1 TXT record. 92 + func (v *Verifier) checkDKIM(ctx context.Context, domain string, selectors []string, r *Result) bool { 93 + if len(selectors) == 0 { 94 + r.Failures = append(r.Failures, "DKIM: no selectors declared") 95 + return false 96 + } 97 + allOK := true 98 + for _, sel := range selectors { 99 + name := sel + "._domainkey." + domain 100 + records, err := v.resolver.LookupTXT(ctx, name) 101 + if err != nil { 102 + r.Failures = append(r.Failures, "DKIM: no record for selector "+sel) 103 + allOK = false 104 + continue 105 + } 106 + found := false 107 + for _, txt := range records { 108 + if strings.Contains(txt, "v=DKIM1") { 109 + found = true 110 + break 111 + } 112 + } 113 + if !found { 114 + r.Failures = append(r.Failures, "DKIM: selector "+sel+" missing v=DKIM1") 115 + allOK = false 116 + } 117 + } 118 + return allOK 119 + } 120 + 121 + // checkDMARC verifies a DMARC record exists at _dmarc.<domain> with policy != none. 122 + func (v *Verifier) checkDMARC(ctx context.Context, domain string, r *Result) bool { 123 + name := "_dmarc." + domain 124 + records, err := v.resolver.LookupTXT(ctx, name) 125 + if err != nil { 126 + r.Failures = append(r.Failures, "DMARC: no record at "+name) 127 + return false 128 + } 129 + 130 + for _, txt := range records { 131 + if !strings.Contains(txt, "v=DMARC1") { 132 + continue 133 + } 134 + policy := parseDMARCPolicy(txt) 135 + if policy == "none" || policy == "" { 136 + r.Failures = append(r.Failures, "DMARC: policy is "+policy+" (must be quarantine or reject)") 137 + return false 138 + } 139 + return true 140 + } 141 + 142 + r.Failures = append(r.Failures, "DMARC: no v=DMARC1 record found") 143 + return false 144 + } 145 + 146 + // parseDMARCPolicy extracts the p= value from a DMARC TXT record. 147 + func parseDMARCPolicy(txt string) string { 148 + for _, part := range strings.Split(txt, ";") { 149 + part = strings.TrimSpace(part) 150 + if strings.HasPrefix(part, "p=") { 151 + return strings.TrimSpace(strings.TrimPrefix(part, "p=")) 152 + } 153 + } 154 + return "" 155 + }
+158
internal/dns/verifier_test.go
··· 1 + package dns 2 + 3 + import ( 4 + "context" 5 + "net" 6 + "testing" 7 + ) 8 + 9 + // mockResolver implements Resolver with canned responses. 10 + type mockResolver struct { 11 + mx []*net.MX 12 + txt map[string][]string 13 + } 14 + 15 + func (m *mockResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) { 16 + if m.mx == nil { 17 + return nil, &net.DNSError{Err: "no MX", Name: name, IsNotFound: true} 18 + } 19 + return m.mx, nil 20 + } 21 + 22 + func (m *mockResolver) LookupTXT(ctx context.Context, name string) ([]string, error) { 23 + if records, ok := m.txt[name]; ok { 24 + return records, nil 25 + } 26 + return nil, &net.DNSError{Err: "no TXT", Name: name, IsNotFound: true} 27 + } 28 + 29 + func goodResolver(domain string, selectors []string) *mockResolver { 30 + txt := map[string][]string{ 31 + domain: {"v=spf1 include:_spf.google.com ~all"}, 32 + "_dmarc." + domain: {"v=DMARC1; p=reject; rua=mailto:dmarc@" + domain}, 33 + } 34 + for _, sel := range selectors { 35 + txt[sel+"._domainkey."+domain] = []string{"v=DKIM1; k=rsa; p=MIIBIjANBg..."} 36 + } 37 + 38 + return &mockResolver{ 39 + mx: []*net.MX{{Host: "mail." + domain, Pref: 10}}, 40 + txt: txt, 41 + } 42 + } 43 + 44 + func TestVerifyAllPass(t *testing.T) { 45 + r := goodResolver("example.com", []string{"sel1", "sel2"}) 46 + v := NewVerifier(r) 47 + 48 + result := v.Verify(context.Background(), "example.com", []string{"sel1", "sel2"}) 49 + if !result.Pass() { 50 + t.Errorf("expected all pass, got: MX=%v SPF=%v DKIM=%v DMARC=%v", 51 + result.MX, result.SPF, result.DKIM, result.DMARC) 52 + for _, f := range result.Failures { 53 + t.Errorf(" failure: %s", f) 54 + } 55 + } 56 + } 57 + 58 + func TestVerifyNoMX(t *testing.T) { 59 + r := goodResolver("example.com", []string{"sel1"}) 60 + r.mx = nil 61 + v := NewVerifier(r) 62 + 63 + result := v.Verify(context.Background(), "example.com", []string{"sel1"}) 64 + if result.MX { 65 + t.Error("MX should fail with no records") 66 + } 67 + if result.Pass() { 68 + t.Error("should not pass with MX failure") 69 + } 70 + } 71 + 72 + func TestVerifyNoSPF(t *testing.T) { 73 + r := goodResolver("example.com", []string{"sel1"}) 74 + delete(r.txt, "example.com") 75 + v := NewVerifier(r) 76 + 77 + result := v.Verify(context.Background(), "example.com", []string{"sel1"}) 78 + if result.SPF { 79 + t.Error("SPF should fail with no TXT record") 80 + } 81 + } 82 + 83 + func TestVerifySPFPlusAll(t *testing.T) { 84 + r := goodResolver("example.com", []string{"sel1"}) 85 + r.txt["example.com"] = []string{"v=spf1 +all"} 86 + v := NewVerifier(r) 87 + 88 + result := v.Verify(context.Background(), "example.com", []string{"sel1"}) 89 + if result.SPF { 90 + t.Error("SPF should fail with +all") 91 + } 92 + } 93 + 94 + func TestVerifyMissingDKIM(t *testing.T) { 95 + r := goodResolver("example.com", []string{"sel1"}) 96 + // sel2 is declared but not in DNS 97 + v := NewVerifier(r) 98 + 99 + result := v.Verify(context.Background(), "example.com", []string{"sel1", "sel2"}) 100 + if result.DKIM { 101 + t.Error("DKIM should fail when a selector is missing") 102 + } 103 + } 104 + 105 + func TestVerifyDMARCNone(t *testing.T) { 106 + r := goodResolver("example.com", []string{"sel1"}) 107 + r.txt["_dmarc.example.com"] = []string{"v=DMARC1; p=none"} 108 + v := NewVerifier(r) 109 + 110 + result := v.Verify(context.Background(), "example.com", []string{"sel1"}) 111 + if result.DMARC { 112 + t.Error("DMARC should fail with p=none") 113 + } 114 + } 115 + 116 + func TestVerifyDMARCQuarantine(t *testing.T) { 117 + r := goodResolver("example.com", []string{"sel1"}) 118 + r.txt["_dmarc.example.com"] = []string{"v=DMARC1; p=quarantine"} 119 + v := NewVerifier(r) 120 + 121 + result := v.Verify(context.Background(), "example.com", []string{"sel1"}) 122 + if !result.DMARC { 123 + t.Error("DMARC should pass with p=quarantine") 124 + } 125 + } 126 + 127 + func TestVerifySPFPlusAllSubstring(t *testing.T) { 128 + // "+all" appearing as a substring (e.g. in a domain) should NOT trigger rejection 129 + r := goodResolver("example.com", []string{"sel1"}) 130 + r.txt["example.com"] = []string{"v=spf1 include:+allmail.example.com ~all"} 131 + v := NewVerifier(r) 132 + 133 + result := v.Verify(context.Background(), "example.com", []string{"sel1"}) 134 + if !result.SPF { 135 + t.Error("SPF should pass — +all is a substring, not a standalone mechanism") 136 + } 137 + } 138 + 139 + func TestVerifyDKIMEmptySelectors(t *testing.T) { 140 + r := goodResolver("example.com", []string{}) 141 + v := NewVerifier(r) 142 + 143 + result := v.Verify(context.Background(), "example.com", []string{}) 144 + if result.DKIM { 145 + t.Error("DKIM should fail with empty selectors") 146 + } 147 + } 148 + 149 + func TestVerifyNoDMARC(t *testing.T) { 150 + r := goodResolver("example.com", []string{"sel1"}) 151 + delete(r.txt, "_dmarc.example.com") 152 + v := NewVerifier(r) 153 + 154 + result := v.Verify(context.Background(), "example.com", []string{"sel1"}) 155 + if result.DMARC { 156 + t.Error("DMARC should fail with no record") 157 + } 158 + }
+65
internal/domain/control.go
··· 1 + package domain 2 + 3 + import ( 4 + "context" 5 + "strings" 6 + ) 7 + 8 + // TXTResolver abstracts DNS TXT lookups for testing. 9 + type TXTResolver interface { 10 + LookupTXT(ctx context.Context, name string) ([]string, error) 11 + } 12 + 13 + // HandleResolver resolves a DID to its atproto handle. 14 + type HandleResolver interface { 15 + ResolveHandle(ctx context.Context, did string) (string, error) 16 + } 17 + 18 + // Verifier checks whether a DID controls a given domain. 19 + type Verifier struct { 20 + handles HandleResolver 21 + txt TXTResolver 22 + } 23 + 24 + // NewVerifier creates a domain control verifier. 25 + func NewVerifier(handles HandleResolver, txt TXTResolver) *Verifier { 26 + return &Verifier{handles: handles, txt: txt} 27 + } 28 + 29 + // Verify checks if the given DID controls the domain. 30 + // Returns (ok, method, error) where method is "handle" or "dns-txt". 31 + // 32 + // Two verification methods: 33 + // 1. Handle match: the DID's handle exactly equals the domain. 34 + // 2. DNS TXT: a TXT record at _atproto.<domain> contains "did=<did>". 35 + func (v *Verifier) Verify(ctx context.Context, did, domain string) (bool, string, error) { 36 + // Try handle match first (most common for PDS operators) 37 + handle, err := v.handles.ResolveHandle(ctx, did) 38 + if err == nil && handle != "" { 39 + if handleMatchesDomain(handle, domain) { 40 + return true, "handle", nil 41 + } 42 + } 43 + 44 + // Fall back to DNS TXT record at _atproto.<domain> 45 + records, err := v.txt.LookupTXT(ctx, "_atproto."+domain) 46 + if err == nil { 47 + for _, txt := range records { 48 + if strings.TrimSpace(txt) == "did="+did { 49 + return true, "dns-txt", nil 50 + } 51 + } 52 + } 53 + 54 + return false, "", nil 55 + } 56 + 57 + // handleMatchesDomain returns true only if handle exactly equals the domain. 58 + // Subdomain handles do NOT prove control of the parent domain — a user with 59 + // handle "foo.example.com" should not be able to claim example.com mail operator. 60 + // Operators with subdomain handles must use the DNS TXT fallback instead. 61 + func handleMatchesDomain(handle, domain string) bool { 62 + handle = strings.ToLower(strings.TrimSuffix(handle, ".")) 63 + domain = strings.ToLower(strings.TrimSuffix(domain, ".")) 64 + return handle == domain 65 + }
+133
internal/domain/control_test.go
··· 1 + package domain 2 + 3 + import ( 4 + "context" 5 + "net" 6 + "testing" 7 + ) 8 + 9 + type mockTXTResolver struct { 10 + txt map[string][]string 11 + } 12 + 13 + func (m *mockTXTResolver) LookupTXT(ctx context.Context, name string) ([]string, error) { 14 + if records, ok := m.txt[name]; ok { 15 + return records, nil 16 + } 17 + return nil, &net.DNSError{Err: "no TXT", Name: name, IsNotFound: true} 18 + } 19 + 20 + type mockHandleResolver struct { 21 + // did -> handle 22 + handles map[string]string 23 + } 24 + 25 + func (m *mockHandleResolver) ResolveHandle(ctx context.Context, did string) (string, error) { 26 + if h, ok := m.handles[did]; ok { 27 + return h, nil 28 + } 29 + return "", &net.DNSError{Err: "not found", IsNotFound: true} 30 + } 31 + 32 + func TestVerifyHandleMatch(t *testing.T) { 33 + v := &Verifier{ 34 + handles: &mockHandleResolver{ 35 + handles: map[string]string{ 36 + "did:plc:abc": "example.com", 37 + }, 38 + }, 39 + txt: &mockTXTResolver{}, 40 + } 41 + 42 + ok, method, err := v.Verify(context.Background(), "did:plc:abc", "example.com") 43 + if err != nil { 44 + t.Fatal(err) 45 + } 46 + if !ok { 47 + t.Error("expected domain control verified") 48 + } 49 + if method != "handle" { 50 + t.Errorf("method = %q, want handle", method) 51 + } 52 + } 53 + 54 + func TestVerifyHandleSubdomainRejected(t *testing.T) { 55 + // Handle is at.example.com, attesting example.com — must NOT match. 56 + // Subdomain handles don't prove parent domain control. 57 + v := &Verifier{ 58 + handles: &mockHandleResolver{ 59 + handles: map[string]string{ 60 + "did:plc:abc": "at.example.com", 61 + }, 62 + }, 63 + txt: &mockTXTResolver{}, 64 + } 65 + 66 + ok, _, err := v.Verify(context.Background(), "did:plc:abc", "example.com") 67 + if err != nil { 68 + t.Fatal(err) 69 + } 70 + if ok { 71 + t.Error("subdomain handle must NOT verify parent domain control") 72 + } 73 + } 74 + 75 + func TestVerifyDNSTXT(t *testing.T) { 76 + v := &Verifier{ 77 + handles: &mockHandleResolver{ 78 + handles: map[string]string{ 79 + "did:plc:abc": "unrelated.com", 80 + }, 81 + }, 82 + txt: &mockTXTResolver{ 83 + txt: map[string][]string{ 84 + "_atproto.example.com": {"did=did:plc:abc"}, 85 + }, 86 + }, 87 + } 88 + 89 + ok, method, err := v.Verify(context.Background(), "did:plc:abc", "example.com") 90 + if err != nil { 91 + t.Fatal(err) 92 + } 93 + if !ok { 94 + t.Error("expected DNS TXT verification to pass") 95 + } 96 + if method != "dns-txt" { 97 + t.Errorf("method = %q, want dns-txt", method) 98 + } 99 + } 100 + 101 + func TestVerifyNoControl(t *testing.T) { 102 + v := &Verifier{ 103 + handles: &mockHandleResolver{ 104 + handles: map[string]string{ 105 + "did:plc:abc": "other.com", 106 + }, 107 + }, 108 + txt: &mockTXTResolver{}, 109 + } 110 + 111 + ok, _, err := v.Verify(context.Background(), "did:plc:abc", "example.com") 112 + if err != nil { 113 + t.Fatal(err) 114 + } 115 + if ok { 116 + t.Error("expected no domain control") 117 + } 118 + } 119 + 120 + func TestVerifyHandleResolutionError(t *testing.T) { 121 + v := &Verifier{ 122 + handles: &mockHandleResolver{handles: map[string]string{}}, 123 + txt: &mockTXTResolver{}, 124 + } 125 + 126 + ok, _, err := v.Verify(context.Background(), "did:plc:unknown", "example.com") 127 + if err != nil { 128 + t.Fatal(err) 129 + } 130 + if ok { 131 + t.Error("expected no control for unknown DID") 132 + } 133 + }
+175
internal/jetstream/consumer.go
··· 1 + package jetstream 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "fmt" 7 + "log" 8 + "sync" 9 + "time" 10 + 11 + "github.com/gorilla/websocket" 12 + ) 13 + 14 + const collection = "email.atmos.attestation" 15 + 16 + // ReceivedAttestation is a parsed attestation event from the firehose. 17 + type ReceivedAttestation struct { 18 + DID string 19 + RKey string // atproto record key 20 + Domain string 21 + DKIMSelectors []string 22 + RelayMember bool 23 + CreatedAt string 24 + Operation string // "create", "update", or "delete" 25 + TimeUS int64 26 + } 27 + 28 + // Handler processes a received attestation event. 29 + type Handler func(ReceivedAttestation) error 30 + 31 + // Consumer connects to Jetstream and filters for attestation events. 32 + type Consumer struct { 33 + url string 34 + handler Handler 35 + 36 + mu sync.Mutex 37 + cursor int64 38 + } 39 + 40 + // NewConsumer creates a Jetstream consumer. 41 + func NewConsumer(url string, handler Handler) *Consumer { 42 + return &Consumer{ 43 + url: url, 44 + handler: handler, 45 + } 46 + } 47 + 48 + // SetCursor sets the replay cursor (microsecond timestamp). 49 + func (c *Consumer) SetCursor(cursor int64) { 50 + c.mu.Lock() 51 + c.cursor = cursor 52 + c.mu.Unlock() 53 + } 54 + 55 + // Cursor returns the last processed event timestamp. 56 + func (c *Consumer) Cursor() int64 { 57 + c.mu.Lock() 58 + defer c.mu.Unlock() 59 + return c.cursor 60 + } 61 + 62 + func (c *Consumer) advanceCursor(timeUS int64) { 63 + if timeUS > 0 { 64 + c.mu.Lock() 65 + c.cursor = timeUS 66 + c.mu.Unlock() 67 + } 68 + } 69 + 70 + // Run connects to Jetstream and processes events until the context is cancelled. 71 + // It reconnects automatically on connection loss. 72 + func (c *Consumer) Run(ctx context.Context) error { 73 + for { 74 + err := c.connect(ctx) 75 + if ctx.Err() != nil { 76 + return ctx.Err() 77 + } 78 + if err != nil { 79 + log.Printf("jetstream connection error: %v, reconnecting in 5s", err) 80 + } 81 + select { 82 + case <-ctx.Done(): 83 + return ctx.Err() 84 + case <-time.After(5 * time.Second): 85 + } 86 + } 87 + } 88 + 89 + func (c *Consumer) connect(ctx context.Context) error { 90 + c.mu.Lock() 91 + cur := c.cursor 92 + c.mu.Unlock() 93 + 94 + url := c.url + "?wantedCollections=" + collection 95 + if cur > 0 { 96 + url += fmt.Sprintf("&cursor=%d", cur) 97 + } 98 + 99 + dialer := websocket.Dialer{ 100 + HandshakeTimeout: 10 * time.Second, 101 + } 102 + conn, _, err := dialer.DialContext(ctx, url, nil) 103 + if err != nil { 104 + return fmt.Errorf("dial: %v", err) 105 + } 106 + defer conn.Close() 107 + 108 + // Close connection when context is done so ReadMessage unblocks. 109 + go func() { 110 + <-ctx.Done() 111 + conn.WriteMessage(websocket.CloseMessage, 112 + websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) 113 + conn.Close() 114 + }() 115 + 116 + log.Printf("connected to jetstream: %s", c.url) 117 + 118 + for { 119 + conn.SetReadDeadline(time.Now().Add(30 * time.Second)) 120 + _, msg, err := conn.ReadMessage() 121 + if err != nil { 122 + return fmt.Errorf("read: %v", err) 123 + } 124 + 125 + var ev Event 126 + if err := json.Unmarshal(msg, &ev); err != nil { 127 + log.Printf("jetstream: unmarshal error: %v", err) 128 + continue 129 + } 130 + 131 + // Non-commit events: advance cursor and skip 132 + if ev.Kind != "commit" || ev.Commit == nil { 133 + c.advanceCursor(ev.TimeUS) 134 + continue 135 + } 136 + // Wrong collection: advance cursor and skip 137 + if ev.Commit.Collection != collection { 138 + c.advanceCursor(ev.TimeUS) 139 + continue 140 + } 141 + 142 + att := ReceivedAttestation{ 143 + DID: ev.DID, 144 + RKey: ev.Commit.RKey, 145 + Operation: ev.Commit.Operation, 146 + TimeUS: ev.TimeUS, 147 + } 148 + 149 + // Parse record for create/update operations 150 + if ev.Commit.Operation != "delete" && ev.Commit.Record != nil { 151 + var record Attestation 152 + if err := json.Unmarshal(ev.Commit.Record, &record); err != nil { 153 + log.Printf("jetstream: unmarshal attestation record: %v", err) 154 + c.advanceCursor(ev.TimeUS) // Bad record, skip permanently 155 + continue 156 + } 157 + // Validate $type if present (atproto records always have $type) 158 + if record.Type != "" && record.Type != collection { 159 + log.Printf("jetstream: unexpected $type %q for %s, skipping", record.Type, ev.DID) 160 + c.advanceCursor(ev.TimeUS) // Wrong type, skip permanently 161 + continue 162 + } 163 + att.Domain = record.Domain 164 + att.DKIMSelectors = record.DKIMSelectors 165 + att.RelayMember = record.RelayMember 166 + att.CreatedAt = record.CreatedAt 167 + } 168 + 169 + if err := c.handler(att); err != nil { 170 + log.Printf("jetstream: handler error for %s/%s: %v (cursor not advanced)", att.DID, att.Domain, err) 171 + continue // Don't advance cursor — event will be retried on reconnect 172 + } 173 + c.advanceCursor(ev.TimeUS) 174 + } 175 + }
+339
internal/jetstream/consumer_test.go
··· 1 + package jetstream 2 + 3 + import ( 4 + "context" 5 + "encoding/json" 6 + "net/http" 7 + "net/http/httptest" 8 + "strings" 9 + "sync" 10 + "testing" 11 + "time" 12 + 13 + "github.com/gorilla/websocket" 14 + ) 15 + 16 + // mockJetstreamServer sends canned events over WebSocket. 17 + func mockJetstreamServer(t *testing.T, events []Event) *httptest.Server { 18 + t.Helper() 19 + upgrader := websocket.Upgrader{} 20 + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 21 + conn, err := upgrader.Upgrade(w, r, nil) 22 + if err != nil { 23 + t.Logf("upgrade: %v", err) 24 + return 25 + } 26 + defer conn.Close() 27 + for _, ev := range events { 28 + data, _ := json.Marshal(ev) 29 + if err := conn.WriteMessage(websocket.TextMessage, data); err != nil { 30 + return 31 + } 32 + } 33 + // Keep connection open until client disconnects 34 + for { 35 + if _, _, err := conn.ReadMessage(); err != nil { 36 + return 37 + } 38 + } 39 + })) 40 + } 41 + 42 + func TestConsumerReceivesAttestations(t *testing.T) { 43 + record, _ := json.Marshal(Attestation{ 44 + Domain: "example.com", 45 + DKIMSelectors: []string{"default"}, 46 + CreatedAt: "2026-03-31T00:00:00Z", 47 + }) 48 + 49 + events := []Event{ 50 + { 51 + DID: "did:plc:test", 52 + TimeUS: 1000, 53 + Kind: "commit", 54 + Commit: &Commit{ 55 + Operation: "create", 56 + Collection: "email.atmos.attestation", 57 + RKey: "example.com", 58 + Record: record, 59 + }, 60 + }, 61 + { 62 + DID: "did:plc:other", 63 + TimeUS: 2000, 64 + Kind: "identity", // Non-commit event, should be ignored 65 + }, 66 + } 67 + 68 + ts := mockJetstreamServer(t, events) 69 + defer ts.Close() 70 + 71 + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/subscribe" 72 + 73 + var mu sync.Mutex 74 + var received []ReceivedAttestation 75 + 76 + handler := func(att ReceivedAttestation) error { 77 + mu.Lock() 78 + defer mu.Unlock() 79 + received = append(received, att) 80 + return nil 81 + } 82 + 83 + c := NewConsumer(wsURL, handler) 84 + 85 + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 86 + defer cancel() 87 + 88 + // Run in goroutine, expect it to process events then context cancels 89 + done := make(chan error, 1) 90 + go func() { 91 + done <- c.Run(ctx) 92 + }() 93 + 94 + // Wait for events to be processed 95 + deadline := time.After(2 * time.Second) 96 + for { 97 + mu.Lock() 98 + n := len(received) 99 + mu.Unlock() 100 + if n >= 1 { 101 + break 102 + } 103 + select { 104 + case <-deadline: 105 + t.Fatal("timed out waiting for events") 106 + default: 107 + time.Sleep(10 * time.Millisecond) 108 + } 109 + } 110 + 111 + cancel() 112 + <-done 113 + 114 + mu.Lock() 115 + defer mu.Unlock() 116 + if len(received) != 1 { 117 + t.Fatalf("got %d attestations, want 1", len(received)) 118 + } 119 + if received[0].DID != "did:plc:test" { 120 + t.Errorf("DID = %q, want did:plc:test", received[0].DID) 121 + } 122 + if received[0].Domain != "example.com" { 123 + t.Errorf("Domain = %q", received[0].Domain) 124 + } 125 + if received[0].Operation != "create" { 126 + t.Errorf("Operation = %q, want create", received[0].Operation) 127 + } 128 + } 129 + 130 + func TestConsumerHandlesDelete(t *testing.T) { 131 + events := []Event{ 132 + { 133 + DID: "did:plc:test", 134 + TimeUS: 3000, 135 + Kind: "commit", 136 + Commit: &Commit{ 137 + Operation: "delete", 138 + Collection: "email.atmos.attestation", 139 + RKey: "example.com", 140 + }, 141 + }, 142 + } 143 + 144 + ts := mockJetstreamServer(t, events) 145 + defer ts.Close() 146 + 147 + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/subscribe" 148 + 149 + var mu sync.Mutex 150 + var received []ReceivedAttestation 151 + 152 + c := NewConsumer(wsURL, func(att ReceivedAttestation) error { 153 + mu.Lock() 154 + defer mu.Unlock() 155 + received = append(received, att) 156 + return nil 157 + }) 158 + 159 + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 160 + defer cancel() 161 + 162 + done := make(chan error, 1) 163 + go func() { done <- c.Run(ctx) }() 164 + 165 + deadline := time.After(2 * time.Second) 166 + for { 167 + mu.Lock() 168 + n := len(received) 169 + mu.Unlock() 170 + if n >= 1 { 171 + break 172 + } 173 + select { 174 + case <-deadline: 175 + t.Fatal("timed out waiting for delete event") 176 + default: 177 + time.Sleep(10 * time.Millisecond) 178 + } 179 + } 180 + 181 + cancel() 182 + <-done 183 + 184 + mu.Lock() 185 + defer mu.Unlock() 186 + if received[0].Operation != "delete" { 187 + t.Errorf("Operation = %q, want delete", received[0].Operation) 188 + } 189 + if received[0].RKey != "example.com" { 190 + t.Errorf("RKey = %q, want example.com", received[0].RKey) 191 + } 192 + } 193 + 194 + func TestConsumerRejectsWrongType(t *testing.T) { 195 + // Record with wrong $type should be skipped 196 + wrongType, _ := json.Marshal(Attestation{ 197 + Type: "com.example.wrong", 198 + Domain: "wrong.com", 199 + DKIMSelectors: []string{"default"}, 200 + CreatedAt: "2026-03-31T00:00:00Z", 201 + }) 202 + // Record with correct $type should be accepted 203 + correctType, _ := json.Marshal(Attestation{ 204 + Type: "email.atmos.attestation", 205 + Domain: "correct.com", 206 + DKIMSelectors: []string{"default"}, 207 + CreatedAt: "2026-03-31T00:00:00Z", 208 + }) 209 + 210 + events := []Event{ 211 + { 212 + DID: "did:plc:wrong", TimeUS: 1000, Kind: "commit", 213 + Commit: &Commit{ 214 + Operation: "create", Collection: "email.atmos.attestation", 215 + RKey: "wrong.com", Record: wrongType, 216 + }, 217 + }, 218 + { 219 + DID: "did:plc:correct", TimeUS: 2000, Kind: "commit", 220 + Commit: &Commit{ 221 + Operation: "create", Collection: "email.atmos.attestation", 222 + RKey: "correct.com", Record: correctType, 223 + }, 224 + }, 225 + } 226 + 227 + ts := mockJetstreamServer(t, events) 228 + defer ts.Close() 229 + 230 + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/subscribe" 231 + 232 + var mu sync.Mutex 233 + var received []ReceivedAttestation 234 + 235 + c := NewConsumer(wsURL, func(att ReceivedAttestation) error { 236 + mu.Lock() 237 + defer mu.Unlock() 238 + received = append(received, att) 239 + return nil 240 + }) 241 + 242 + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 243 + defer cancel() 244 + 245 + done := make(chan error, 1) 246 + go func() { done <- c.Run(ctx) }() 247 + 248 + deadline := time.After(2 * time.Second) 249 + for { 250 + mu.Lock() 251 + n := len(received) 252 + mu.Unlock() 253 + if n >= 1 { 254 + break 255 + } 256 + select { 257 + case <-deadline: 258 + t.Fatal("timed out waiting for events") 259 + default: 260 + time.Sleep(10 * time.Millisecond) 261 + } 262 + } 263 + 264 + cancel() 265 + <-done 266 + 267 + mu.Lock() 268 + defer mu.Unlock() 269 + if len(received) != 1 { 270 + t.Fatalf("got %d attestations, want 1 (wrong $type should be skipped)", len(received)) 271 + } 272 + if received[0].Domain != "correct.com" { 273 + t.Errorf("Domain = %q, want correct.com", received[0].Domain) 274 + } 275 + } 276 + 277 + func TestConsumerPassesRKey(t *testing.T) { 278 + record, _ := json.Marshal(Attestation{ 279 + Domain: "example.com", 280 + DKIMSelectors: []string{"default"}, 281 + CreatedAt: "2026-03-31T00:00:00Z", 282 + }) 283 + 284 + events := []Event{ 285 + { 286 + DID: "did:plc:test", TimeUS: 1000, Kind: "commit", 287 + Commit: &Commit{ 288 + Operation: "create", Collection: "email.atmos.attestation", 289 + RKey: "custom-rkey-value", Record: record, 290 + }, 291 + }, 292 + } 293 + 294 + ts := mockJetstreamServer(t, events) 295 + defer ts.Close() 296 + 297 + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/subscribe" 298 + 299 + var mu sync.Mutex 300 + var received []ReceivedAttestation 301 + 302 + c := NewConsumer(wsURL, func(att ReceivedAttestation) error { 303 + mu.Lock() 304 + defer mu.Unlock() 305 + received = append(received, att) 306 + return nil 307 + }) 308 + 309 + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) 310 + defer cancel() 311 + 312 + done := make(chan error, 1) 313 + go func() { done <- c.Run(ctx) }() 314 + 315 + deadline := time.After(2 * time.Second) 316 + for { 317 + mu.Lock() 318 + n := len(received) 319 + mu.Unlock() 320 + if n >= 1 { 321 + break 322 + } 323 + select { 324 + case <-deadline: 325 + t.Fatal("timed out") 326 + default: 327 + time.Sleep(10 * time.Millisecond) 328 + } 329 + } 330 + 331 + cancel() 332 + <-done 333 + 334 + mu.Lock() 335 + defer mu.Unlock() 336 + if received[0].RKey != "custom-rkey-value" { 337 + t.Errorf("RKey = %q, want custom-rkey-value", received[0].RKey) 338 + } 339 + }
+30
internal/jetstream/types.go
··· 1 + package jetstream 2 + 3 + import "encoding/json" 4 + 5 + // Event is a Jetstream WebSocket event. 6 + type Event struct { 7 + DID string `json:"did"` 8 + TimeUS int64 `json:"time_us"` 9 + Kind string `json:"kind"` 10 + Commit *Commit `json:"commit,omitempty"` 11 + } 12 + 13 + // Commit describes a repository commit event. 14 + type Commit struct { 15 + Rev string `json:"rev"` 16 + Operation string `json:"operation"` 17 + Collection string `json:"collection"` 18 + RKey string `json:"rkey"` 19 + Record json.RawMessage `json:"record,omitempty"` 20 + CID string `json:"cid,omitempty"` 21 + } 22 + 23 + // Attestation is the parsed email.atmos.attestation record. 24 + type Attestation struct { 25 + Type string `json:"$type,omitempty"` 26 + Domain string `json:"domain"` 27 + DKIMSelectors []string `json:"dkimSelectors"` 28 + RelayMember bool `json:"relayMember,omitempty"` 29 + CreatedAt string `json:"createdAt"` 30 + }
+315
internal/label/manager.go
··· 1 + package label 2 + 3 + import ( 4 + "context" 5 + "fmt" 6 + "log" 7 + "sync" 8 + "time" 9 + 10 + "atmosphere-mail/internal/dns" 11 + "atmosphere-mail/internal/store" 12 + ) 13 + 14 + // Compile-time interface checks. 15 + var ( 16 + _ DNSVerifier = (*dns.Verifier)(nil) 17 + ) 18 + 19 + // DNSVerifier checks mail DNS configuration. 20 + type DNSVerifier interface { 21 + Verify(ctx context.Context, domain string, selectors []string) dns.Result 22 + } 23 + 24 + // DomainVerifier checks DID→domain control. 25 + type DomainVerifier interface { 26 + Verify(ctx context.Context, did, domain string) (bool, string, error) 27 + } 28 + 29 + // RateLimiter tracks global label creation rates. 30 + type RateLimiter struct { 31 + mu sync.Mutex 32 + perSec int 33 + perHour int 34 + perDay int 35 + maxSec int 36 + maxHour int 37 + maxDay int 38 + lastSec time.Time 39 + lastHour time.Time 40 + lastDay time.Time 41 + } 42 + 43 + // NewRateLimiter creates a rate limiter with the given maximums. 44 + func NewRateLimiter(perSec, perHour, perDay int) *RateLimiter { 45 + now := time.Now() 46 + return &RateLimiter{ 47 + maxSec: perSec, 48 + maxHour: perHour, 49 + maxDay: perDay, 50 + lastSec: now, 51 + lastHour: now, 52 + lastDay: now, 53 + } 54 + } 55 + 56 + // Allow checks if a label creation is allowed and increments counters. 57 + func (r *RateLimiter) Allow() bool { 58 + r.mu.Lock() 59 + defer r.mu.Unlock() 60 + 61 + now := time.Now() 62 + 63 + if now.Sub(r.lastSec) >= time.Second { 64 + r.perSec = 0 65 + r.lastSec = now 66 + } 67 + if now.Sub(r.lastHour) >= time.Hour { 68 + r.perHour = 0 69 + r.lastHour = now 70 + } 71 + if now.Sub(r.lastDay) >= 24*time.Hour { 72 + r.perDay = 0 73 + r.lastDay = now 74 + } 75 + 76 + if r.perSec >= r.maxSec || r.perHour >= r.maxHour || r.perDay >= r.maxDay { 77 + return false 78 + } 79 + 80 + r.perSec++ 81 + r.perHour++ 82 + r.perDay++ 83 + return true 84 + } 85 + 86 + // PerDIDRateLimiter combines global rate limits with per-DID limits to prevent 87 + // a single DID from exhausting the global allowance. 88 + type PerDIDRateLimiter struct { 89 + mu sync.Mutex 90 + global *RateLimiter 91 + dids map[string]*didWindow 92 + maxPerMin int 93 + cleanupAt time.Time 94 + } 95 + 96 + type didWindow struct { 97 + count int 98 + windowStart time.Time 99 + } 100 + 101 + // NewPerDIDRateLimiter creates a combined limiter. 102 + // globalPerSec/Hr/Day control total throughput; perDIDPerMin limits each DID. 103 + func NewPerDIDRateLimiter(globalPerSec, globalPerHour, globalPerDay, perDIDPerMin int) *PerDIDRateLimiter { 104 + return &PerDIDRateLimiter{ 105 + global: NewRateLimiter(globalPerSec, globalPerHour, globalPerDay), 106 + dids: make(map[string]*didWindow), 107 + maxPerMin: perDIDPerMin, 108 + cleanupAt: time.Now().Add(10 * time.Minute), 109 + } 110 + } 111 + 112 + // Allow checks both global and per-DID limits. Returns ("", true) on success 113 + // or (reason, false) on rejection. 114 + // 115 + // Global is checked first to avoid burning per-DID tokens when the global 116 + // limit is exhausted — a per-DID rejection wastes at most one global token 117 + // (which resets every second), but the reverse would lock out legitimate DIDs 118 + // for a full minute under global saturation. 119 + func (p *PerDIDRateLimiter) Allow(did string) (string, bool) { 120 + // Check global first 121 + if !p.global.Allow() { 122 + return "global rate limit", false 123 + } 124 + 125 + p.mu.Lock() 126 + now := time.Now() 127 + 128 + // Periodic cleanup of stale DID entries (every 10 min) 129 + if now.After(p.cleanupAt) { 130 + cutoff := now.Add(-2 * time.Minute) 131 + for d, w := range p.dids { 132 + if w.windowStart.Before(cutoff) { 133 + delete(p.dids, d) 134 + } 135 + } 136 + p.cleanupAt = now.Add(10 * time.Minute) 137 + } 138 + 139 + w, ok := p.dids[did] 140 + if !ok { 141 + w = &didWindow{windowStart: now} 142 + p.dids[did] = w 143 + } 144 + 145 + if now.Sub(w.windowStart) >= time.Minute { 146 + w.count = 0 147 + w.windowStart = now 148 + } 149 + 150 + if w.count >= p.maxPerMin { 151 + p.mu.Unlock() 152 + return "per-DID rate limit", false 153 + } 154 + 155 + w.count++ 156 + p.mu.Unlock() 157 + 158 + return "", true 159 + } 160 + 161 + // Manager orchestrates verification and label creation/negation. 162 + type Manager struct { 163 + signer *Signer 164 + store *store.Store 165 + dns DNSVerifier 166 + domain DomainVerifier 167 + limiter *PerDIDRateLimiter 168 + } 169 + 170 + // NewManager creates a label manager with rate limiting. 171 + // Global: 5/sec, 5000/hr, 50000/day. Per-DID: 10/min. 172 + func NewManager(signer *Signer, store *store.Store, dns DNSVerifier, domain DomainVerifier) *Manager { 173 + return &Manager{ 174 + signer: signer, 175 + store: store, 176 + dns: dns, 177 + domain: domain, 178 + limiter: NewPerDIDRateLimiter(5, 5000, 50000, 10), 179 + } 180 + } 181 + 182 + // ProcessAttestation verifies a single attestation's domain control and DNS, 183 + // updates its verified status, then reconciles all labels for the DID based 184 + // on the full set of verified attestations. 185 + func (m *Manager) ProcessAttestation(ctx context.Context, att *store.Attestation) error { 186 + // Validate inputs 187 + if err := ValidateAttestation(att.DID, att.Domain, att.DKIMSelectors); err != nil { 188 + log.Printf("invalid attestation from %s: %v", att.DID, err) 189 + return nil // Drop invalid attestations silently 190 + } 191 + 192 + // Check domain control 193 + domainOK, method, err := m.domain.Verify(ctx, att.DID, att.Domain) 194 + if err != nil { 195 + return err 196 + } 197 + 198 + if !domainOK { 199 + log.Printf("domain control failed for %s on %s", att.DID, att.Domain) 200 + if err := m.store.SetVerified(ctx, att.DID, att.Domain, false); err != nil { 201 + return err 202 + } 203 + return m.ReconcileLabels(ctx, att.DID) 204 + } 205 + log.Printf("domain control verified for %s on %s (method: %s)", att.DID, att.Domain, method) 206 + 207 + // Check DNS 208 + dnsResult := m.dns.Verify(ctx, att.Domain, att.DKIMSelectors) 209 + if !dnsResult.Pass() { 210 + log.Printf("DNS verification failed for %s: %v", att.Domain, dnsResult.Failures) 211 + if err := m.store.SetVerified(ctx, att.DID, att.Domain, false); err != nil { 212 + return err 213 + } 214 + return m.ReconcileLabels(ctx, att.DID) 215 + } 216 + 217 + // Mark verified 218 + if err := m.store.SetVerified(ctx, att.DID, att.Domain, true); err != nil { 219 + return err 220 + } 221 + 222 + return m.ReconcileLabels(ctx, att.DID) 223 + } 224 + 225 + // ReconcileLabels computes the desired label set from ALL verified attestations 226 + // for a DID, then creates missing labels and negates excess ones. 227 + // 228 + // This handles multi-domain correctly: if a DID has domains A (verified) and B 229 + // (failed), the label stays active because A still supports it. Labels are only 230 + // negated when NO attestation supports them. 231 + func (m *Manager) ReconcileLabels(ctx context.Context, did string) error { 232 + // Get all attestations for this DID to compute desired labels 233 + atts, err := m.store.GetAttestationsForDID(ctx, did) 234 + if err != nil { 235 + return err 236 + } 237 + 238 + wantVerified := false 239 + wantRelay := false 240 + for _, a := range atts { 241 + if a.Verified { 242 + wantVerified = true 243 + if a.RelayMember { 244 + wantRelay = true 245 + } 246 + } 247 + } 248 + 249 + desired := map[string]bool{} 250 + if wantVerified { 251 + desired["verified-mail-operator"] = true 252 + } 253 + if wantRelay { 254 + desired["relay-member"] = true 255 + } 256 + 257 + // Get current active labels 258 + active, err := m.store.GetActiveLabelsForDID(ctx, did) 259 + if err != nil { 260 + return err 261 + } 262 + activeVals := map[string]bool{} 263 + for _, l := range active { 264 + activeVals[l.Val] = true 265 + } 266 + 267 + now := time.Now().UTC().Format(time.RFC3339) 268 + 269 + // Create missing labels 270 + for val := range desired { 271 + if activeVals[val] { 272 + continue 273 + } 274 + if reason, ok := m.limiter.Allow(did); !ok { 275 + return fmt.Errorf("%s exceeded, dropping label %q for %s", reason, val, did) 276 + } 277 + signed, err := m.signer.SignLabel(m.signer.DID(), did, val, now, false) 278 + if err != nil { 279 + return err 280 + } 281 + if _, err := m.store.InsertLabel(ctx, signedToStoreLabel(signed)); err != nil { 282 + return err 283 + } 284 + log.Printf("applied label %q to %s", val, did) 285 + } 286 + 287 + // Negate labels that are no longer desired 288 + for _, l := range active { 289 + if desired[l.Val] { 290 + continue 291 + } 292 + signed, err := m.signer.SignLabel(m.signer.DID(), l.URI, l.Val, now, true) 293 + if err != nil { 294 + return err 295 + } 296 + if _, err := m.store.InsertLabel(ctx, signedToStoreLabel(signed)); err != nil { 297 + return err 298 + } 299 + log.Printf("negated label %q on %s", l.Val, did) 300 + } 301 + 302 + return nil 303 + } 304 + 305 + func signedToStoreLabel(s *SignedLabel) *store.Label { 306 + return &store.Label{ 307 + Src: s.Src, 308 + URI: s.URI, 309 + Val: s.Val, 310 + Cts: s.Cts, 311 + Neg: s.Neg, 312 + Sig: s.Sig, 313 + RawCBOR: s.RawCBOR, 314 + } 315 + }
+485
internal/label/manager_test.go
··· 1 + package label 2 + 3 + import ( 4 + "context" 5 + "path/filepath" 6 + "strings" 7 + "testing" 8 + "time" 9 + 10 + "atmosphere-mail/internal/dns" 11 + "atmosphere-mail/internal/store" 12 + ) 13 + 14 + // mockDNSVerifier returns a fixed Result. 15 + type mockDNSVerifier struct { 16 + result dns.Result 17 + } 18 + 19 + func (m *mockDNSVerifier) Verify(ctx context.Context, domain string, selectors []string) dns.Result { 20 + return m.result 21 + } 22 + 23 + // mockDomainVerifier returns a fixed domain control result. 24 + type mockDomainVerifier struct { 25 + ok bool 26 + method string 27 + } 28 + 29 + func (m *mockDomainVerifier) Verify(ctx context.Context, did, domain string) (bool, string, error) { 30 + return m.ok, m.method, nil 31 + } 32 + 33 + func passAllDNS() *mockDNSVerifier { 34 + return &mockDNSVerifier{result: dns.Result{MX: true, SPF: true, DKIM: true, DMARC: true}} 35 + } 36 + 37 + func passAllDomain() *mockDomainVerifier { 38 + return &mockDomainVerifier{ok: true, method: "handle"} 39 + } 40 + 41 + func testManager(t *testing.T) (*Manager, *store.Store) { 42 + t.Helper() 43 + s, err := store.New(":memory:") 44 + if err != nil { 45 + t.Fatal(err) 46 + } 47 + t.Cleanup(func() { s.Close() }) 48 + 49 + keyPath := filepath.Join(t.TempDir(), "signing.key") 50 + if err := GenerateKey(keyPath); err != nil { 51 + t.Fatal(err) 52 + } 53 + signer, err := NewSigner(keyPath) 54 + if err != nil { 55 + t.Fatal(err) 56 + } 57 + 58 + m := NewManager(signer, s, passAllDNS(), passAllDomain()) 59 + return m, s 60 + } 61 + 62 + func TestProcessAttestationSuccess(t *testing.T) { 63 + m, s := testManager(t) 64 + ctx := context.Background() 65 + 66 + att := &store.Attestation{ 67 + DID: "did:plc:test2345test2345test2345", 68 + Domain: "example.com", 69 + DKIMSelectors: []string{"default"}, 70 + RelayMember: false, 71 + CreatedAt: time.Now().UTC(), 72 + } 73 + if err := s.UpsertAttestation(ctx, att); err != nil { 74 + t.Fatal(err) 75 + } 76 + 77 + if err := m.ProcessAttestation(ctx, att); err != nil { 78 + t.Fatalf("ProcessAttestation: %v", err) 79 + } 80 + 81 + labels, err := s.GetActiveLabelsForDID(ctx, "did:plc:test2345test2345test2345") 82 + if err != nil { 83 + t.Fatal(err) 84 + } 85 + if len(labels) != 1 { 86 + t.Fatalf("got %d active labels, want 1", len(labels)) 87 + } 88 + if labels[0].Val != "verified-mail-operator" { 89 + t.Errorf("label val = %q, want verified-mail-operator", labels[0].Val) 90 + } 91 + } 92 + 93 + func TestProcessAttestationWithRelay(t *testing.T) { 94 + m, s := testManager(t) 95 + ctx := context.Background() 96 + 97 + att := &store.Attestation{ 98 + DID: "did:plc:test2345test2345test2345", 99 + Domain: "example.com", 100 + DKIMSelectors: []string{"default"}, 101 + RelayMember: true, 102 + CreatedAt: time.Now().UTC(), 103 + } 104 + if err := s.UpsertAttestation(ctx, att); err != nil { 105 + t.Fatal(err) 106 + } 107 + 108 + if err := m.ProcessAttestation(ctx, att); err != nil { 109 + t.Fatal(err) 110 + } 111 + 112 + labels, err := s.GetActiveLabelsForDID(ctx, "did:plc:test2345test2345test2345") 113 + if err != nil { 114 + t.Fatal(err) 115 + } 116 + if len(labels) != 2 { 117 + t.Fatalf("got %d active labels, want 2 (verified + relay)", len(labels)) 118 + } 119 + 120 + vals := map[string]bool{} 121 + for _, l := range labels { 122 + vals[l.Val] = true 123 + } 124 + if !vals["verified-mail-operator"] { 125 + t.Error("missing verified-mail-operator label") 126 + } 127 + if !vals["relay-member"] { 128 + t.Error("missing relay-member label") 129 + } 130 + } 131 + 132 + func TestProcessAttestationDNSFail(t *testing.T) { 133 + s, err := store.New(":memory:") 134 + if err != nil { 135 + t.Fatal(err) 136 + } 137 + defer s.Close() 138 + 139 + keyPath := filepath.Join(t.TempDir(), "signing.key") 140 + if err := GenerateKey(keyPath); err != nil { 141 + t.Fatal(err) 142 + } 143 + signer, err := NewSigner(keyPath) 144 + if err != nil { 145 + t.Fatal(err) 146 + } 147 + 148 + failDNS := &mockDNSVerifier{result: dns.Result{MX: true, SPF: false, DKIM: true, DMARC: true}} 149 + m := NewManager(signer, s, failDNS, passAllDomain()) 150 + ctx := context.Background() 151 + 152 + att := &store.Attestation{ 153 + DID: "did:plc:test2345test2345test2345", 154 + Domain: "example.com", 155 + DKIMSelectors: []string{"default"}, 156 + CreatedAt: time.Now().UTC(), 157 + } 158 + if err := s.UpsertAttestation(ctx, att); err != nil { 159 + t.Fatal(err) 160 + } 161 + 162 + if err := m.ProcessAttestation(ctx, att); err != nil { 163 + t.Fatal(err) 164 + } 165 + 166 + labels, err := s.GetActiveLabelsForDID(ctx, "did:plc:test2345test2345test2345") 167 + if err != nil { 168 + t.Fatal(err) 169 + } 170 + if len(labels) != 0 { 171 + t.Errorf("got %d active labels, want 0 (DNS failed)", len(labels)) 172 + } 173 + } 174 + 175 + func TestProcessAttestationDomainControlFail(t *testing.T) { 176 + s, err := store.New(":memory:") 177 + if err != nil { 178 + t.Fatal(err) 179 + } 180 + defer s.Close() 181 + 182 + keyPath := filepath.Join(t.TempDir(), "signing.key") 183 + if err := GenerateKey(keyPath); err != nil { 184 + t.Fatal(err) 185 + } 186 + signer, err := NewSigner(keyPath) 187 + if err != nil { 188 + t.Fatal(err) 189 + } 190 + 191 + failDomain := &mockDomainVerifier{ok: false} 192 + m := NewManager(signer, s, passAllDNS(), failDomain) 193 + ctx := context.Background() 194 + 195 + att := &store.Attestation{ 196 + DID: "did:plc:test2345test2345test2345", 197 + Domain: "example.com", 198 + DKIMSelectors: []string{"default"}, 199 + CreatedAt: time.Now().UTC(), 200 + } 201 + if err := s.UpsertAttestation(ctx, att); err != nil { 202 + t.Fatal(err) 203 + } 204 + 205 + if err := m.ProcessAttestation(ctx, att); err != nil { 206 + t.Fatal(err) 207 + } 208 + 209 + labels, err := s.GetActiveLabelsForDID(ctx, "did:plc:test2345test2345test2345") 210 + if err != nil { 211 + t.Fatal(err) 212 + } 213 + if len(labels) != 0 { 214 + t.Errorf("got %d active labels, want 0 (domain control failed)", len(labels)) 215 + } 216 + } 217 + 218 + func TestReconcileLabelsNegatesOnDelete(t *testing.T) { 219 + m, s := testManager(t) 220 + ctx := context.Background() 221 + 222 + att := &store.Attestation{ 223 + DID: "did:plc:test2345test2345test2345", 224 + Domain: "example.com", 225 + DKIMSelectors: []string{"default"}, 226 + RelayMember: true, 227 + CreatedAt: time.Now().UTC(), 228 + } 229 + if err := s.UpsertAttestation(ctx, att); err != nil { 230 + t.Fatal(err) 231 + } 232 + 233 + // Create labels via ProcessAttestation 234 + if err := m.ProcessAttestation(ctx, att); err != nil { 235 + t.Fatal(err) 236 + } 237 + 238 + labels, err := s.GetActiveLabelsForDID(ctx, "did:plc:test2345test2345test2345") 239 + if err != nil { 240 + t.Fatal(err) 241 + } 242 + if len(labels) != 2 { 243 + t.Fatalf("setup: got %d labels, want 2", len(labels)) 244 + } 245 + 246 + // Delete the attestation, then reconcile — labels should be negated 247 + if err := s.DeleteAttestation(ctx, "did:plc:test2345test2345test2345", "example.com"); err != nil { 248 + t.Fatal(err) 249 + } 250 + if err := m.ReconcileLabels(ctx, "did:plc:test2345test2345test2345"); err != nil { 251 + t.Fatal(err) 252 + } 253 + 254 + labels, err = s.GetActiveLabelsForDID(ctx, "did:plc:test2345test2345test2345") 255 + if err != nil { 256 + t.Fatal(err) 257 + } 258 + if len(labels) != 0 { 259 + t.Errorf("got %d active labels after delete+reconcile, want 0", len(labels)) 260 + } 261 + } 262 + 263 + func TestReconcileLabelsMultiDomain(t *testing.T) { 264 + m, s := testManager(t) 265 + ctx := context.Background() 266 + 267 + // DID has two domains, both verified 268 + att1 := &store.Attestation{ 269 + DID: "did:plc:dddd2222eeee3333ffff4444", 270 + Domain: "foo.com", 271 + DKIMSelectors: []string{"default"}, 272 + RelayMember: true, 273 + CreatedAt: time.Now().UTC(), 274 + } 275 + att2 := &store.Attestation{ 276 + DID: "did:plc:dddd2222eeee3333ffff4444", 277 + Domain: "bar.com", 278 + DKIMSelectors: []string{"default"}, 279 + RelayMember: false, 280 + CreatedAt: time.Now().UTC(), 281 + } 282 + if err := s.UpsertAttestation(ctx, att1); err != nil { 283 + t.Fatal(err) 284 + } 285 + if err := s.UpsertAttestation(ctx, att2); err != nil { 286 + t.Fatal(err) 287 + } 288 + 289 + // Process both — should get verified-mail-operator + relay-member 290 + if err := m.ProcessAttestation(ctx, att1); err != nil { 291 + t.Fatal(err) 292 + } 293 + if err := m.ProcessAttestation(ctx, att2); err != nil { 294 + t.Fatal(err) 295 + } 296 + 297 + labels, err := s.GetActiveLabelsForDID(ctx, "did:plc:dddd2222eeee3333ffff4444") 298 + if err != nil { 299 + t.Fatal(err) 300 + } 301 + if len(labels) != 2 { 302 + t.Fatalf("setup: got %d labels, want 2", len(labels)) 303 + } 304 + 305 + // Delete foo.com (the relay-member domain). verified-mail-operator should 306 + // stay because bar.com is still verified. relay-member should be negated. 307 + if err := s.DeleteAttestation(ctx, "did:plc:dddd2222eeee3333ffff4444", "foo.com"); err != nil { 308 + t.Fatal(err) 309 + } 310 + if err := m.ReconcileLabels(ctx, "did:plc:dddd2222eeee3333ffff4444"); err != nil { 311 + t.Fatal(err) 312 + } 313 + 314 + labels, err = s.GetActiveLabelsForDID(ctx, "did:plc:dddd2222eeee3333ffff4444") 315 + if err != nil { 316 + t.Fatal(err) 317 + } 318 + if len(labels) != 1 { 319 + t.Fatalf("got %d active labels, want 1 (verified-mail-operator only)", len(labels)) 320 + } 321 + if labels[0].Val != "verified-mail-operator" { 322 + t.Errorf("remaining label = %q, want verified-mail-operator", labels[0].Val) 323 + } 324 + } 325 + 326 + func TestGlobalRateLimiterRejectsAtBoundary(t *testing.T) { 327 + limiter := NewRateLimiter(2, 100, 1000) // 2 per second 328 + 329 + if !limiter.Allow() { 330 + t.Error("first call should be allowed") 331 + } 332 + if !limiter.Allow() { 333 + t.Error("second call should be allowed") 334 + } 335 + if limiter.Allow() { 336 + t.Error("third call should be rejected (limit=2/sec)") 337 + } 338 + } 339 + 340 + func TestGlobalRateLimiterResetsAfterWindow(t *testing.T) { 341 + limiter := NewRateLimiter(1, 100, 1000) 342 + 343 + if !limiter.Allow() { 344 + t.Fatal("first call should be allowed") 345 + } 346 + if limiter.Allow() { 347 + t.Fatal("second call should be rejected") 348 + } 349 + 350 + // Simulate time passing by resetting the window 351 + limiter.mu.Lock() 352 + limiter.lastSec = time.Now().Add(-2 * time.Second) 353 + limiter.mu.Unlock() 354 + 355 + if !limiter.Allow() { 356 + t.Error("should be allowed after window reset") 357 + } 358 + } 359 + 360 + func TestPerDIDRateLimiterIsolatesDIDs(t *testing.T) { 361 + // 100/sec global (won't trigger), 2/min per-DID 362 + limiter := NewPerDIDRateLimiter(100, 10000, 100000, 2) 363 + 364 + // DID A gets 2 allowed 365 + if _, ok := limiter.Allow("did:plc:a"); !ok { 366 + t.Error("A first call should be allowed") 367 + } 368 + if _, ok := limiter.Allow("did:plc:a"); !ok { 369 + t.Error("A second call should be allowed") 370 + } 371 + // DID A is now exhausted 372 + if reason, ok := limiter.Allow("did:plc:a"); ok { 373 + t.Error("A third call should be rejected") 374 + } else if reason != "per-DID rate limit" { 375 + t.Errorf("reason = %q, want per-DID rate limit", reason) 376 + } 377 + 378 + // DID B should still work — isolated from A 379 + if _, ok := limiter.Allow("did:plc:b"); !ok { 380 + t.Error("B first call should be allowed (independent of A)") 381 + } 382 + } 383 + 384 + func TestPerDIDRateLimiterGlobalCap(t *testing.T) { 385 + // 2/sec global, 100/min per-DID (per-DID won't trigger) 386 + limiter := NewPerDIDRateLimiter(2, 10000, 100000, 100) 387 + 388 + if _, ok := limiter.Allow("did:plc:a"); !ok { 389 + t.Error("first call should be allowed") 390 + } 391 + if _, ok := limiter.Allow("did:plc:b"); !ok { 392 + t.Error("second call should be allowed") 393 + } 394 + // Global limit exhausted even though per-DID limits are fine 395 + if reason, ok := limiter.Allow("did:plc:c"); ok { 396 + t.Error("third call should hit global limit") 397 + } else if reason != "global rate limit" { 398 + t.Errorf("reason = %q, want global rate limit", reason) 399 + } 400 + } 401 + 402 + func TestProcessAttestationDropsInvalid(t *testing.T) { 403 + m, s := testManager(t) 404 + ctx := context.Background() 405 + 406 + // Attestation with empty DID — should be silently dropped 407 + att := &store.Attestation{ 408 + DID: "", 409 + Domain: "example.com", 410 + DKIMSelectors: []string{"default"}, 411 + CreatedAt: time.Now().UTC(), 412 + } 413 + if err := s.UpsertAttestation(ctx, att); err != nil { 414 + t.Fatal(err) 415 + } 416 + 417 + err := m.ProcessAttestation(ctx, att) 418 + if err != nil { 419 + t.Fatalf("expected nil error for invalid attestation, got %v", err) 420 + } 421 + 422 + // No labels should be created 423 + labels, err := s.GetActiveLabelsForDID(ctx, "") 424 + if err != nil { 425 + t.Fatal(err) 426 + } 427 + if len(labels) != 0 { 428 + t.Errorf("got %d labels for invalid attestation, want 0", len(labels)) 429 + } 430 + } 431 + 432 + func TestProcessAttestationRateLimitRejectsLabel(t *testing.T) { 433 + s, err := store.New(":memory:") 434 + if err != nil { 435 + t.Fatal(err) 436 + } 437 + defer s.Close() 438 + 439 + keyPath := filepath.Join(t.TempDir(), "signing.key") 440 + if err := GenerateKey(keyPath); err != nil { 441 + t.Fatal(err) 442 + } 443 + signer, err := NewSigner(keyPath) 444 + if err != nil { 445 + t.Fatal(err) 446 + } 447 + 448 + m := NewManager(signer, s, passAllDNS(), passAllDomain()) 449 + ctx := context.Background() 450 + 451 + // Exhaust the rate limiter (1/sec global, 1/min per-DID) 452 + m.limiter = NewPerDIDRateLimiter(1, 100, 1000, 1) 453 + 454 + att1 := &store.Attestation{ 455 + DID: "did:plc:aaaa2222bbbb3333cccc4444", 456 + Domain: "first.com", 457 + DKIMSelectors: []string{"default"}, 458 + CreatedAt: time.Now().UTC(), 459 + } 460 + if err := s.UpsertAttestation(ctx, att1); err != nil { 461 + t.Fatal(err) 462 + } 463 + // This consumes the 1/sec allowance 464 + if err := m.ProcessAttestation(ctx, att1); err != nil { 465 + t.Fatal(err) 466 + } 467 + 468 + // Second attestation should hit rate limit 469 + att2 := &store.Attestation{ 470 + DID: "did:plc:xxxx5555yyyy6666zzzz7777", 471 + Domain: "second.com", 472 + DKIMSelectors: []string{"default"}, 473 + CreatedAt: time.Now().UTC(), 474 + } 475 + if err := s.UpsertAttestation(ctx, att2); err != nil { 476 + t.Fatal(err) 477 + } 478 + err = m.ProcessAttestation(ctx, att2) 479 + if err == nil { 480 + t.Fatal("expected rate limit error, got nil") 481 + } 482 + if !strings.Contains(err.Error(), "rate limit") { 483 + t.Errorf("expected rate limit error, got: %v", err) 484 + } 485 + }
+166
internal/label/signer.go
··· 1 + package label 2 + 3 + import ( 4 + "crypto" 5 + "crypto/sha256" 6 + "encoding/asn1" 7 + "encoding/hex" 8 + "fmt" 9 + "math/big" 10 + "os" 11 + "strings" 12 + 13 + "github.com/fxamacker/cbor/v2" 14 + "github.com/mr-tron/base58" 15 + "gitlab.com/yawning/secp256k1-voi/secec" 16 + ) 17 + 18 + // secp256k1 curve order and half-order for low-S normalization. 19 + var ( 20 + secp256k1N, _ = new(big.Int).SetString("FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", 16) 21 + secp256k1HalfN = new(big.Int).Rsh(secp256k1N, 1) 22 + ) 23 + 24 + // SignedLabel is the output of label signing, ready for storage. 25 + type SignedLabel struct { 26 + Src string 27 + URI string 28 + Val string 29 + Cts string 30 + Neg bool 31 + Sig []byte 32 + RawCBOR []byte 33 + } 34 + 35 + // Signer creates and signs atproto labels with a secp256k1 key. 36 + type Signer struct { 37 + key *secec.PrivateKey 38 + did string 39 + enc cbor.EncMode 40 + } 41 + 42 + // unsignedLabel is the CBOR structure for signing. 43 + // CoreDetEncOptions sorts map keys by encoded length then lexicographic, 44 + // which matches DAG-CBOR ordering for string keys. 45 + type unsignedLabel struct { 46 + Cts string `cbor:"cts"` 47 + Neg bool `cbor:"neg,omitempty"` 48 + Src string `cbor:"src"` 49 + Uri string `cbor:"uri"` 50 + Val string `cbor:"val"` 51 + Ver int64 `cbor:"ver"` 52 + } 53 + 54 + // GenerateKey creates a new secp256k1 private key and writes it to path as hex. 55 + func GenerateKey(path string) error { 56 + key, err := secec.GenerateKey() 57 + if err != nil { 58 + return fmt.Errorf("generate key: %v", err) 59 + } 60 + encoded := hex.EncodeToString(key.Bytes()) 61 + return os.WriteFile(path, []byte(encoded+"\n"), 0600) 62 + } 63 + 64 + // NewSigner loads a secp256k1 private key from a hex-encoded file. 65 + // The key file must have permissions no more permissive than 0600. 66 + func NewSigner(keyPath string) (*Signer, error) { 67 + info, err := os.Stat(keyPath) 68 + if err != nil { 69 + return nil, fmt.Errorf("stat key: %v", err) 70 + } 71 + if mode := info.Mode().Perm(); mode&0077 != 0 { 72 + return nil, fmt.Errorf("signing key %s has unsafe permissions %o (want 0600, got %o)", keyPath, mode, mode) 73 + } 74 + 75 + data, err := os.ReadFile(keyPath) 76 + if err != nil { 77 + return nil, fmt.Errorf("read key: %v", err) 78 + } 79 + keyBytes, err := hex.DecodeString(strings.TrimSpace(string(data))) 80 + if err != nil { 81 + return nil, fmt.Errorf("decode key hex: %v", err) 82 + } 83 + key, err := secec.NewPrivateKey(keyBytes) 84 + if err != nil { 85 + return nil, fmt.Errorf("parse key: %v", err) 86 + } 87 + 88 + // did:key from compressed public key with secp256k1-pub multicodec prefix (0xe7, 0x01) 89 + compressed := key.PublicKey().CompressedBytes() 90 + multicodec := append([]byte{0xe7, 0x01}, compressed...) 91 + did := "did:key:z" + base58.Encode(multicodec) 92 + 93 + enc, err := cbor.CoreDetEncOptions().EncMode() 94 + if err != nil { 95 + return nil, fmt.Errorf("cbor enc mode: %v", err) 96 + } 97 + 98 + return &Signer{key: key, did: did, enc: enc}, nil 99 + } 100 + 101 + // DID returns the labeler's did:key derived from the signing key. 102 + func (s *Signer) DID() string { 103 + return s.did 104 + } 105 + 106 + // SignLabel creates a signed atproto label. 107 + // The label is CBOR-encoded, SHA-256 hashed, and signed with secp256k1. 108 + // The signature is 64 bytes in compact [R || S] format with low-S normalization. 109 + func (s *Signer) SignLabel(src, uri, val, cts string, neg bool) (*SignedLabel, error) { 110 + ul := unsignedLabel{ 111 + Src: src, 112 + Uri: uri, 113 + Val: val, 114 + Cts: cts, 115 + Ver: 1, 116 + } 117 + if neg { 118 + ul.Neg = true 119 + } 120 + 121 + raw, err := s.enc.Marshal(ul) 122 + if err != nil { 123 + return nil, fmt.Errorf("cbor encode: %v", err) 124 + } 125 + 126 + hash := sha256.Sum256(raw) 127 + derSig, err := s.key.Sign(nil, hash[:], crypto.SHA256) 128 + if err != nil { 129 + return nil, fmt.Errorf("sign: %v", err) 130 + } 131 + 132 + compact, err := derToCompactLowS(derSig) 133 + if err != nil { 134 + return nil, fmt.Errorf("compact sig: %v", err) 135 + } 136 + 137 + return &SignedLabel{ 138 + Src: src, 139 + URI: uri, 140 + Val: val, 141 + Cts: cts, 142 + Neg: neg, 143 + Sig: compact, 144 + RawCBOR: raw, 145 + }, nil 146 + } 147 + 148 + // derToCompactLowS converts an ASN.1 DER ECDSA signature to 64-byte compact 149 + // [R || S] format with low-S normalization per atproto spec. 150 + // If S > N/2, it is replaced with N - S. 151 + func derToCompactLowS(der []byte) ([]byte, error) { 152 + var sig struct{ R, S *big.Int } 153 + if _, err := asn1.Unmarshal(der, &sig); err != nil { 154 + return nil, fmt.Errorf("unmarshal DER: %v", err) 155 + } 156 + 157 + // Low-S normalization: if S > N/2, replace with N - S 158 + if sig.S.Cmp(secp256k1HalfN) > 0 { 159 + sig.S.Sub(secp256k1N, sig.S) 160 + } 161 + 162 + compact := make([]byte, 64) 163 + sig.R.FillBytes(compact[:32]) 164 + sig.S.FillBytes(compact[32:]) 165 + return compact, nil 166 + }
+235
internal/label/signer_test.go
··· 1 + package label 2 + 3 + import ( 4 + "bytes" 5 + "math/big" 6 + "os" 7 + "path/filepath" 8 + "strings" 9 + "testing" 10 + "time" 11 + 12 + "github.com/fxamacker/cbor/v2" 13 + ) 14 + 15 + func testSigner(t *testing.T) *Signer { 16 + t.Helper() 17 + keyPath := filepath.Join(t.TempDir(), "signing.key") 18 + if err := GenerateKey(keyPath); err != nil { 19 + t.Fatal(err) 20 + } 21 + s, err := NewSigner(keyPath) 22 + if err != nil { 23 + t.Fatal(err) 24 + } 25 + return s 26 + } 27 + 28 + func TestGenerateAndLoadKey(t *testing.T) { 29 + dir := t.TempDir() 30 + keyPath := filepath.Join(dir, "signing.key") 31 + 32 + if err := GenerateKey(keyPath); err != nil { 33 + t.Fatalf("GenerateKey: %v", err) 34 + } 35 + 36 + if _, err := os.Stat(keyPath); err != nil { 37 + t.Fatalf("key file not created: %v", err) 38 + } 39 + 40 + s, err := NewSigner(keyPath) 41 + if err != nil { 42 + t.Fatalf("NewSigner: %v", err) 43 + } 44 + 45 + did := s.DID() 46 + if !strings.HasPrefix(did, "did:key:z") { 47 + t.Errorf("DID = %q, want did:key:z... prefix", did) 48 + } 49 + } 50 + 51 + func TestSignLabel(t *testing.T) { 52 + s := testSigner(t) 53 + 54 + now := time.Now().UTC().Format(time.RFC3339) 55 + signed, err := s.SignLabel(s.DID(), "did:plc:test", "verified-mail-operator", now, false) 56 + if err != nil { 57 + t.Fatalf("SignLabel: %v", err) 58 + } 59 + 60 + if signed.Src != s.DID() { 61 + t.Errorf("Src = %q, want %q", signed.Src, s.DID()) 62 + } 63 + if signed.URI != "did:plc:test" { 64 + t.Errorf("URI = %q", signed.URI) 65 + } 66 + if signed.Val != "verified-mail-operator" { 67 + t.Errorf("Val = %q", signed.Val) 68 + } 69 + if len(signed.Sig) != 64 { 70 + t.Errorf("Sig length = %d, want 64", len(signed.Sig)) 71 + } 72 + if len(signed.RawCBOR) == 0 { 73 + t.Error("RawCBOR is empty") 74 + } 75 + if signed.Neg { 76 + t.Error("Neg should be false") 77 + } 78 + } 79 + 80 + func TestSignLabelLowS(t *testing.T) { 81 + s := testSigner(t) 82 + 83 + // Sign many labels to increase chance of hitting a high-S signature pre-normalization. 84 + for i := 0; i < 100; i++ { 85 + now := time.Now().UTC().Format(time.RFC3339) 86 + signed, err := s.SignLabel(s.DID(), "did:plc:test", "verified-mail-operator", now, false) 87 + if err != nil { 88 + t.Fatalf("SignLabel iteration %d: %v", i, err) 89 + } 90 + 91 + sValue := new(big.Int).SetBytes(signed.Sig[32:]) 92 + if sValue.Cmp(secp256k1HalfN) > 0 { 93 + t.Fatalf("iteration %d: S > N/2 (low-S normalization failed)", i) 94 + } 95 + } 96 + } 97 + 98 + func TestSignLabelNegation(t *testing.T) { 99 + s := testSigner(t) 100 + 101 + now := time.Now().UTC().Format(time.RFC3339) 102 + signed, err := s.SignLabel(s.DID(), "did:plc:test", "verified-mail-operator", now, true) 103 + if err != nil { 104 + t.Fatalf("SignLabel (neg): %v", err) 105 + } 106 + 107 + if !signed.Neg { 108 + t.Error("Neg should be true for negation label") 109 + } 110 + } 111 + 112 + func TestNewSignerRejectsUnsafePermissions(t *testing.T) { 113 + dir := t.TempDir() 114 + keyPath := filepath.Join(dir, "signing.key") 115 + if err := GenerateKey(keyPath); err != nil { 116 + t.Fatal(err) 117 + } 118 + 119 + // Make key world-readable 120 + if err := os.Chmod(keyPath, 0644); err != nil { 121 + t.Fatal(err) 122 + } 123 + 124 + _, err := NewSigner(keyPath) 125 + if err == nil { 126 + t.Fatal("expected error for world-readable key file") 127 + } 128 + if !strings.Contains(err.Error(), "unsafe permissions") { 129 + t.Errorf("error = %q, want mention of unsafe permissions", err) 130 + } 131 + } 132 + 133 + func TestNewSignerAccepts0600(t *testing.T) { 134 + dir := t.TempDir() 135 + keyPath := filepath.Join(dir, "signing.key") 136 + if err := GenerateKey(keyPath); err != nil { 137 + t.Fatal(err) 138 + } 139 + 140 + // GenerateKey already writes 0600, but be explicit 141 + if err := os.Chmod(keyPath, 0600); err != nil { 142 + t.Fatal(err) 143 + } 144 + 145 + s, err := NewSigner(keyPath) 146 + if err != nil { 147 + t.Fatalf("NewSigner with 0600: %v", err) 148 + } 149 + if !strings.HasPrefix(s.DID(), "did:key:z") { 150 + t.Errorf("DID = %q", s.DID()) 151 + } 152 + } 153 + 154 + func TestCBOREncodingGolden(t *testing.T) { 155 + s := testSigner(t) 156 + cts := "2024-01-01T00:00:00Z" 157 + 158 + // Same inputs must produce identical CBOR (deterministic encoding) 159 + signed1, err := s.SignLabel(s.DID(), "did:plc:subject", "verified-mail-operator", cts, false) 160 + if err != nil { 161 + t.Fatal(err) 162 + } 163 + signed2, err := s.SignLabel(s.DID(), "did:plc:subject", "verified-mail-operator", cts, false) 164 + if err != nil { 165 + t.Fatal(err) 166 + } 167 + if !bytes.Equal(signed1.RawCBOR, signed2.RawCBOR) { 168 + t.Errorf("CBOR not deterministic:\n got1: %x\n got2: %x", signed1.RawCBOR, signed2.RawCBOR) 169 + } 170 + 171 + // Verify neg=false is omitted from CBOR (atproto convention) 172 + var decoded map[string]any 173 + if err := cbor.Unmarshal(signed1.RawCBOR, &decoded); err != nil { 174 + t.Fatal(err) 175 + } 176 + if _, ok := decoded["neg"]; ok { 177 + t.Error("neg=false should be omitted from CBOR encoding") 178 + } 179 + if _, ok := decoded["ver"]; !ok { 180 + t.Error("ver should always be present in CBOR encoding") 181 + } 182 + if ver, ok := decoded["ver"].(uint64); !ok || ver != 1 { 183 + t.Errorf("ver = %v, want 1", decoded["ver"]) 184 + } 185 + 186 + // Verify neg=true IS included 187 + signedNeg, err := s.SignLabel(s.DID(), "did:plc:subject", "verified-mail-operator", cts, true) 188 + if err != nil { 189 + t.Fatal(err) 190 + } 191 + var decodedNeg map[string]any 192 + if err := cbor.Unmarshal(signedNeg.RawCBOR, &decodedNeg); err != nil { 193 + t.Fatal(err) 194 + } 195 + if _, ok := decodedNeg["neg"]; !ok { 196 + t.Error("neg=true should be present in CBOR encoding") 197 + } 198 + 199 + // Verify DAG-CBOR key ordering: alphabetical for same-length 3-char keys 200 + // Expected order: cts, src, uri, val, ver (neg omitted when false) 201 + raw := signed1.RawCBOR 202 + ctsIdx := bytes.Index(raw, []byte("cts")) 203 + srcIdx := bytes.Index(raw, []byte("src")) 204 + uriIdx := bytes.Index(raw, []byte("uri")) 205 + valIdx := bytes.Index(raw, []byte("val")) 206 + verIdx := bytes.Index(raw, []byte("ver")) 207 + if ctsIdx < 0 || srcIdx < 0 || uriIdx < 0 || valIdx < 0 || verIdx < 0 { 208 + t.Fatalf("missing key in CBOR: cts@%d src@%d uri@%d val@%d ver@%d", ctsIdx, srcIdx, uriIdx, valIdx, verIdx) 209 + } 210 + if !(ctsIdx < srcIdx && srcIdx < uriIdx && uriIdx < valIdx && valIdx < verIdx) { 211 + t.Errorf("CBOR key order wrong (must be alphabetical for DAG-CBOR): cts@%d src@%d uri@%d val@%d ver@%d", 212 + ctsIdx, srcIdx, uriIdx, valIdx, verIdx) 213 + } 214 + } 215 + 216 + func TestDIDDeterministic(t *testing.T) { 217 + dir := t.TempDir() 218 + keyPath := filepath.Join(dir, "signing.key") 219 + if err := GenerateKey(keyPath); err != nil { 220 + t.Fatal(err) 221 + } 222 + 223 + s1, err := NewSigner(keyPath) 224 + if err != nil { 225 + t.Fatal(err) 226 + } 227 + s2, err := NewSigner(keyPath) 228 + if err != nil { 229 + t.Fatal(err) 230 + } 231 + 232 + if s1.DID() != s2.DID() { 233 + t.Errorf("DID not deterministic: %q != %q", s1.DID(), s2.DID()) 234 + } 235 + }
+50
internal/label/validate.go
··· 1 + package label 2 + 3 + import ( 4 + "fmt" 5 + "regexp" 6 + "strings" 7 + ) 8 + 9 + var ( 10 + // did:plc uses base32-lower encoding, always 24 chars after prefix. 11 + didPLCPattern = regexp.MustCompile(`^did:plc:[a-z2-7]{24}$`) 12 + // did:web allows domain chars plus %3A port encoding and : path separators. 13 + didWebPattern = regexp.MustCompile(`^did:web:[a-zA-Z0-9._:%-]+$`) 14 + domainPattern = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`) 15 + selectorPattern = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$`) 16 + ) 17 + 18 + // ValidateAttestation checks that attestation fields are well-formed before processing. 19 + func ValidateAttestation(did, domain string, dkimSelectors []string) error { 20 + if !didPLCPattern.MatchString(did) && !didWebPattern.MatchString(did) { 21 + return fmt.Errorf("invalid DID format: %q", did) 22 + } 23 + 24 + if len(domain) == 0 || len(domain) > 253 { 25 + return fmt.Errorf("domain length out of range: %d", len(domain)) 26 + } 27 + if !domainPattern.MatchString(domain) { 28 + return fmt.Errorf("invalid domain format: %q", domain) 29 + } 30 + 31 + if len(dkimSelectors) == 0 { 32 + return fmt.Errorf("at least one DKIM selector required") 33 + } 34 + if len(dkimSelectors) > 10 { 35 + return fmt.Errorf("too many DKIM selectors: %d (max 10)", len(dkimSelectors)) 36 + } 37 + for _, sel := range dkimSelectors { 38 + if len(sel) == 0 || len(sel) > 63 { 39 + return fmt.Errorf("DKIM selector length out of range: %q", sel) 40 + } 41 + if !selectorPattern.MatchString(sel) { 42 + return fmt.Errorf("invalid DKIM selector: %q", sel) 43 + } 44 + if strings.Contains(sel, "..") { 45 + return fmt.Errorf("invalid DKIM selector: %q", sel) 46 + } 47 + } 48 + 49 + return nil 50 + }
+160
internal/label/validate_test.go
··· 1 + package label 2 + 3 + import "testing" 4 + 5 + func TestValidateAttestation(t *testing.T) { 6 + tests := []struct { 7 + name string 8 + did string 9 + domain string 10 + selectors []string 11 + wantErr bool 12 + }{ 13 + { 14 + name: "valid did:plc", 15 + did: "did:plc:z72i7hdynmk6r22z27h6tvur", 16 + domain: "example.com", 17 + selectors: []string{"default"}, 18 + }, 19 + { 20 + name: "valid did:web", 21 + did: "did:web:example.com", 22 + domain: "example.com", 23 + selectors: []string{"sel1", "sel2"}, 24 + }, 25 + { 26 + name: "did:web with port encoding", 27 + did: "did:web:example.com%3A8080", 28 + domain: "example.com", 29 + selectors: []string{"default"}, 30 + }, 31 + { 32 + name: "invalid DID format", 33 + did: "not-a-did", 34 + domain: "example.com", 35 + selectors: []string{"default"}, 36 + wantErr: true, 37 + }, 38 + { 39 + name: "empty DID", 40 + did: "", 41 + domain: "example.com", 42 + selectors: []string{"default"}, 43 + wantErr: true, 44 + }, 45 + { 46 + name: "DID with spaces", 47 + did: "did:plc:abc 123", 48 + domain: "example.com", 49 + selectors: []string{"default"}, 50 + wantErr: true, 51 + }, 52 + { 53 + name: "did:plc too short", 54 + did: "did:plc:tooshort", 55 + domain: "example.com", 56 + selectors: []string{"default"}, 57 + wantErr: true, 58 + }, 59 + { 60 + name: "did:plc with uppercase", 61 + did: "did:plc:Z72I7HDYNMK6R22Z27H6TVUR", 62 + domain: "example.com", 63 + selectors: []string{"default"}, 64 + wantErr: true, 65 + }, 66 + { 67 + name: "did:plc with percent encoding", 68 + did: "did:plc:%2f%2f%2f%2f%2f%2f%2f%2f", 69 + domain: "example.com", 70 + selectors: []string{"default"}, 71 + wantErr: true, 72 + }, 73 + { 74 + name: "did:plc with invalid base32 chars", 75 + did: "did:plc:abc123def890ghijklmnopqr", 76 + domain: "example.com", 77 + selectors: []string{"default"}, 78 + wantErr: true, 79 + }, 80 + { 81 + name: "empty domain", 82 + did: "did:plc:abc123", 83 + domain: "", 84 + selectors: []string{"default"}, 85 + wantErr: true, 86 + }, 87 + { 88 + name: "domain too long", 89 + did: "did:plc:abc123", 90 + domain: string(make([]byte, 254)), 91 + selectors: []string{"default"}, 92 + wantErr: true, 93 + }, 94 + { 95 + name: "domain with path", 96 + did: "did:plc:abc123", 97 + domain: "example.com/evil", 98 + selectors: []string{"default"}, 99 + wantErr: true, 100 + }, 101 + { 102 + name: "no selectors", 103 + did: "did:plc:abc123", 104 + domain: "example.com", 105 + selectors: []string{}, 106 + wantErr: true, 107 + }, 108 + { 109 + name: "nil selectors", 110 + did: "did:plc:abc123", 111 + domain: "example.com", 112 + selectors: nil, 113 + wantErr: true, 114 + }, 115 + { 116 + name: "too many selectors", 117 + did: "did:plc:abc123", 118 + domain: "example.com", 119 + selectors: []string{"a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k"}, 120 + wantErr: true, 121 + }, 122 + { 123 + name: "empty selector", 124 + did: "did:plc:abc123", 125 + domain: "example.com", 126 + selectors: []string{""}, 127 + wantErr: true, 128 + }, 129 + { 130 + name: "selector with dots", 131 + did: "did:plc:abc123", 132 + domain: "example.com", 133 + selectors: []string{"sel.evil"}, 134 + wantErr: true, 135 + }, 136 + { 137 + name: "selector with slash", 138 + did: "did:plc:abc123", 139 + domain: "example.com", 140 + selectors: []string{"sel/evil"}, 141 + wantErr: true, 142 + }, 143 + { 144 + name: "selector too long", 145 + did: "did:plc:abc123", 146 + domain: "example.com", 147 + selectors: []string{string(make([]byte, 64))}, 148 + wantErr: true, 149 + }, 150 + } 151 + 152 + for _, tt := range tests { 153 + t.Run(tt.name, func(t *testing.T) { 154 + err := ValidateAttestation(tt.did, tt.domain, tt.selectors) 155 + if (err != nil) != tt.wantErr { 156 + t.Errorf("ValidateAttestation() error = %v, wantErr %v", err, tt.wantErr) 157 + } 158 + }) 159 + } 160 + }
+60
internal/scheduler/reverify.go
··· 1 + package scheduler 2 + 3 + import ( 4 + "context" 5 + "log" 6 + "time" 7 + 8 + "atmosphere-mail/internal/label" 9 + "atmosphere-mail/internal/store" 10 + ) 11 + 12 + // Scheduler periodically re-verifies attestations and negates labels if DNS degrades. 13 + type Scheduler struct { 14 + manager *label.Manager 15 + store *store.Store 16 + interval time.Duration 17 + } 18 + 19 + // New creates a re-verification scheduler. 20 + func New(manager *label.Manager, store *store.Store, interval time.Duration) *Scheduler { 21 + return &Scheduler{ 22 + manager: manager, 23 + store: store, 24 + interval: interval, 25 + } 26 + } 27 + 28 + // Run starts the periodic re-verification loop until the context is cancelled. 29 + func (s *Scheduler) Run(ctx context.Context) error { 30 + ticker := time.NewTicker(s.interval) 31 + defer ticker.Stop() 32 + 33 + for { 34 + select { 35 + case <-ctx.Done(): 36 + return ctx.Err() 37 + case <-ticker.C: 38 + if err := s.RunOnce(ctx); err != nil { 39 + log.Printf("reverify: %v", err) 40 + } 41 + } 42 + } 43 + } 44 + 45 + // RunOnce runs a single re-verification pass over all attestations. 46 + func (s *Scheduler) RunOnce(ctx context.Context) error { 47 + atts, err := s.store.ListAttestations(ctx) 48 + if err != nil { 49 + return err 50 + } 51 + 52 + for i := range atts { 53 + att := &atts[i] 54 + if err := s.manager.ProcessAttestation(ctx, att); err != nil { 55 + log.Printf("reverify %s/%s: %v", att.DID, att.Domain, err) 56 + } 57 + } 58 + 59 + return nil 60 + }
+155
internal/scheduler/reverify_test.go
··· 1 + package scheduler 2 + 3 + import ( 4 + "context" 5 + "path/filepath" 6 + "testing" 7 + "time" 8 + 9 + "atmosphere-mail/internal/dns" 10 + "atmosphere-mail/internal/label" 11 + "atmosphere-mail/internal/store" 12 + ) 13 + 14 + type mockDNS struct { 15 + result dns.Result 16 + } 17 + 18 + func (m *mockDNS) Verify(ctx context.Context, domain string, selectors []string) dns.Result { 19 + return m.result 20 + } 21 + 22 + type mockDomain struct { 23 + ok bool 24 + method string 25 + } 26 + 27 + func (m *mockDomain) Verify(ctx context.Context, did, domain string) (bool, string, error) { 28 + return m.ok, m.method, nil 29 + } 30 + 31 + func passDNS() *mockDNS { 32 + return &mockDNS{result: dns.Result{MX: true, SPF: true, DKIM: true, DMARC: true}} 33 + } 34 + 35 + func failDNS() *mockDNS { 36 + return &mockDNS{result: dns.Result{MX: true, SPF: false, DKIM: true, DMARC: true}} 37 + } 38 + 39 + func passDomain() *mockDomain { 40 + return &mockDomain{ok: true, method: "handle"} 41 + } 42 + 43 + func newSigner(t *testing.T) *label.Signer { 44 + t.Helper() 45 + keyPath := filepath.Join(t.TempDir(), "signing.key") 46 + if err := label.GenerateKey(keyPath); err != nil { 47 + t.Fatal(err) 48 + } 49 + signer, err := label.NewSigner(keyPath) 50 + if err != nil { 51 + t.Fatal(err) 52 + } 53 + return signer 54 + } 55 + 56 + func TestReverifyMaintainsLabels(t *testing.T) { 57 + s, err := store.New(":memory:") 58 + if err != nil { 59 + t.Fatal(err) 60 + } 61 + defer s.Close() 62 + ctx := context.Background() 63 + 64 + signer := newSigner(t) 65 + dnsCheck := passDNS() 66 + mgr := label.NewManager(signer, s, dnsCheck, passDomain()) 67 + 68 + att := &store.Attestation{ 69 + DID: "did:plc:test2345test2345test2345", 70 + Domain: "example.com", 71 + DKIMSelectors: []string{"default"}, 72 + CreatedAt: time.Now().UTC(), 73 + } 74 + if err := s.UpsertAttestation(ctx, att); err != nil { 75 + t.Fatal(err) 76 + } 77 + 78 + // Create labels through the manager (correct src DID) 79 + if err := mgr.ProcessAttestation(ctx, att); err != nil { 80 + t.Fatal(err) 81 + } 82 + 83 + labels, err := s.GetActiveLabelsForDID(ctx, "did:plc:test2345test2345test2345") 84 + if err != nil { 85 + t.Fatal(err) 86 + } 87 + if len(labels) != 1 { 88 + t.Fatalf("setup: got %d active labels, want 1", len(labels)) 89 + } 90 + 91 + // Re-verify with same passing DNS — labels should persist 92 + sched := New(mgr, s, 100*time.Millisecond) 93 + if err := sched.RunOnce(ctx); err != nil { 94 + t.Fatal(err) 95 + } 96 + 97 + labels, err = s.GetActiveLabelsForDID(ctx, "did:plc:test2345test2345test2345") 98 + if err != nil { 99 + t.Fatal(err) 100 + } 101 + if len(labels) != 1 { 102 + t.Errorf("got %d active labels, want 1 (should persist)", len(labels)) 103 + } 104 + } 105 + 106 + func TestReverifyNegatesOnDNSFailure(t *testing.T) { 107 + s, err := store.New(":memory:") 108 + if err != nil { 109 + t.Fatal(err) 110 + } 111 + defer s.Close() 112 + ctx := context.Background() 113 + 114 + signer := newSigner(t) 115 + dnsCheck := passDNS() 116 + mgr := label.NewManager(signer, s, dnsCheck, passDomain()) 117 + 118 + att := &store.Attestation{ 119 + DID: "did:plc:test2345test2345test2345", 120 + Domain: "example.com", 121 + DKIMSelectors: []string{"default"}, 122 + CreatedAt: time.Now().UTC(), 123 + } 124 + if err := s.UpsertAttestation(ctx, att); err != nil { 125 + t.Fatal(err) 126 + } 127 + 128 + // Create labels with passing DNS 129 + if err := mgr.ProcessAttestation(ctx, att); err != nil { 130 + t.Fatal(err) 131 + } 132 + 133 + labels, err := s.GetActiveLabelsForDID(ctx, "did:plc:test2345test2345test2345") 134 + if err != nil { 135 + t.Fatal(err) 136 + } 137 + if len(labels) != 1 { 138 + t.Fatalf("setup: got %d active labels, want 1", len(labels)) 139 + } 140 + 141 + // Now re-verify with failing DNS — labels should be negated 142 + failMgr := label.NewManager(signer, s, failDNS(), passDomain()) 143 + sched := New(failMgr, s, 100*time.Millisecond) 144 + if err := sched.RunOnce(ctx); err != nil { 145 + t.Fatal(err) 146 + } 147 + 148 + labels, err = s.GetActiveLabelsForDID(ctx, "did:plc:test2345test2345test2345") 149 + if err != nil { 150 + t.Fatal(err) 151 + } 152 + if len(labels) != 0 { 153 + t.Errorf("got %d active labels, want 0 (DNS failed, should negate)", len(labels)) 154 + } 155 + }
+86
internal/server/diagnostics.go
··· 1 + package server 2 + 3 + import ( 4 + "encoding/json" 5 + "log" 6 + "net/http" 7 + "regexp" 8 + ) 9 + 10 + // validDID matches did:plc (base32-lower, 24 chars) and did:web formats. 11 + var validDID = regexp.MustCompile(`^(did:plc:[a-z2-7]{24}|did:web:[a-zA-Z0-9._:%-]+)$`) 12 + 13 + type verificationStatusResponse struct { 14 + DID string `json:"did"` 15 + Attestations []attestationStatus `json:"attestations"` 16 + ActiveLabels []string `json:"activeLabels"` 17 + } 18 + 19 + type attestationStatus struct { 20 + Domain string `json:"domain"` 21 + Verified bool `json:"verified"` 22 + RelayMember bool `json:"relayMember"` 23 + LastVerified string `json:"lastVerified,omitempty"` 24 + DKIMSelectors []string `json:"dkimSelectors"` 25 + } 26 + 27 + func (s *Server) handleGetVerificationStatus(w http.ResponseWriter, r *http.Request) { 28 + if r.Method != http.MethodGet { 29 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 30 + return 31 + } 32 + 33 + did := r.URL.Query().Get("did") 34 + if !validDID.MatchString(did) { 35 + http.Error(w, "did parameter required", http.StatusBadRequest) 36 + return 37 + } 38 + 39 + ctx := r.Context() 40 + 41 + atts, err := s.store.GetAttestationsForDID(ctx, did) 42 + if err != nil { 43 + log.Printf("getVerificationStatus attestations: %v", err) 44 + http.Error(w, "internal error", http.StatusInternalServerError) 45 + return 46 + } 47 + 48 + var attStatuses []attestationStatus 49 + for _, a := range atts { 50 + status := attestationStatus{ 51 + Domain: a.Domain, 52 + Verified: a.Verified, 53 + RelayMember: a.RelayMember, 54 + DKIMSelectors: a.DKIMSelectors, 55 + } 56 + if !a.LastVerified.IsZero() { 57 + status.LastVerified = a.LastVerified.Format("2006-01-02T15:04:05Z") 58 + } 59 + attStatuses = append(attStatuses, status) 60 + } 61 + if attStatuses == nil { 62 + attStatuses = []attestationStatus{} 63 + } 64 + 65 + labels, err := s.store.GetActiveLabelsForDID(ctx, did) 66 + if err != nil { 67 + log.Printf("getVerificationStatus labels: %v", err) 68 + http.Error(w, "internal error", http.StatusInternalServerError) 69 + return 70 + } 71 + 72 + var activeLabels []string 73 + for _, l := range labels { 74 + activeLabels = append(activeLabels, l.Val) 75 + } 76 + if activeLabels == nil { 77 + activeLabels = []string{} 78 + } 79 + 80 + w.Header().Set("Content-Type", "application/json") 81 + json.NewEncoder(w).Encode(verificationStatusResponse{ 82 + DID: did, 83 + Attestations: attStatuses, 84 + ActiveLabels: activeLabels, 85 + }) 86 + }
+115
internal/server/query.go
··· 1 + package server 2 + 3 + import ( 4 + "encoding/base64" 5 + "encoding/json" 6 + "log" 7 + "net/http" 8 + "strconv" 9 + 10 + "atmosphere-mail/internal/store" 11 + ) 12 + 13 + type queryLabelsResponse struct { 14 + Cursor string `json:"cursor,omitempty"` 15 + Labels []labelResponse `json:"labels"` 16 + } 17 + 18 + type labelResponse struct { 19 + Src string `json:"src"` 20 + URI string `json:"uri"` 21 + Val string `json:"val"` 22 + Cts string `json:"cts"` 23 + Neg *bool `json:"neg,omitempty"` 24 + Sig *bytesJSON `json:"sig,omitempty"` 25 + Ver int64 `json:"ver"` 26 + } 27 + 28 + // bytesJSON encodes bytes as {"$bytes": "<base64>"} per atproto data model. 29 + type bytesJSON struct { 30 + Bytes string `json:"$bytes"` 31 + } 32 + 33 + func newBytesJSON(b []byte) *bytesJSON { 34 + if len(b) == 0 { 35 + return nil 36 + } 37 + return &bytesJSON{Bytes: base64.StdEncoding.EncodeToString(b)} 38 + } 39 + 40 + func labelToResponse(l store.Label) labelResponse { 41 + lr := labelResponse{ 42 + Src: l.Src, 43 + URI: l.URI, 44 + Val: l.Val, 45 + Cts: l.Cts, 46 + Sig: newBytesJSON(l.Sig), 47 + Ver: store.LabelVersion, 48 + } 49 + if l.Neg { 50 + neg := true 51 + lr.Neg = &neg 52 + } 53 + return lr 54 + } 55 + 56 + func (s *Server) handleQueryLabels(w http.ResponseWriter, r *http.Request) { 57 + if r.Method != http.MethodGet { 58 + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 59 + return 60 + } 61 + 62 + q := r.URL.Query() 63 + uriPatterns := q["uriPatterns"] 64 + sources := q["sources"] 65 + cursor := q.Get("cursor") 66 + limitStr := q.Get("limit") 67 + 68 + limit := 50 69 + if limitStr != "" { 70 + n, err := strconv.Atoi(limitStr) 71 + if err != nil || n < 1 { 72 + http.Error(w, "invalid limit", http.StatusBadRequest) 73 + return 74 + } 75 + if n > 250 { 76 + n = 250 77 + } 78 + limit = n 79 + } 80 + 81 + if len(uriPatterns) == 0 { 82 + http.Error(w, "uriPatterns required", http.StatusBadRequest) 83 + return 84 + } 85 + 86 + ctx := r.Context() 87 + 88 + // Build source filter set for O(1) lookup 89 + sourceSet := map[string]bool{} 90 + for _, src := range sources { 91 + sourceSet[src] = true 92 + } 93 + 94 + // Single query across all patterns with correct global pagination 95 + labels, nextCursor, err := s.store.GetLabelsByURIPatterns(ctx, uriPatterns, cursor, limit) 96 + if err != nil { 97 + log.Printf("queryLabels: %v", err) 98 + http.Error(w, "internal error", http.StatusInternalServerError) 99 + return 100 + } 101 + 102 + allLabels := make([]labelResponse, 0, len(labels)) 103 + for _, l := range labels { 104 + if len(sourceSet) > 0 && !sourceSet[l.Src] { 105 + continue 106 + } 107 + allLabels = append(allLabels, labelToResponse(l)) 108 + } 109 + 110 + w.Header().Set("Content-Type", "application/json") 111 + json.NewEncoder(w).Encode(queryLabelsResponse{ 112 + Cursor: nextCursor, 113 + Labels: allLabels, 114 + }) 115 + }
+109
internal/server/server.go
··· 1 + package server 2 + 3 + import ( 4 + "fmt" 5 + "net/http" 6 + "sync" 7 + "sync/atomic" 8 + "time" 9 + 10 + "github.com/gorilla/websocket" 11 + 12 + "atmosphere-mail/internal/store" 13 + ) 14 + 15 + const ( 16 + maxWebSocketConns = 100 17 + maxBackfillLabels = 10000 18 + ) 19 + 20 + // Server handles XRPC endpoints for the labeler. 21 + type Server struct { 22 + store *store.Store 23 + labelerDID string 24 + mux *http.ServeMux 25 + wsConns atomic.Int64 26 + 27 + // WebSocket connection tracking for graceful shutdown 28 + wsMu sync.Mutex 29 + wsTracked map[*websocket.Conn]struct{} 30 + } 31 + 32 + // New creates a labeler XRPC server. 33 + func New(s *store.Store, labelerDID string) *Server { 34 + srv := &Server{ 35 + store: s, 36 + labelerDID: labelerDID, 37 + mux: http.NewServeMux(), 38 + wsTracked: make(map[*websocket.Conn]struct{}), 39 + } 40 + srv.mux.HandleFunc("/xrpc/com.atproto.label.queryLabels", srv.handleQueryLabels) 41 + srv.mux.HandleFunc("/xrpc/com.atproto.label.subscribeLabels", srv.handleSubscribeLabels) 42 + srv.mux.HandleFunc("/xrpc/email.atmos.getVerificationStatus", srv.handleGetVerificationStatus) 43 + srv.mux.HandleFunc("/healthz", srv.handleHealthz) 44 + srv.mux.HandleFunc("/metrics", srv.handleMetrics) 45 + return srv 46 + } 47 + 48 + // Handler returns the HTTP handler for this server. 49 + func (s *Server) Handler() http.Handler { 50 + return s.mux 51 + } 52 + 53 + func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) { 54 + ctx := r.Context() 55 + if err := s.store.Ping(ctx); err != nil { 56 + http.Error(w, "database unreachable", http.StatusServiceUnavailable) 57 + return 58 + } 59 + w.WriteHeader(http.StatusOK) 60 + w.Write([]byte("ok\n")) 61 + } 62 + 63 + func (s *Server) handleMetrics(w http.ResponseWriter, r *http.Request) { 64 + ctx := r.Context() 65 + stats, err := s.store.Stats(ctx) 66 + if err != nil { 67 + http.Error(w, "internal error", http.StatusInternalServerError) 68 + return 69 + } 70 + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") 71 + fmt.Fprintf(w, "# HELP atmosphere_labels_total Total number of labels in the store.\n") 72 + fmt.Fprintf(w, "# TYPE atmosphere_labels_total gauge\n") 73 + fmt.Fprintf(w, "atmosphere_labels_total %d\n", stats.Labels) 74 + fmt.Fprintf(w, "# HELP atmosphere_attestations_total Total number of attestations in the store.\n") 75 + fmt.Fprintf(w, "# TYPE atmosphere_attestations_total gauge\n") 76 + fmt.Fprintf(w, "atmosphere_attestations_total %d\n", stats.Attestations) 77 + fmt.Fprintf(w, "# HELP atmosphere_websocket_connections Current number of WebSocket connections.\n") 78 + fmt.Fprintf(w, "# TYPE atmosphere_websocket_connections gauge\n") 79 + fmt.Fprintf(w, "atmosphere_websocket_connections %d\n", s.wsConns.Load()) 80 + } 81 + 82 + func (s *Server) trackConn(conn *websocket.Conn) { 83 + s.wsMu.Lock() 84 + s.wsTracked[conn] = struct{}{} 85 + s.wsMu.Unlock() 86 + } 87 + 88 + func (s *Server) untrackConn(conn *websocket.Conn) { 89 + s.wsMu.Lock() 90 + delete(s.wsTracked, conn) 91 + s.wsMu.Unlock() 92 + } 93 + 94 + // ShutdownWebSockets sends close frames to all active WebSocket connections 95 + // and waits briefly for them to acknowledge. 96 + func (s *Server) ShutdownWebSockets() { 97 + s.wsMu.Lock() 98 + conns := make([]*websocket.Conn, 0, len(s.wsTracked)) 99 + for c := range s.wsTracked { 100 + conns = append(conns, c) 101 + } 102 + s.wsMu.Unlock() 103 + 104 + closeMsg := websocket.FormatCloseMessage(websocket.CloseGoingAway, "server shutting down") 105 + for _, c := range conns { 106 + c.WriteControl(websocket.CloseMessage, closeMsg, time.Now().Add(2*time.Second)) 107 + c.Close() 108 + } 109 + }
+584
internal/server/server_test.go
··· 1 + package server 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "encoding/json" 7 + "net/http" 8 + "net/http/httptest" 9 + "strings" 10 + "testing" 11 + "time" 12 + 13 + "github.com/fxamacker/cbor/v2" 14 + "github.com/gorilla/websocket" 15 + 16 + "atmosphere-mail/internal/store" 17 + ) 18 + 19 + func testServer(t *testing.T) (*Server, *store.Store) { 20 + t.Helper() 21 + // Use temp file instead of :memory: because database/sql connection 22 + // pooling gives each connection its own :memory: DB, causing 23 + // "no such table" errors under concurrent access / -race. 24 + dbPath := t.TempDir() + "/test.sqlite" 25 + s, err := store.New(dbPath) 26 + if err != nil { 27 + t.Fatal(err) 28 + } 29 + t.Cleanup(func() { s.Close() }) 30 + srv := New(s, "did:key:zTestLabeler") 31 + return srv, s 32 + } 33 + 34 + func insertTestLabel(t *testing.T, s *store.Store, uri, val string) int64 { 35 + t.Helper() 36 + seq, err := s.InsertLabel(context.Background(), &store.Label{ 37 + Src: "did:key:zTestLabeler", 38 + URI: uri, 39 + Val: val, 40 + Cts: time.Now().UTC().Format(time.RFC3339), 41 + Neg: false, 42 + Sig: []byte("testsig"), 43 + RawCBOR: []byte("testraw"), 44 + }) 45 + if err != nil { 46 + t.Fatal(err) 47 + } 48 + return seq 49 + } 50 + 51 + func TestQueryLabels(t *testing.T) { 52 + srv, s := testServer(t) 53 + insertTestLabel(t, s, "did:plc:member1", "verified-mail-operator") 54 + insertTestLabel(t, s, "did:plc:member2", "relay-member") 55 + 56 + ts := httptest.NewServer(srv.Handler()) 57 + defer ts.Close() 58 + 59 + resp, err := http.Get(ts.URL + "/xrpc/com.atproto.label.queryLabels?uriPatterns=did:plc:member1") 60 + if err != nil { 61 + t.Fatal(err) 62 + } 63 + defer resp.Body.Close() 64 + 65 + if resp.StatusCode != 200 { 66 + t.Fatalf("status = %d, want 200", resp.StatusCode) 67 + } 68 + 69 + var result queryLabelsResponse 70 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 71 + t.Fatal(err) 72 + } 73 + 74 + if len(result.Labels) != 1 { 75 + t.Fatalf("got %d labels, want 1", len(result.Labels)) 76 + } 77 + if result.Labels[0].Val != "verified-mail-operator" { 78 + t.Errorf("val = %q", result.Labels[0].Val) 79 + } 80 + if result.Labels[0].Sig == nil { 81 + t.Error("sig should be present") 82 + } 83 + } 84 + 85 + func TestQueryLabelsNoPattern(t *testing.T) { 86 + srv, _ := testServer(t) 87 + ts := httptest.NewServer(srv.Handler()) 88 + defer ts.Close() 89 + 90 + resp, err := http.Get(ts.URL + "/xrpc/com.atproto.label.queryLabels") 91 + if err != nil { 92 + t.Fatal(err) 93 + } 94 + defer resp.Body.Close() 95 + 96 + if resp.StatusCode != http.StatusBadRequest { 97 + t.Errorf("status = %d, want 400", resp.StatusCode) 98 + } 99 + } 100 + 101 + func TestQueryLabelsPagination(t *testing.T) { 102 + srv, s := testServer(t) 103 + for i := 0; i < 5; i++ { 104 + insertTestLabel(t, s, "did:plc:bulk", "verified-mail-operator") 105 + } 106 + 107 + ts := httptest.NewServer(srv.Handler()) 108 + defer ts.Close() 109 + 110 + resp, err := http.Get(ts.URL + "/xrpc/com.atproto.label.queryLabels?uriPatterns=did:plc:bulk&limit=2") 111 + if err != nil { 112 + t.Fatal(err) 113 + } 114 + defer resp.Body.Close() 115 + 116 + var result queryLabelsResponse 117 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 118 + t.Fatal(err) 119 + } 120 + 121 + if len(result.Labels) != 2 { 122 + t.Fatalf("got %d labels, want 2", len(result.Labels)) 123 + } 124 + if result.Cursor == "" { 125 + t.Error("expected cursor for pagination") 126 + } 127 + 128 + // Fetch next page 129 + resp2, err := http.Get(ts.URL + "/xrpc/com.atproto.label.queryLabels?uriPatterns=did:plc:bulk&limit=2&cursor=" + result.Cursor) 130 + if err != nil { 131 + t.Fatal(err) 132 + } 133 + defer resp2.Body.Close() 134 + 135 + var result2 queryLabelsResponse 136 + if err := json.NewDecoder(resp2.Body).Decode(&result2); err != nil { 137 + t.Fatal(err) 138 + } 139 + 140 + if len(result2.Labels) != 2 { 141 + t.Fatalf("page 2: got %d labels, want 2", len(result2.Labels)) 142 + } 143 + } 144 + 145 + func TestQueryLabelsLimitCappedAt250(t *testing.T) { 146 + srv, _ := testServer(t) 147 + ts := httptest.NewServer(srv.Handler()) 148 + defer ts.Close() 149 + 150 + // Request limit=999, should be capped to 250 (no error) 151 + resp, err := http.Get(ts.URL + "/xrpc/com.atproto.label.queryLabels?uriPatterns=did:plc:test&limit=999") 152 + if err != nil { 153 + t.Fatal(err) 154 + } 155 + defer resp.Body.Close() 156 + 157 + if resp.StatusCode != 200 { 158 + t.Errorf("limit=999 status = %d, want 200 (capped, not rejected)", resp.StatusCode) 159 + } 160 + } 161 + 162 + func TestQueryLabelsInvalidLimit(t *testing.T) { 163 + srv, _ := testServer(t) 164 + ts := httptest.NewServer(srv.Handler()) 165 + defer ts.Close() 166 + 167 + // limit=0 should be rejected 168 + resp, err := http.Get(ts.URL + "/xrpc/com.atproto.label.queryLabels?uriPatterns=did:plc:test&limit=0") 169 + if err != nil { 170 + t.Fatal(err) 171 + } 172 + defer resp.Body.Close() 173 + if resp.StatusCode != http.StatusBadRequest { 174 + t.Errorf("limit=0 status = %d, want 400", resp.StatusCode) 175 + } 176 + 177 + // limit=-1 should be rejected 178 + resp2, err := http.Get(ts.URL + "/xrpc/com.atproto.label.queryLabels?uriPatterns=did:plc:test&limit=-1") 179 + if err != nil { 180 + t.Fatal(err) 181 + } 182 + defer resp2.Body.Close() 183 + if resp2.StatusCode != http.StatusBadRequest { 184 + t.Errorf("limit=-1 status = %d, want 400", resp2.StatusCode) 185 + } 186 + 187 + // limit=abc should be rejected 188 + resp3, err := http.Get(ts.URL + "/xrpc/com.atproto.label.queryLabels?uriPatterns=did:plc:test&limit=abc") 189 + if err != nil { 190 + t.Fatal(err) 191 + } 192 + defer resp3.Body.Close() 193 + if resp3.StatusCode != http.StatusBadRequest { 194 + t.Errorf("limit=abc status = %d, want 400", resp3.StatusCode) 195 + } 196 + } 197 + 198 + func TestQueryLabelsWildcard(t *testing.T) { 199 + srv, s := testServer(t) 200 + insertTestLabel(t, s, "did:plc:member1", "verified-mail-operator") 201 + insertTestLabel(t, s, "did:plc:member2", "relay-member") 202 + 203 + ts := httptest.NewServer(srv.Handler()) 204 + defer ts.Close() 205 + 206 + resp, err := http.Get(ts.URL + "/xrpc/com.atproto.label.queryLabels?uriPatterns=did:plc:*") 207 + if err != nil { 208 + t.Fatal(err) 209 + } 210 + defer resp.Body.Close() 211 + 212 + var result queryLabelsResponse 213 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 214 + t.Fatal(err) 215 + } 216 + 217 + if len(result.Labels) != 2 { 218 + t.Fatalf("wildcard: got %d labels, want 2", len(result.Labels)) 219 + } 220 + } 221 + 222 + // decodeCBORFrame decodes a binary XRPC subscription frame into header + body. 223 + func decodeCBORFrame(t *testing.T, data []byte) (frameHeader, labelsBody) { 224 + t.Helper() 225 + dec := cbor.NewDecoder(bytes.NewReader(data)) 226 + 227 + var header frameHeader 228 + if err := dec.Decode(&header); err != nil { 229 + t.Fatalf("decode frame header: %v", err) 230 + } 231 + 232 + var body labelsBody 233 + if err := dec.Decode(&body); err != nil { 234 + t.Fatalf("decode frame body: %v", err) 235 + } 236 + return header, body 237 + } 238 + 239 + func TestSubscribeLabelsBackfill(t *testing.T) { 240 + srv, s := testServer(t) 241 + insertTestLabel(t, s, "did:plc:member1", "verified-mail-operator") 242 + insertTestLabel(t, s, "did:plc:member2", "relay-member") 243 + 244 + ts := httptest.NewServer(srv.Handler()) 245 + defer ts.Close() 246 + 247 + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/xrpc/com.atproto.label.subscribeLabels?cursor=0" 248 + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) 249 + if err != nil { 250 + t.Fatalf("dial: %v", err) 251 + } 252 + defer conn.Close() 253 + 254 + // Should receive 2 binary CBOR-framed messages (backfill) 255 + for i := 0; i < 2; i++ { 256 + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) 257 + msgType, msg, err := conn.ReadMessage() 258 + if err != nil { 259 + t.Fatalf("read message %d: %v", i, err) 260 + } 261 + if msgType != websocket.BinaryMessage { 262 + t.Errorf("message %d: type = %d, want BinaryMessage", i, msgType) 263 + } 264 + 265 + header, body := decodeCBORFrame(t, msg) 266 + if header.Op != 1 { 267 + t.Errorf("message %d: op = %d, want 1", i, header.Op) 268 + } 269 + if header.T != "#labels" { 270 + t.Errorf("message %d: t = %q, want #labels", i, header.T) 271 + } 272 + if body.Seq < 1 { 273 + t.Errorf("message %d: seq = %d, want >= 1", i, body.Seq) 274 + } 275 + if len(body.Labels) != 1 { 276 + t.Errorf("message %d: got %d labels, want 1", i, len(body.Labels)) 277 + } 278 + } 279 + } 280 + 281 + func TestGetVerificationStatus(t *testing.T) { 282 + srv, s := testServer(t) 283 + ctx := context.Background() 284 + 285 + const testDID = "did:plc:mmmm2222nnnn3333oooo4444" 286 + const unknownDID = "did:plc:zzzz2222yyyy3333xxxx4444" 287 + 288 + // Insert an attestation 289 + if err := s.UpsertAttestation(ctx, &store.Attestation{ 290 + DID: testDID, 291 + RKey: "example.com", 292 + Domain: "example.com", 293 + DKIMSelectors: []string{"default", "mail"}, 294 + RelayMember: true, 295 + Verified: true, 296 + CreatedAt: time.Now().UTC(), 297 + }); err != nil { 298 + t.Fatal(err) 299 + } 300 + insertTestLabel(t, s, testDID, "verified-mail-operator") 301 + 302 + ts := httptest.NewServer(srv.Handler()) 303 + defer ts.Close() 304 + 305 + // Query with DID 306 + resp, err := http.Get(ts.URL + "/xrpc/email.atmos.getVerificationStatus?did=" + testDID) 307 + if err != nil { 308 + t.Fatal(err) 309 + } 310 + defer resp.Body.Close() 311 + 312 + if resp.StatusCode != 200 { 313 + t.Fatalf("status = %d, want 200", resp.StatusCode) 314 + } 315 + 316 + var result verificationStatusResponse 317 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 318 + t.Fatal(err) 319 + } 320 + 321 + if result.DID != testDID { 322 + t.Errorf("DID = %q", result.DID) 323 + } 324 + if len(result.Attestations) != 1 { 325 + t.Fatalf("got %d attestations, want 1", len(result.Attestations)) 326 + } 327 + if !result.Attestations[0].Verified { 328 + t.Error("attestation should be verified") 329 + } 330 + if result.Attestations[0].Domain != "example.com" { 331 + t.Errorf("domain = %q", result.Attestations[0].Domain) 332 + } 333 + if len(result.ActiveLabels) != 1 { 334 + t.Fatalf("got %d active labels, want 1", len(result.ActiveLabels)) 335 + } 336 + if result.ActiveLabels[0] != "verified-mail-operator" { 337 + t.Errorf("active label = %q", result.ActiveLabels[0]) 338 + } 339 + 340 + // Query without DID should fail 341 + resp2, err := http.Get(ts.URL + "/xrpc/email.atmos.getVerificationStatus") 342 + if err != nil { 343 + t.Fatal(err) 344 + } 345 + defer resp2.Body.Close() 346 + if resp2.StatusCode != http.StatusBadRequest { 347 + t.Errorf("no-did status = %d, want 400", resp2.StatusCode) 348 + } 349 + 350 + // Query unknown DID should return empty arrays 351 + resp3, err := http.Get(ts.URL + "/xrpc/email.atmos.getVerificationStatus?did=" + unknownDID) 352 + if err != nil { 353 + t.Fatal(err) 354 + } 355 + defer resp3.Body.Close() 356 + 357 + var result3 verificationStatusResponse 358 + if err := json.NewDecoder(resp3.Body).Decode(&result3); err != nil { 359 + t.Fatal(err) 360 + } 361 + if len(result3.Attestations) != 0 { 362 + t.Errorf("unknown DID: got %d attestations, want 0", len(result3.Attestations)) 363 + } 364 + if len(result3.ActiveLabels) != 0 { 365 + t.Errorf("unknown DID: got %d labels, want 0", len(result3.ActiveLabels)) 366 + } 367 + } 368 + 369 + func TestQueryLabelsSourcesFilter(t *testing.T) { 370 + srv, s := testServer(t) 371 + ctx := context.Background() 372 + 373 + // Insert labels from two different sources 374 + s.InsertLabel(ctx, &store.Label{ 375 + Src: "did:key:zLabelerA", URI: "did:plc:member1", Val: "verified-mail-operator", 376 + Cts: "2026-01-01T00:00:00Z", Sig: []byte("sig1"), RawCBOR: []byte("raw1"), 377 + }) 378 + s.InsertLabel(ctx, &store.Label{ 379 + Src: "did:key:zLabelerB", URI: "did:plc:member1", Val: "relay-member", 380 + Cts: "2026-01-01T00:00:00Z", Sig: []byte("sig2"), RawCBOR: []byte("raw2"), 381 + }) 382 + 383 + ts := httptest.NewServer(srv.Handler()) 384 + defer ts.Close() 385 + 386 + // Filter by source A — should only get 1 label 387 + resp, err := http.Get(ts.URL + "/xrpc/com.atproto.label.queryLabels?uriPatterns=did:plc:member1&sources=did:key:zLabelerA") 388 + if err != nil { 389 + t.Fatal(err) 390 + } 391 + defer resp.Body.Close() 392 + 393 + var result queryLabelsResponse 394 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 395 + t.Fatal(err) 396 + } 397 + if len(result.Labels) != 1 { 398 + t.Fatalf("sources filter: got %d labels, want 1", len(result.Labels)) 399 + } 400 + if result.Labels[0].Src != "did:key:zLabelerA" { 401 + t.Errorf("src = %q, want did:key:zLabelerA", result.Labels[0].Src) 402 + } 403 + 404 + // No sources filter — should get both 405 + resp2, err := http.Get(ts.URL + "/xrpc/com.atproto.label.queryLabels?uriPatterns=did:plc:member1") 406 + if err != nil { 407 + t.Fatal(err) 408 + } 409 + defer resp2.Body.Close() 410 + 411 + var result2 queryLabelsResponse 412 + if err := json.NewDecoder(resp2.Body).Decode(&result2); err != nil { 413 + t.Fatal(err) 414 + } 415 + if len(result2.Labels) != 2 { 416 + t.Fatalf("no sources filter: got %d labels, want 2", len(result2.Labels)) 417 + } 418 + } 419 + 420 + func TestQueryLabelsMultiPattern(t *testing.T) { 421 + srv, s := testServer(t) 422 + // Insert labels for two different DIDs 423 + insertTestLabel(t, s, "did:plc:aaa", "verified-mail-operator") 424 + insertTestLabel(t, s, "did:plc:bbb", "relay-member") 425 + insertTestLabel(t, s, "did:plc:aaa", "relay-member") 426 + 427 + ts := httptest.NewServer(srv.Handler()) 428 + defer ts.Close() 429 + 430 + // Query with two exact patterns — should return all 3 labels in seq order 431 + resp, err := http.Get(ts.URL + "/xrpc/com.atproto.label.queryLabels?uriPatterns=did:plc:aaa&uriPatterns=did:plc:bbb") 432 + if err != nil { 433 + t.Fatal(err) 434 + } 435 + defer resp.Body.Close() 436 + 437 + var result queryLabelsResponse 438 + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { 439 + t.Fatal(err) 440 + } 441 + 442 + if len(result.Labels) != 3 { 443 + t.Fatalf("multi-pattern: got %d labels, want 3", len(result.Labels)) 444 + } 445 + 446 + // Paginate with limit=2 — cursor should work across patterns 447 + resp2, err := http.Get(ts.URL + "/xrpc/com.atproto.label.queryLabels?uriPatterns=did:plc:aaa&uriPatterns=did:plc:bbb&limit=2") 448 + if err != nil { 449 + t.Fatal(err) 450 + } 451 + defer resp2.Body.Close() 452 + 453 + var page1 queryLabelsResponse 454 + if err := json.NewDecoder(resp2.Body).Decode(&page1); err != nil { 455 + t.Fatal(err) 456 + } 457 + if len(page1.Labels) != 2 { 458 + t.Fatalf("page1: got %d labels, want 2", len(page1.Labels)) 459 + } 460 + if page1.Cursor == "" { 461 + t.Fatal("page1: expected cursor") 462 + } 463 + 464 + resp3, err := http.Get(ts.URL + "/xrpc/com.atproto.label.queryLabels?uriPatterns=did:plc:aaa&uriPatterns=did:plc:bbb&limit=2&cursor=" + page1.Cursor) 465 + if err != nil { 466 + t.Fatal(err) 467 + } 468 + defer resp3.Body.Close() 469 + 470 + var page2 queryLabelsResponse 471 + if err := json.NewDecoder(resp3.Body).Decode(&page2); err != nil { 472 + t.Fatal(err) 473 + } 474 + if len(page2.Labels) != 1 { 475 + t.Fatalf("page2: got %d labels, want 1", len(page2.Labels)) 476 + } 477 + } 478 + 479 + func TestGetVerificationStatusRejectsInvalidDID(t *testing.T) { 480 + srv, _ := testServer(t) 481 + ts := httptest.NewServer(srv.Handler()) 482 + defer ts.Close() 483 + 484 + resp, err := http.Get(ts.URL + "/xrpc/email.atmos.getVerificationStatus?did=not-a-did") 485 + if err != nil { 486 + t.Fatal(err) 487 + } 488 + defer resp.Body.Close() 489 + if resp.StatusCode != http.StatusBadRequest { 490 + t.Errorf("invalid DID status = %d, want 400", resp.StatusCode) 491 + } 492 + } 493 + 494 + func TestHealthz(t *testing.T) { 495 + srv, _ := testServer(t) 496 + ts := httptest.NewServer(srv.Handler()) 497 + defer ts.Close() 498 + 499 + resp, err := http.Get(ts.URL + "/healthz") 500 + if err != nil { 501 + t.Fatal(err) 502 + } 503 + defer resp.Body.Close() 504 + if resp.StatusCode != 200 { 505 + t.Errorf("healthz status = %d, want 200", resp.StatusCode) 506 + } 507 + } 508 + 509 + func TestSubscribeLabelsConnectionLimit(t *testing.T) { 510 + srv, _ := testServer(t) 511 + ts := httptest.NewServer(srv.Handler()) 512 + defer ts.Close() 513 + 514 + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/xrpc/com.atproto.label.subscribeLabels" 515 + 516 + // Open maxWebSocketConns connections 517 + var conns []*websocket.Conn 518 + for i := 0; i < maxWebSocketConns; i++ { 519 + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) 520 + if err != nil { 521 + t.Fatalf("dial %d: %v", i, err) 522 + } 523 + conns = append(conns, conn) 524 + } 525 + defer func() { 526 + for _, c := range conns { 527 + c.Close() 528 + } 529 + }() 530 + 531 + // Next connection should be rejected 532 + _, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) 533 + if err == nil { 534 + t.Error("expected connection to be rejected at limit") 535 + } 536 + if resp != nil && resp.StatusCode != http.StatusServiceUnavailable { 537 + t.Errorf("status = %d, want 503", resp.StatusCode) 538 + } 539 + } 540 + 541 + func TestShutdownWebSocketsSendsClose(t *testing.T) { 542 + srv, s := testServer(t) 543 + ctx := context.Background() 544 + 545 + insertTestLabel(t, s, "did:plc:member1", "verified-mail-operator") 546 + 547 + ts := httptest.NewServer(srv.Handler()) 548 + defer ts.Close() 549 + 550 + wsURL := "ws" + strings.TrimPrefix(ts.URL, "http") + "/xrpc/com.atproto.label.subscribeLabels?cursor=0" 551 + 552 + // Read the backfilled label first so connection is established 553 + conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) 554 + if err != nil { 555 + t.Fatal(err) 556 + } 557 + defer conn.Close() 558 + 559 + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) 560 + _, _, err = conn.ReadMessage() 561 + if err != nil { 562 + t.Fatalf("read backfill: %v", err) 563 + } 564 + 565 + _ = ctx // keep consistent with other tests 566 + 567 + // Shut down WebSockets — should send close frame 568 + srv.ShutdownWebSockets() 569 + 570 + // Next read should get a close message or error 571 + conn.SetReadDeadline(time.Now().Add(2 * time.Second)) 572 + _, _, err = conn.ReadMessage() 573 + if err == nil { 574 + t.Error("expected error after shutdown, got nil") 575 + } 576 + if !websocket.IsCloseError(err, websocket.CloseGoingAway) { 577 + // Connection may also just be closed — both are acceptable 578 + if !strings.Contains(err.Error(), "use of closed") && 579 + !strings.Contains(err.Error(), "connection reset") && 580 + !websocket.IsUnexpectedCloseError(err) { 581 + t.Logf("got error type: %v (acceptable)", err) 582 + } 583 + } 584 + }
+200
internal/server/subscribe.go
··· 1 + package server 2 + 3 + import ( 4 + "log" 5 + "net/http" 6 + "strconv" 7 + "time" 8 + 9 + "github.com/fxamacker/cbor/v2" 10 + "github.com/gorilla/websocket" 11 + 12 + "atmosphere-mail/internal/store" 13 + ) 14 + 15 + // cborEnc is a cached CBOR encoder config, avoiding per-call allocation. 16 + var cborEnc cbor.EncMode 17 + 18 + func init() { 19 + var err error 20 + cborEnc, err = cbor.CoreDetEncOptions().EncMode() 21 + if err != nil { 22 + panic("cbor enc mode: " + err.Error()) 23 + } 24 + } 25 + 26 + var upgrader = websocket.Upgrader{ 27 + ReadBufferSize: 1024, 28 + WriteBufferSize: 4096, 29 + CheckOrigin: func(r *http.Request) bool { 30 + return true // XRPC endpoints are public 31 + }, 32 + } 33 + 34 + // XRPC subscription frame header. 35 + type frameHeader struct { 36 + Op int `cbor:"op"` 37 + T string `cbor:"t,omitempty"` 38 + } 39 + 40 + // subscribeLabels body for #labels frames. 41 + type labelsBody struct { 42 + Seq int64 `cbor:"seq"` 43 + Labels []cborLabel `cbor:"labels"` 44 + } 45 + 46 + // cborLabel is the CBOR-encoded label for subscribeLabels wire format. 47 + type cborLabel struct { 48 + Src string `cbor:"src"` 49 + URI string `cbor:"uri"` 50 + Val string `cbor:"val"` 51 + Cts string `cbor:"cts"` 52 + Neg bool `cbor:"neg,omitempty"` 53 + Sig []byte `cbor:"sig,omitempty"` 54 + Ver int64 `cbor:"ver"` 55 + } 56 + 57 + func storeLabelToCBOR(l store.Label) cborLabel { 58 + cl := cborLabel{ 59 + Src: l.Src, 60 + URI: l.URI, 61 + Val: l.Val, 62 + Cts: l.Cts, 63 + Sig: l.Sig, 64 + Ver: store.LabelVersion, 65 + } 66 + if l.Neg { 67 + cl.Neg = true 68 + } 69 + return cl 70 + } 71 + 72 + // encodeFrame encodes an XRPC subscription frame (header + body) as CBOR. 73 + func encodeFrame(header frameHeader, body any) ([]byte, error) { 74 + h, err := cborEnc.Marshal(header) 75 + if err != nil { 76 + return nil, err 77 + } 78 + b, err := cborEnc.Marshal(body) 79 + if err != nil { 80 + return nil, err 81 + } 82 + return append(h, b...), nil 83 + } 84 + 85 + func (s *Server) handleSubscribeLabels(w http.ResponseWriter, r *http.Request) { 86 + // Connection limit 87 + if s.wsConns.Add(1) > maxWebSocketConns { 88 + s.wsConns.Add(-1) 89 + http.Error(w, "too many connections", http.StatusServiceUnavailable) 90 + return 91 + } 92 + 93 + cursorStr := r.URL.Query().Get("cursor") 94 + var cursor int64 95 + if cursorStr != "" { 96 + n, err := strconv.ParseInt(cursorStr, 10, 64) 97 + if err != nil { 98 + s.wsConns.Add(-1) 99 + http.Error(w, "invalid cursor", http.StatusBadRequest) 100 + return 101 + } 102 + cursor = n 103 + } 104 + 105 + conn, err := upgrader.Upgrade(w, r, nil) 106 + if err != nil { 107 + s.wsConns.Add(-1) 108 + log.Printf("websocket upgrade: %v", err) 109 + return 110 + } 111 + s.trackConn(conn) 112 + defer func() { 113 + s.untrackConn(conn) 114 + conn.Close() 115 + s.wsConns.Add(-1) 116 + }() 117 + 118 + ctx := r.Context() 119 + 120 + // Backfill from cursor (capped to prevent unbounded data transfer) 121 + backfilled := 0 122 + for backfilled < maxBackfillLabels { 123 + batch := 100 124 + if remaining := maxBackfillLabels - backfilled; remaining < batch { 125 + batch = remaining 126 + } 127 + labels, err := s.store.GetLabelsSince(ctx, cursor, batch) 128 + if err != nil { 129 + log.Printf("subscribeLabels backfill: %v", err) 130 + return 131 + } 132 + if len(labels) == 0 { 133 + break 134 + } 135 + for _, l := range labels { 136 + if err := s.writeLabel(conn, l); err != nil { 137 + return 138 + } 139 + cursor = l.Seq 140 + backfilled++ 141 + } 142 + } 143 + 144 + // Live tail: wait for label insertion notifications 145 + notify := s.store.LabelNotify() 146 + fallback := time.NewTicker(30 * time.Second) 147 + defer fallback.Stop() 148 + 149 + // Read pump to detect client disconnect 150 + done := make(chan struct{}) 151 + go func() { 152 + defer close(done) 153 + for { 154 + if _, _, err := conn.ReadMessage(); err != nil { 155 + return 156 + } 157 + } 158 + }() 159 + 160 + for { 161 + select { 162 + case <-done: 163 + return 164 + case <-ctx.Done(): 165 + return 166 + case <-notify: 167 + notify = s.store.LabelNotify() 168 + case <-fallback.C: 169 + } 170 + 171 + labels, err := s.store.GetLabelsSince(ctx, cursor, 100) 172 + if err != nil { 173 + log.Printf("subscribeLabels poll: %v", err) 174 + return 175 + } 176 + for _, l := range labels { 177 + if err := s.writeLabel(conn, l); err != nil { 178 + return 179 + } 180 + cursor = l.Seq 181 + } 182 + } 183 + } 184 + 185 + // writeLabel encodes and sends a single label as an XRPC subscription frame. 186 + func (s *Server) writeLabel(conn *websocket.Conn, l store.Label) error { 187 + frame, err := encodeFrame( 188 + frameHeader{Op: 1, T: "#labels"}, 189 + labelsBody{ 190 + Seq: l.Seq, 191 + Labels: []cborLabel{storeLabelToCBOR(l)}, 192 + }, 193 + ) 194 + if err != nil { 195 + log.Printf("subscribeLabels encode: %v", err) 196 + return err 197 + } 198 + conn.SetWriteDeadline(time.Now().Add(10 * time.Second)) 199 + return conn.WriteMessage(websocket.BinaryMessage, frame) 200 + }
+435
internal/store/sqlite.go
··· 1 + package store 2 + 3 + import ( 4 + "context" 5 + "database/sql" 6 + "encoding/json" 7 + "fmt" 8 + "log" 9 + "strings" 10 + "sync" 11 + "time" 12 + 13 + _ "modernc.org/sqlite" 14 + ) 15 + 16 + type Label struct { 17 + Seq int64 18 + Src string 19 + URI string 20 + Val string 21 + Cts string 22 + Neg bool 23 + Sig []byte 24 + RawCBOR []byte 25 + } 26 + 27 + type Attestation struct { 28 + DID string 29 + RKey string // atproto record key 30 + Domain string 31 + DKIMSelectors []string 32 + RelayMember bool 33 + Verified bool 34 + LastVerified time.Time 35 + CreatedAt time.Time 36 + } 37 + 38 + // LabelVersion is the atproto label spec version. 39 + const LabelVersion int64 = 1 40 + 41 + type Store struct { 42 + db *sql.DB 43 + 44 + // Label insertion notification for WebSocket subscribers. 45 + notifyMu sync.Mutex 46 + notifyCh chan struct{} 47 + } 48 + 49 + func New(dsn string) (*Store, error) { 50 + db, err := sql.Open("sqlite", dsn) 51 + if err != nil { 52 + return nil, fmt.Errorf("open sqlite: %v", err) 53 + } 54 + if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil { 55 + db.Close() 56 + return nil, fmt.Errorf("set WAL mode: %v", err) 57 + } 58 + if _, err := db.Exec("PRAGMA busy_timeout = 5000"); err != nil { 59 + db.Close() 60 + return nil, fmt.Errorf("set busy timeout: %v", err) 61 + } 62 + if _, err := db.Exec("PRAGMA foreign_keys=ON"); err != nil { 63 + db.Close() 64 + return nil, fmt.Errorf("enable foreign keys: %v", err) 65 + } 66 + s := &Store{db: db, notifyCh: make(chan struct{})} 67 + if err := s.migrate(); err != nil { 68 + db.Close() 69 + return nil, fmt.Errorf("migrate: %v", err) 70 + } 71 + return s, nil 72 + } 73 + 74 + func (s *Store) Close() error { 75 + return s.db.Close() 76 + } 77 + 78 + // LabelNotify returns a channel that is closed when a new label is inserted. 79 + // Callers should call LabelNotify again after each close to get the next signal. 80 + func (s *Store) LabelNotify() <-chan struct{} { 81 + s.notifyMu.Lock() 82 + defer s.notifyMu.Unlock() 83 + return s.notifyCh 84 + } 85 + 86 + func (s *Store) notifyLabelInserted() { 87 + s.notifyMu.Lock() 88 + close(s.notifyCh) 89 + s.notifyCh = make(chan struct{}) 90 + s.notifyMu.Unlock() 91 + } 92 + 93 + func (s *Store) migrate() error { 94 + _, err := s.db.Exec(` 95 + CREATE TABLE IF NOT EXISTS labels ( 96 + seq INTEGER PRIMARY KEY AUTOINCREMENT, 97 + src TEXT NOT NULL, 98 + uri TEXT NOT NULL, 99 + val TEXT NOT NULL, 100 + cts TEXT NOT NULL, 101 + neg INTEGER NOT NULL DEFAULT 0, 102 + sig BLOB NOT NULL, 103 + raw_cbor BLOB NOT NULL 104 + ); 105 + CREATE INDEX IF NOT EXISTS idx_labels_uri ON labels(uri); 106 + CREATE INDEX IF NOT EXISTS idx_labels_src ON labels(src); 107 + CREATE INDEX IF NOT EXISTS idx_labels_uri_neg_seq ON labels(uri, neg, seq); 108 + 109 + CREATE TABLE IF NOT EXISTS attestations ( 110 + did TEXT NOT NULL, 111 + domain TEXT NOT NULL, 112 + rkey TEXT NOT NULL DEFAULT '', 113 + dkim_selectors TEXT NOT NULL DEFAULT '[]', 114 + relay_member INTEGER NOT NULL DEFAULT 0, 115 + verified INTEGER NOT NULL DEFAULT 0, 116 + last_verified TEXT NOT NULL DEFAULT '', 117 + created_at TEXT NOT NULL, 118 + PRIMARY KEY (did, domain) 119 + ); 120 + CREATE INDEX IF NOT EXISTS idx_attestations_did ON attestations(did); 121 + 122 + CREATE TABLE IF NOT EXISTS cursor ( 123 + id INTEGER PRIMARY KEY CHECK (id = 1), 124 + cursor INTEGER NOT NULL DEFAULT 0 125 + ); 126 + INSERT OR IGNORE INTO cursor (id, cursor) VALUES (1, 0); 127 + `) 128 + return err 129 + } 130 + 131 + // --- Labels --- 132 + 133 + func (s *Store) InsertLabel(ctx context.Context, l *Label) (int64, error) { 134 + res, err := s.db.ExecContext(ctx, 135 + `INSERT INTO labels (src, uri, val, cts, neg, sig, raw_cbor) VALUES (?, ?, ?, ?, ?, ?, ?)`, 136 + l.Src, l.URI, l.Val, l.Cts, boolToInt(l.Neg), l.Sig, l.RawCBOR, 137 + ) 138 + if err != nil { 139 + return 0, fmt.Errorf("insert label: %v", err) 140 + } 141 + s.notifyLabelInserted() 142 + return res.LastInsertId() 143 + } 144 + 145 + // GetLabelsByURIPatterns queries labels matching any of the given URI patterns 146 + // in a single query with correct global pagination. Wildcards (*) in patterns 147 + // are converted to SQL LIKE patterns (%). 148 + func (s *Store) GetLabelsByURIPatterns(ctx context.Context, patterns []string, cursor string, limit int) ([]Label, string, error) { 149 + if len(patterns) == 0 { 150 + return nil, "", nil 151 + } 152 + 153 + var conditions []string 154 + var args []any 155 + for _, p := range patterns { 156 + if strings.Contains(p, "*") { 157 + escaped := strings.ReplaceAll(p, `\`, `\\`) 158 + escaped = strings.ReplaceAll(escaped, "%", `\%`) 159 + escaped = strings.ReplaceAll(escaped, "_", `\_`) 160 + likePattern := strings.ReplaceAll(escaped, "*", "%") 161 + conditions = append(conditions, `uri LIKE ? ESCAPE '\'`) 162 + args = append(args, likePattern) 163 + } else { 164 + conditions = append(conditions, "uri = ?") 165 + args = append(args, p) 166 + } 167 + } 168 + 169 + query := `SELECT seq, src, uri, val, cts, neg, sig, raw_cbor FROM labels WHERE (` + strings.Join(conditions, " OR ") + `)` 170 + if cursor != "" { 171 + query += ` AND seq > ?` 172 + args = append(args, cursor) 173 + } 174 + query += ` ORDER BY seq ASC LIMIT ?` 175 + args = append(args, limit) 176 + 177 + return s.queryLabels(ctx, query, args) 178 + } 179 + 180 + func (s *Store) GetLabelsSince(ctx context.Context, seq int64, limit int) ([]Label, error) { 181 + query := `SELECT seq, src, uri, val, cts, neg, sig, raw_cbor FROM labels WHERE seq > ? ORDER BY seq ASC LIMIT ?` 182 + labels, _, err := s.queryLabels(ctx, query, []any{seq, limit}) 183 + return labels, err 184 + } 185 + 186 + // GetActiveLabelsForDID returns labels currently active for the given DID. 187 + // INVARIANT: label URI always equals the subject DID (set by ReconcileLabels). 188 + // This allows us to query by URI = DID. If labels are ever created with a 189 + // different URI format (e.g. AT-URI), this query must be updated. 190 + func (s *Store) GetActiveLabelsForDID(ctx context.Context, did string) ([]Label, error) { 191 + // A label is active if there is no later negation for the same (src, uri, val). 192 + query := ` 193 + SELECT l.seq, l.src, l.uri, l.val, l.cts, l.neg, l.sig, l.raw_cbor 194 + FROM labels l 195 + WHERE l.uri = ? AND l.neg = 0 196 + AND NOT EXISTS ( 197 + SELECT 1 FROM labels n 198 + WHERE n.uri = l.uri AND n.src = l.src AND n.val = l.val 199 + AND n.neg = 1 AND n.seq > l.seq 200 + ) 201 + ORDER BY l.seq ASC 202 + ` 203 + labels, _, err := s.queryLabels(ctx, query, []any{did}) 204 + return labels, err 205 + } 206 + 207 + func (s *Store) queryLabels(ctx context.Context, query string, args []any) ([]Label, string, error) { 208 + rows, err := s.db.QueryContext(ctx, query, args...) 209 + if err != nil { 210 + return nil, "", fmt.Errorf("query labels: %v", err) 211 + } 212 + defer rows.Close() 213 + 214 + var labels []Label 215 + var lastSeq int64 216 + for rows.Next() { 217 + var l Label 218 + var neg int 219 + if err := rows.Scan(&l.Seq, &l.Src, &l.URI, &l.Val, &l.Cts, &neg, &l.Sig, &l.RawCBOR); err != nil { 220 + return nil, "", fmt.Errorf("scan label: %v", err) 221 + } 222 + l.Neg = neg != 0 223 + lastSeq = l.Seq 224 + labels = append(labels, l) 225 + } 226 + 227 + var nextCursor string 228 + if lastSeq > 0 { 229 + nextCursor = fmt.Sprintf("%d", lastSeq) 230 + } 231 + return labels, nextCursor, rows.Err() 232 + } 233 + 234 + // --- Attestations --- 235 + 236 + func (s *Store) UpsertAttestation(ctx context.Context, a *Attestation) error { 237 + selectors, err := json.Marshal(a.DKIMSelectors) 238 + if err != nil { 239 + return fmt.Errorf("marshal selectors: %v", err) 240 + } 241 + _, err = s.db.ExecContext(ctx, 242 + `INSERT INTO attestations (did, domain, rkey, dkim_selectors, relay_member, verified, last_verified, created_at) 243 + VALUES (?, ?, ?, ?, ?, ?, ?, ?) 244 + ON CONFLICT(did, domain) DO UPDATE SET 245 + rkey = excluded.rkey, 246 + dkim_selectors = excluded.dkim_selectors, 247 + relay_member = excluded.relay_member, 248 + verified = excluded.verified, 249 + last_verified = excluded.last_verified`, 250 + a.DID, a.Domain, a.RKey, string(selectors), boolToInt(a.RelayMember), 251 + boolToInt(a.Verified), formatTime(a.LastVerified), formatTime(a.CreatedAt), 252 + ) 253 + if err != nil { 254 + return fmt.Errorf("upsert attestation: %v", err) 255 + } 256 + return nil 257 + } 258 + 259 + func (s *Store) GetAttestation(ctx context.Context, did, domain string) (*Attestation, error) { 260 + row := s.db.QueryRowContext(ctx, 261 + `SELECT did, domain, rkey, dkim_selectors, relay_member, verified, last_verified, created_at 262 + FROM attestations WHERE did = ? AND domain = ?`, 263 + did, domain, 264 + ) 265 + return scanAttestationFrom(row) 266 + } 267 + 268 + func (s *Store) DeleteAttestation(ctx context.Context, did, domain string) error { 269 + _, err := s.db.ExecContext(ctx, 270 + `DELETE FROM attestations WHERE did = ? AND domain = ?`, did, domain, 271 + ) 272 + return err 273 + } 274 + 275 + // DeleteAttestationByRKey deletes an attestation by its atproto record key. 276 + // Used for Jetstream delete events where only the rkey is available. 277 + func (s *Store) DeleteAttestationByRKey(ctx context.Context, did, rkey string) error { 278 + _, err := s.db.ExecContext(ctx, 279 + `DELETE FROM attestations WHERE did = ? AND rkey = ?`, did, rkey, 280 + ) 281 + return err 282 + } 283 + 284 + // GetAttestationsForDID returns all attestations for a given DID. 285 + // Used by ReconcileLabels to compute the desired label set. 286 + func (s *Store) GetAttestationsForDID(ctx context.Context, did string) ([]Attestation, error) { 287 + rows, err := s.db.QueryContext(ctx, 288 + `SELECT did, domain, rkey, dkim_selectors, relay_member, verified, last_verified, created_at 289 + FROM attestations WHERE did = ?`, did, 290 + ) 291 + if err != nil { 292 + return nil, fmt.Errorf("get attestations for DID: %v", err) 293 + } 294 + defer rows.Close() 295 + 296 + var atts []Attestation 297 + for rows.Next() { 298 + a, err := scanAttestationFrom(rows) 299 + if err != nil { 300 + return nil, err 301 + } 302 + atts = append(atts, *a) 303 + } 304 + return atts, rows.Err() 305 + } 306 + 307 + func (s *Store) ListAttestations(ctx context.Context) ([]Attestation, error) { 308 + rows, err := s.db.QueryContext(ctx, 309 + `SELECT did, domain, rkey, dkim_selectors, relay_member, verified, last_verified, created_at FROM attestations`, 310 + ) 311 + if err != nil { 312 + return nil, fmt.Errorf("list attestations: %v", err) 313 + } 314 + defer rows.Close() 315 + 316 + var atts []Attestation 317 + for rows.Next() { 318 + a, err := scanAttestationFrom(rows) 319 + if err != nil { 320 + return nil, err 321 + } 322 + atts = append(atts, *a) 323 + } 324 + return atts, rows.Err() 325 + } 326 + 327 + func (s *Store) SetVerified(ctx context.Context, did, domain string, verified bool) error { 328 + _, err := s.db.ExecContext(ctx, 329 + `UPDATE attestations SET verified = ?, last_verified = ? WHERE did = ? AND domain = ?`, 330 + boolToInt(verified), formatTime(time.Now().UTC()), did, domain, 331 + ) 332 + return err 333 + } 334 + 335 + // scanner is satisfied by both *sql.Row and *sql.Rows. 336 + type scanner interface { 337 + Scan(dest ...any) error 338 + } 339 + 340 + func scanAttestationFrom(sc scanner) (*Attestation, error) { 341 + var a Attestation 342 + var selJSON string 343 + var relay, verified int 344 + var lastVerified, createdAt string 345 + 346 + err := sc.Scan(&a.DID, &a.Domain, &a.RKey, &selJSON, &relay, &verified, &lastVerified, &createdAt) 347 + if err == sql.ErrNoRows { 348 + return nil, nil 349 + } 350 + if err != nil { 351 + return nil, fmt.Errorf("scan attestation: %v", err) 352 + } 353 + 354 + if err := json.Unmarshal([]byte(selJSON), &a.DKIMSelectors); err != nil { 355 + return nil, fmt.Errorf("unmarshal selectors: %v", err) 356 + } 357 + a.RelayMember = relay != 0 358 + a.Verified = verified != 0 359 + a.LastVerified = parseTime(lastVerified) 360 + a.CreatedAt = parseTime(createdAt) 361 + return &a, nil 362 + } 363 + 364 + // --- Cursor --- 365 + 366 + func (s *Store) GetCursor(ctx context.Context) (int64, error) { 367 + var cursor int64 368 + err := s.db.QueryRowContext(ctx, `SELECT cursor FROM cursor WHERE id = 1`).Scan(&cursor) 369 + if err != nil { 370 + return 0, fmt.Errorf("get cursor: %v", err) 371 + } 372 + return cursor, nil 373 + } 374 + 375 + func (s *Store) SetCursor(ctx context.Context, cursor int64) error { 376 + _, err := s.db.ExecContext(ctx, 377 + `UPDATE cursor SET cursor = ? WHERE id = 1`, cursor, 378 + ) 379 + return err 380 + } 381 + 382 + // --- Stats --- 383 + 384 + // Stats holds counts for metrics. 385 + type Stats struct { 386 + Labels int64 387 + Attestations int64 388 + } 389 + 390 + // Stats returns aggregate counts for metrics. 391 + func (s *Store) Stats(ctx context.Context) (Stats, error) { 392 + var st Stats 393 + err := s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM labels`).Scan(&st.Labels) 394 + if err != nil { 395 + return st, fmt.Errorf("count labels: %v", err) 396 + } 397 + err = s.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM attestations`).Scan(&st.Attestations) 398 + if err != nil { 399 + return st, fmt.Errorf("count attestations: %v", err) 400 + } 401 + return st, nil 402 + } 403 + 404 + // Ping verifies the database is reachable. 405 + func (s *Store) Ping(ctx context.Context) error { 406 + return s.db.PingContext(ctx) 407 + } 408 + 409 + // --- helpers --- 410 + 411 + func boolToInt(b bool) int { 412 + if b { 413 + return 1 414 + } 415 + return 0 416 + } 417 + 418 + func formatTime(t time.Time) string { 419 + if t.IsZero() { 420 + return "" 421 + } 422 + return t.Format(time.RFC3339) 423 + } 424 + 425 + func parseTime(s string) time.Time { 426 + if s == "" { 427 + return time.Time{} 428 + } 429 + t, err := time.Parse(time.RFC3339, s) 430 + if err != nil { 431 + log.Printf("parseTime: invalid RFC3339 value %q: %v", s, err) 432 + return time.Time{} 433 + } 434 + return t 435 + }
+476
internal/store/sqlite_test.go
··· 1 + package store 2 + 3 + import ( 4 + "context" 5 + "testing" 6 + "time" 7 + ) 8 + 9 + func testStore(t *testing.T) *Store { 10 + t.Helper() 11 + s, err := New(":memory:") 12 + if err != nil { 13 + t.Fatalf("New: %v", err) 14 + } 15 + t.Cleanup(func() { s.Close() }) 16 + return s 17 + } 18 + 19 + func TestNewCreatesSchema(t *testing.T) { 20 + s := testStore(t) 21 + // Verify tables exist by running queries against them. 22 + ctx := context.Background() 23 + _, err := s.GetCursor(ctx) 24 + if err != nil { 25 + t.Fatalf("GetCursor on fresh DB: %v", err) 26 + } 27 + } 28 + 29 + func TestAttestationCRUD(t *testing.T) { 30 + s := testStore(t) 31 + ctx := context.Background() 32 + 33 + att := &Attestation{ 34 + DID: "did:plc:test123", 35 + Domain: "example.com", 36 + DKIMSelectors: []string{"sel1", "sel2"}, 37 + RelayMember: true, 38 + CreatedAt: time.Now().UTC().Truncate(time.Second), 39 + } 40 + 41 + // Insert 42 + if err := s.UpsertAttestation(ctx, att); err != nil { 43 + t.Fatalf("UpsertAttestation: %v", err) 44 + } 45 + 46 + // Read 47 + got, err := s.GetAttestation(ctx, att.DID, att.Domain) 48 + if err != nil { 49 + t.Fatalf("GetAttestation: %v", err) 50 + } 51 + if got.DID != att.DID { 52 + t.Errorf("DID = %q, want %q", got.DID, att.DID) 53 + } 54 + if got.Domain != att.Domain { 55 + t.Errorf("Domain = %q, want %q", got.Domain, att.Domain) 56 + } 57 + if len(got.DKIMSelectors) != 2 || got.DKIMSelectors[0] != "sel1" { 58 + t.Errorf("DKIMSelectors = %v, want [sel1 sel2]", got.DKIMSelectors) 59 + } 60 + if !got.RelayMember { 61 + t.Error("RelayMember = false, want true") 62 + } 63 + 64 + // Update 65 + att.DKIMSelectors = []string{"sel3"} 66 + att.RelayMember = false 67 + if err := s.UpsertAttestation(ctx, att); err != nil { 68 + t.Fatalf("UpsertAttestation (update): %v", err) 69 + } 70 + got, err = s.GetAttestation(ctx, att.DID, att.Domain) 71 + if err != nil { 72 + t.Fatalf("GetAttestation after update: %v", err) 73 + } 74 + if len(got.DKIMSelectors) != 1 || got.DKIMSelectors[0] != "sel3" { 75 + t.Errorf("updated DKIMSelectors = %v, want [sel3]", got.DKIMSelectors) 76 + } 77 + if got.RelayMember { 78 + t.Error("updated RelayMember = true, want false") 79 + } 80 + 81 + // Delete 82 + if err := s.DeleteAttestation(ctx, att.DID, att.Domain); err != nil { 83 + t.Fatalf("DeleteAttestation: %v", err) 84 + } 85 + got, err = s.GetAttestation(ctx, att.DID, att.Domain) 86 + if err != nil { 87 + t.Fatalf("GetAttestation after delete: %v", err) 88 + } 89 + if got != nil { 90 + t.Error("expected nil after delete") 91 + } 92 + } 93 + 94 + func TestLabelCRUD(t *testing.T) { 95 + s := testStore(t) 96 + ctx := context.Background() 97 + 98 + label := &Label{ 99 + Src: "did:key:zTest", 100 + URI: "did:plc:test123", 101 + Val: "verified-mail-operator", 102 + Cts: time.Now().UTC().Format(time.RFC3339), 103 + Neg: false, 104 + Sig: []byte("fakesig"), 105 + RawCBOR: []byte("fakeraw"), 106 + } 107 + 108 + seq, err := s.InsertLabel(ctx, label) 109 + if err != nil { 110 + t.Fatalf("InsertLabel: %v", err) 111 + } 112 + if seq < 1 { 113 + t.Errorf("seq = %d, want >= 1", seq) 114 + } 115 + 116 + // Query by URI pattern (exact match) 117 + labels, _, err := s.GetLabelsByURIPatterns(ctx, []string{"did:plc:test123"}, "", 50) 118 + if err != nil { 119 + t.Fatalf("GetLabelsByURIPatterns: %v", err) 120 + } 121 + if len(labels) != 1 { 122 + t.Fatalf("GetLabelsByURIPatterns returned %d labels, want 1", len(labels)) 123 + } 124 + if labels[0].Val != "verified-mail-operator" { 125 + t.Errorf("Val = %q, want %q", labels[0].Val, "verified-mail-operator") 126 + } 127 + 128 + // Query since seq 129 + labels, err = s.GetLabelsSince(ctx, 0, 50) 130 + if err != nil { 131 + t.Fatalf("GetLabelsSince: %v", err) 132 + } 133 + if len(labels) != 1 { 134 + t.Fatalf("GetLabelsSince returned %d labels, want 1", len(labels)) 135 + } 136 + } 137 + 138 + func TestCursor(t *testing.T) { 139 + s := testStore(t) 140 + ctx := context.Background() 141 + 142 + // Initial cursor is 0 143 + cursor, err := s.GetCursor(ctx) 144 + if err != nil { 145 + t.Fatalf("GetCursor: %v", err) 146 + } 147 + if cursor != 0 { 148 + t.Errorf("initial cursor = %d, want 0", cursor) 149 + } 150 + 151 + // Set and read back 152 + if err := s.SetCursor(ctx, 12345); err != nil { 153 + t.Fatalf("SetCursor: %v", err) 154 + } 155 + cursor, err = s.GetCursor(ctx) 156 + if err != nil { 157 + t.Fatalf("GetCursor after set: %v", err) 158 + } 159 + if cursor != 12345 { 160 + t.Errorf("cursor = %d, want 12345", cursor) 161 + } 162 + 163 + // Update 164 + if err := s.SetCursor(ctx, 99999); err != nil { 165 + t.Fatalf("SetCursor update: %v", err) 166 + } 167 + cursor, err = s.GetCursor(ctx) 168 + if err != nil { 169 + t.Fatalf("GetCursor after update: %v", err) 170 + } 171 + if cursor != 99999 { 172 + t.Errorf("cursor = %d, want 99999", cursor) 173 + } 174 + } 175 + 176 + func TestListAttestations(t *testing.T) { 177 + s := testStore(t) 178 + ctx := context.Background() 179 + 180 + for _, domain := range []string{"a.com", "b.com", "c.com"} { 181 + if err := s.UpsertAttestation(ctx, &Attestation{ 182 + DID: "did:plc:test", 183 + Domain: domain, 184 + DKIMSelectors: []string{"default"}, 185 + CreatedAt: time.Now().UTC(), 186 + }); err != nil { 187 + t.Fatalf("UpsertAttestation(%s): %v", domain, err) 188 + } 189 + } 190 + 191 + atts, err := s.ListAttestations(ctx) 192 + if err != nil { 193 + t.Fatalf("ListAttestations: %v", err) 194 + } 195 + if len(atts) != 3 { 196 + t.Errorf("ListAttestations returned %d, want 3", len(atts)) 197 + } 198 + } 199 + 200 + func TestGetAttestationsForDID(t *testing.T) { 201 + s := testStore(t) 202 + ctx := context.Background() 203 + 204 + // Insert attestations for two DIDs 205 + for _, domain := range []string{"a.com", "b.com"} { 206 + if err := s.UpsertAttestation(ctx, &Attestation{ 207 + DID: "did:plc:target", 208 + Domain: domain, 209 + DKIMSelectors: []string{"default"}, 210 + CreatedAt: time.Now().UTC(), 211 + }); err != nil { 212 + t.Fatal(err) 213 + } 214 + } 215 + // Different DID — should NOT appear 216 + if err := s.UpsertAttestation(ctx, &Attestation{ 217 + DID: "did:plc:other", 218 + Domain: "other.com", 219 + DKIMSelectors: []string{"default"}, 220 + CreatedAt: time.Now().UTC(), 221 + }); err != nil { 222 + t.Fatal(err) 223 + } 224 + 225 + atts, err := s.GetAttestationsForDID(ctx, "did:plc:target") 226 + if err != nil { 227 + t.Fatal(err) 228 + } 229 + if len(atts) != 2 { 230 + t.Fatalf("got %d attestations, want 2", len(atts)) 231 + } 232 + domains := map[string]bool{} 233 + for _, a := range atts { 234 + domains[a.Domain] = true 235 + } 236 + if !domains["a.com"] || !domains["b.com"] { 237 + t.Errorf("domains = %v, want a.com and b.com", domains) 238 + } 239 + 240 + // Empty result for unknown DID 241 + atts, err = s.GetAttestationsForDID(ctx, "did:plc:nobody") 242 + if err != nil { 243 + t.Fatal(err) 244 + } 245 + if len(atts) != 0 { 246 + t.Errorf("got %d attestations for unknown DID, want 0", len(atts)) 247 + } 248 + } 249 + 250 + func TestDeleteAttestationByRKey(t *testing.T) { 251 + s := testStore(t) 252 + ctx := context.Background() 253 + 254 + if err := s.UpsertAttestation(ctx, &Attestation{ 255 + DID: "did:plc:test", 256 + RKey: "my-rkey-123", 257 + Domain: "example.com", 258 + DKIMSelectors: []string{"default"}, 259 + CreatedAt: time.Now().UTC(), 260 + }); err != nil { 261 + t.Fatal(err) 262 + } 263 + 264 + // Delete by rkey 265 + if err := s.DeleteAttestationByRKey(ctx, "did:plc:test", "my-rkey-123"); err != nil { 266 + t.Fatal(err) 267 + } 268 + 269 + got, err := s.GetAttestation(ctx, "did:plc:test", "example.com") 270 + if err != nil { 271 + t.Fatal(err) 272 + } 273 + if got != nil { 274 + t.Error("attestation should be deleted by rkey") 275 + } 276 + 277 + // Delete by wrong rkey — should be no-op 278 + if err := s.UpsertAttestation(ctx, &Attestation{ 279 + DID: "did:plc:test", 280 + RKey: "correct-rkey", 281 + Domain: "keep.com", 282 + DKIMSelectors: []string{"default"}, 283 + CreatedAt: time.Now().UTC(), 284 + }); err != nil { 285 + t.Fatal(err) 286 + } 287 + if err := s.DeleteAttestationByRKey(ctx, "did:plc:test", "wrong-rkey"); err != nil { 288 + t.Fatal(err) 289 + } 290 + got, err = s.GetAttestation(ctx, "did:plc:test", "keep.com") 291 + if err != nil { 292 + t.Fatal(err) 293 + } 294 + if got == nil { 295 + t.Error("attestation with different rkey should NOT be deleted") 296 + } 297 + } 298 + 299 + func TestGetLabelsByURIPatterns(t *testing.T) { 300 + s := testStore(t) 301 + ctx := context.Background() 302 + 303 + for _, uri := range []string{"did:plc:aaa", "did:plc:bbb", "did:web:ccc"} { 304 + if _, err := s.InsertLabel(ctx, &Label{ 305 + Src: "did:key:zTest", URI: uri, Val: "verified-mail-operator", 306 + Cts: time.Now().UTC().Format(time.RFC3339), Sig: []byte("sig"), RawCBOR: []byte("raw"), 307 + }); err != nil { 308 + t.Fatal(err) 309 + } 310 + } 311 + 312 + // Wildcard matching did:plc:* 313 + labels, _, err := s.GetLabelsByURIPatterns(ctx, []string{"did:plc:*"}, "", 50) 314 + if err != nil { 315 + t.Fatal(err) 316 + } 317 + if len(labels) != 2 { 318 + t.Fatalf("did:plc:* got %d labels, want 2", len(labels)) 319 + } 320 + 321 + // Full wildcard 322 + labels, _, err = s.GetLabelsByURIPatterns(ctx, []string{"*"}, "", 50) 323 + if err != nil { 324 + t.Fatal(err) 325 + } 326 + if len(labels) != 3 { 327 + t.Fatalf("* got %d labels, want 3", len(labels)) 328 + } 329 + 330 + // No match 331 + labels, _, err = s.GetLabelsByURIPatterns(ctx, []string{"did:plc:zzz*"}, "", 50) 332 + if err != nil { 333 + t.Fatal(err) 334 + } 335 + if len(labels) != 0 { 336 + t.Errorf("zzz* got %d labels, want 0", len(labels)) 337 + } 338 + 339 + // Multiple patterns in one query 340 + labels, _, err = s.GetLabelsByURIPatterns(ctx, []string{"did:plc:aaa", "did:web:*"}, "", 50) 341 + if err != nil { 342 + t.Fatal(err) 343 + } 344 + if len(labels) != 2 { 345 + t.Fatalf("multi-pattern got %d labels, want 2", len(labels)) 346 + } 347 + 348 + // Empty patterns returns nothing 349 + labels, _, err = s.GetLabelsByURIPatterns(ctx, []string{}, "", 50) 350 + if err != nil { 351 + t.Fatal(err) 352 + } 353 + if len(labels) != 0 { 354 + t.Errorf("empty patterns got %d labels, want 0", len(labels)) 355 + } 356 + } 357 + 358 + func TestGetLabelsByURIPatternsEscapesSQLChars(t *testing.T) { 359 + s := testStore(t) 360 + ctx := context.Background() 361 + 362 + // Insert labels with special SQL LIKE characters in URIs 363 + for _, uri := range []string{"did:plc:100%done", "did:plc:a_b", "did:plc:normal"} { 364 + if _, err := s.InsertLabel(ctx, &Label{ 365 + Src: "did:key:zTest", URI: uri, Val: "test", 366 + Cts: time.Now().UTC().Format(time.RFC3339), Sig: []byte("sig"), RawCBOR: []byte("raw"), 367 + }); err != nil { 368 + t.Fatal(err) 369 + } 370 + } 371 + 372 + // Pattern "did:plc:100%*" should only match "did:plc:100%done", not everything 373 + // (% is a SQL LIKE wildcard and must be escaped) 374 + labels, _, err := s.GetLabelsByURIPatterns(ctx, []string{"did:plc:100%*"}, "", 50) 375 + if err != nil { 376 + t.Fatal(err) 377 + } 378 + if len(labels) != 1 { 379 + t.Fatalf("100%%* got %d labels, want 1", len(labels)) 380 + } 381 + if labels[0].URI != "did:plc:100%done" { 382 + t.Errorf("URI = %q, want did:plc:100%%done", labels[0].URI) 383 + } 384 + 385 + // Pattern "did:plc:a_b" should match exactly, not treat _ as single-char wildcard 386 + labels, _, err = s.GetLabelsByURIPatterns(ctx, []string{"did:plc:a_b"}, "", 50) 387 + if err != nil { 388 + t.Fatal(err) 389 + } 390 + if len(labels) != 1 { 391 + t.Fatalf("a_b got %d labels, want 1 (exact match)", len(labels)) 392 + } 393 + } 394 + 395 + func TestRKeyStoredAndRetrieved(t *testing.T) { 396 + s := testStore(t) 397 + ctx := context.Background() 398 + 399 + if err := s.UpsertAttestation(ctx, &Attestation{ 400 + DID: "did:plc:test", 401 + RKey: "custom-rkey", 402 + Domain: "example.com", 403 + DKIMSelectors: []string{"default"}, 404 + CreatedAt: time.Now().UTC(), 405 + }); err != nil { 406 + t.Fatal(err) 407 + } 408 + 409 + got, err := s.GetAttestation(ctx, "did:plc:test", "example.com") 410 + if err != nil { 411 + t.Fatal(err) 412 + } 413 + if got.RKey != "custom-rkey" { 414 + t.Errorf("RKey = %q, want %q", got.RKey, "custom-rkey") 415 + } 416 + 417 + // Upsert with new rkey for same (did, domain) — rkey should be updated 418 + if err := s.UpsertAttestation(ctx, &Attestation{ 419 + DID: "did:plc:test", 420 + RKey: "updated-rkey", 421 + Domain: "example.com", 422 + DKIMSelectors: []string{"default"}, 423 + CreatedAt: time.Now().UTC(), 424 + }); err != nil { 425 + t.Fatal(err) 426 + } 427 + got, err = s.GetAttestation(ctx, "did:plc:test", "example.com") 428 + if err != nil { 429 + t.Fatal(err) 430 + } 431 + if got.RKey != "updated-rkey" { 432 + t.Errorf("updated RKey = %q, want %q", got.RKey, "updated-rkey") 433 + } 434 + } 435 + 436 + func TestGetActiveLabelsForDID(t *testing.T) { 437 + s := testStore(t) 438 + ctx := context.Background() 439 + 440 + // Insert a positive label 441 + _, err := s.InsertLabel(ctx, &Label{ 442 + Src: "did:key:zLabeler", 443 + URI: "did:plc:member1", 444 + Val: "verified-mail-operator", 445 + Cts: time.Now().UTC().Format(time.RFC3339), 446 + Neg: false, 447 + Sig: []byte("sig1"), 448 + RawCBOR: []byte("raw1"), 449 + }) 450 + if err != nil { 451 + t.Fatal(err) 452 + } 453 + 454 + // Insert a negation for the same label 455 + _, err = s.InsertLabel(ctx, &Label{ 456 + Src: "did:key:zLabeler", 457 + URI: "did:plc:member1", 458 + Val: "verified-mail-operator", 459 + Cts: time.Now().UTC().Format(time.RFC3339), 460 + Neg: true, 461 + Sig: []byte("sig2"), 462 + RawCBOR: []byte("raw2"), 463 + }) 464 + if err != nil { 465 + t.Fatal(err) 466 + } 467 + 468 + // Active labels should be empty — the negation cancels the positive 469 + labels, err := s.GetActiveLabelsForDID(ctx, "did:plc:member1") 470 + if err != nil { 471 + t.Fatal(err) 472 + } 473 + if len(labels) != 0 { 474 + t.Errorf("got %d active labels, want 0 (negation should cancel)", len(labels)) 475 + } 476 + }