| 1 | package memoriatest |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/binary" |
| 6 | "encoding/json" |
| 7 | "fmt" |
| 8 | "html" |
| 9 | "io" |
| 10 | "log/slog" |
| 11 | "net" |
| 12 | "net/http" |
| 13 | "net/http/httptest" |
| 14 | "net/url" |
| 15 | "path/filepath" |
| 16 | "regexp" |
| 17 | "strings" |
| 18 | stdsync "sync" |
| 19 | "testing" |
| 20 | "time" |
| 21 | |
| 22 | "amadan.net/rastrillo/rastrillo/db" |
| 23 | |
| 24 | "amadan.net/oficina/memoria/internal/memoria" |
| 25 | ) |
| 26 | |
| 27 | // fakeKeymail answers the two probes signin's classifier makes of a |
| 28 | // Keymail server — /.well-known/keymail and /api/federation/lookup — for |
| 29 | // one host, and the token exchange for the codes approve hands out, and |
| 30 | // 404s everything else. It stands in for http.DefaultTransport, which the |
| 31 | // classifier's and the callback's clients fall back to. |
| 32 | type fakeKeymail struct { |
| 33 | host string |
| 34 | mu stdsync.Mutex |
| 35 | hits []string |
| 36 | codes map[string]string |
| 37 | } |
| 38 | |
| 39 | // approve is Keymail's consent screen saying yes for address: the code |
| 40 | // the browser carries back to /auth/callback. |
| 41 | func (f *fakeKeymail) approve(address string) string { |
| 42 | f.mu.Lock() |
| 43 | defer f.mu.Unlock() |
| 44 | if f.codes == nil { |
| 45 | f.codes = map[string]string{} |
| 46 | } |
| 47 | code := fmt.Sprintf("code-%d", len(f.codes)+1) |
| 48 | f.codes[code] = address |
| 49 | return code |
| 50 | } |
| 51 | |
| 52 | func (f *fakeKeymail) RoundTrip(r *http.Request) (*http.Response, error) { |
| 53 | f.mu.Lock() |
| 54 | f.hits = append(f.hits, r.URL.Host+r.URL.Path) |
| 55 | f.mu.Unlock() |
| 56 | body, code := `{}`, http.StatusNotFound |
| 57 | if r.URL.Host == f.host { |
| 58 | switch r.URL.Path { |
| 59 | case "/.well-known/keymail": |
| 60 | body, code = `{"version":"1"}`, http.StatusOK |
| 61 | case "/api/federation/lookup": |
| 62 | code = http.StatusOK |
| 63 | case "/api/oauth/token": |
| 64 | var in map[string]string |
| 65 | _ = json.NewDecoder(r.Body).Decode(&in) |
| 66 | f.mu.Lock() |
| 67 | address, ok := f.codes[in["code"]] |
| 68 | delete(f.codes, in["code"]) |
| 69 | f.mu.Unlock() |
| 70 | body, code = `{"error":"invalid_grant"}`, http.StatusBadRequest |
| 71 | if ok && r.Method == http.MethodPost { |
| 72 | out, _ := json.Marshal(map[string]any{ |
| 73 | "access_token": "synthetic", "token_type": "bearer", |
| 74 | "address": address, "auth_time": time.Now().Unix(), |
| 75 | }) |
| 76 | body, code = string(out), http.StatusOK |
| 77 | } |
| 78 | } |
| 79 | } |
| 80 | return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body)), |
| 81 | Header: http.Header{"Content-Type": {"application/json"}}, Request: r}, nil |
| 82 | } |
| 83 | |
| 84 | // nxdomainResolver answers every DNS query NXDOMAIN — "this domain |
| 85 | // publishes no _keymail delegation" — which signin's classifier reads as |
| 86 | // "the domain itself is the Keymail candidate". Go's resolver speaks |
| 87 | // length-prefixed DNS over any conn that is not a PacketConn, which a |
| 88 | // net.Pipe is not. |
| 89 | func nxdomainResolver() *net.Resolver { |
| 90 | return &net.Resolver{PreferGo: true, Dial: func(context.Context, string, string) (net.Conn, error) { |
| 91 | client, server := net.Pipe() |
| 92 | go func() { |
| 93 | defer server.Close() |
| 94 | for { |
| 95 | var n [2]byte |
| 96 | if _, err := io.ReadFull(server, n[:]); err != nil { |
| 97 | return |
| 98 | } |
| 99 | q := make([]byte, binary.BigEndian.Uint16(n[:])) |
| 100 | if _, err := io.ReadFull(server, q); err != nil || len(q) < 12 { |
| 101 | return |
| 102 | } |
| 103 | // Header plus the question, and nothing after it. |
| 104 | i := 12 |
| 105 | for i < len(q) && q[i] != 0 { |
| 106 | i += int(q[i]) + 1 |
| 107 | } |
| 108 | i += 5 // the root label, QTYPE, QCLASS |
| 109 | if i > len(q) { |
| 110 | return |
| 111 | } |
| 112 | resp := append([]byte(nil), q[:i]...) |
| 113 | rd := binary.BigEndian.Uint16(q[2:4]) & 0x0100 |
| 114 | binary.BigEndian.PutUint16(resp[2:4], 0x8000|0x0400|rd|0x0080|3) // QR AA RD RA, NXDOMAIN |
| 115 | binary.BigEndian.PutUint16(resp[4:6], 1) |
| 116 | for _, off := range []int{6, 8, 10} { |
| 117 | binary.BigEndian.PutUint16(resp[off:off+2], 0) |
| 118 | } |
| 119 | binary.BigEndian.PutUint16(n[:], uint16(len(resp))) |
| 120 | if _, err := server.Write(append(n[:], resp...)); err != nil { |
| 121 | return |
| 122 | } |
| 123 | } |
| 124 | }() |
| 125 | return client, nil |
| 126 | }} |
| 127 | } |
| 128 | |
| 129 | // keymailSigninApp is the real app with the real sign-in wired, whose |
| 130 | // owner's domain resolves — through the fakes above — to a Keymail server. |
| 131 | func keymailSigninApp(t *testing.T) (http.Handler, *fakeKeymail) { |
| 132 | t.Helper() |
| 133 | km := &fakeKeymail{host: "example.test"} |
| 134 | return keymailSigninAppWith(t, filepath.Join(t.TempDir(), "app.db"), []string{dialTestOwner}, km), km |
| 135 | } |
| 136 | |
| 137 | // keymailSigninAppWith is keymailSigninApp over a given database and |
| 138 | // owners list and Keymail fake: two apps over one database share the |
| 139 | // pending state, so a sign-in begun under one owners list can come back |
| 140 | // under another. |
| 141 | func keymailSigninAppWith(t *testing.T, dbPath string, owners []string, km *fakeKeymail) http.Handler { |
| 142 | t.Helper() |
| 143 | logger := slog.New(slog.NewTextHandler(io.Discard, nil)) |
| 144 | d, err := db.Open(dbPath, logger) |
| 145 | if err != nil { |
| 146 | t.Fatal(err) |
| 147 | } |
| 148 | t.Cleanup(func() { d.Close() }) |
| 149 | mux, a, err := memoria.App(d, testOrigin, testDEK(t), logger) |
| 150 | if err != nil { |
| 151 | t.Fatal(err) |
| 152 | } |
| 153 | au, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{ |
| 154 | Origin: testOrigin, InstanceKey: "signin-keymail-instance-key", |
| 155 | Owners: owners, Mailer: &stubMailer{}, |
| 156 | }, logger, a) |
| 157 | if err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | a.UseAuth(au) |
| 161 | a.SetOwners(owners) |
| 162 | a.MountAuth(mux, testOrigin) |
| 163 | |
| 164 | prevT, prevR := http.DefaultTransport, net.DefaultResolver |
| 165 | http.DefaultTransport, net.DefaultResolver = km, nxdomainResolver() |
| 166 | t.Cleanup(func() { http.DefaultTransport, net.DefaultResolver = prevT, prevR }) |
| 167 | return mux |
| 168 | } |
| 169 | |
| 170 | // **Signing in with a Keymail address must be able to leave the page.** |
| 171 | // |
| 172 | // Begin answers a Keymail address with 303 to the server's /oauth/authorize, |
| 173 | // and memoria's CSP says form-action 'self' — which browsers enforce across |
| 174 | // a form submission's WHOLE redirect chain. Safari refused the redirect and |
| 175 | // the sign-in button span forever (2026-09-29, memoria.oficina.works). The |
| 176 | // POST now ends in a 200 continuation page whose navigation is not a form |
| 177 | // submission. |
| 178 | // |
| 179 | // Through rastrillo's REAL Begin and the REAL mounted route: the classifier |
| 180 | // is only faked at the network, so what is asserted is what production |
| 181 | // sends. |
| 182 | func TestAKeymailSigninCanLeaveThePage(t *testing.T) { |
| 183 | h, km := keymailSigninApp(t) |
| 184 | rr := beginSignin(t, h, dialTestOwner) |
| 185 | |
| 186 | // Presence: the classifier really took the Keymail path. Without this, |
| 187 | // a 200 below could be the magic-link page and prove nothing. |
| 188 | if !contains(km.hits, "example.test/api/federation/lookup") { |
| 189 | t.Fatalf("the classifier never reached the Keymail probes (hits %v); this proves nothing", km.hits) |
| 190 | } |
| 191 | |
| 192 | if rr.Code == http.StatusSeeOther { |
| 193 | t.Fatalf("POST /auth/begin still redirects (Location %q): form-action 'self' refuses this in the browser", rr.Header().Get("Location")) |
| 194 | } |
| 195 | if rr.Code != http.StatusOK { |
| 196 | t.Fatalf("status %d, want 200", rr.Code) |
| 197 | } |
| 198 | body := rr.Body.String() |
| 199 | |
| 200 | // The continuation navigates to the authorize URL Begin chose, for THIS |
| 201 | // origin and callback. |
| 202 | i := strings.Index(body, `http-equiv="refresh"`) |
| 203 | if i < 0 { |
| 204 | t.Fatalf("no meta refresh in the continuation page:\n%s", body) |
| 205 | } |
| 206 | for _, want := range []string{ |
| 207 | "https://example.test/oauth/authorize", |
| 208 | "client_id=" + url.QueryEscape(testOrigin), |
| 209 | "redirect_uri=" + url.QueryEscape(testOrigin+"/auth/callback"), |
| 210 | "code_challenge_method=S256", |
| 211 | } { |
| 212 | if !strings.Contains(strings.ReplaceAll(body, "&", "&"), want) { |
| 213 | t.Errorf("continuation page lacks %q", want) |
| 214 | } |
| 215 | } |
| 216 | if got := rr.Header().Get("Cache-Control"); got != "no-store" { |
| 217 | t.Errorf("Cache-Control = %q, want no-store: the page carries a one-time PKCE state", got) |
| 218 | } |
| 219 | if got := rr.Header().Get("Referrer-Policy"); got != "no-referrer" { |
| 220 | t.Errorf("Referrer-Policy = %q, want no-referrer", got) |
| 221 | } |
| 222 | // Begin's pending cookie must survive the wrapping, or the callback |
| 223 | // cannot match the flow it started. |
| 224 | if !strings.Contains(strings.Join(rr.Header().Values("Set-Cookie"), "\n"), "rastrillo_pending") { |
| 225 | t.Errorf("the pending cookie did not survive: %q", rr.Header().Values("Set-Cookie")) |
| 226 | } |
| 227 | // Rendered as a Memoria page: the layout, and its bar. (No footer: it |
| 228 | // is a sign-in page, and the footer is said only to somebody signed |
| 229 | // in.) |
| 230 | if !strings.Contains(body, "<header estilo-bar") || strings.Contains(body, "<footer") { |
| 231 | t.Error("the continuation page is not inside Memoria's layout") |
| 232 | } |
| 233 | } |
| 234 | |
| 235 | // And the paths that must NOT change: a non-owner still gets the guard's |
| 236 | // same-origin redirect with no dial (gate 33's ground), and an address with |
| 237 | // no Keymail still gets the magic link's same-origin redirect, untouched. |
| 238 | func TestSigninPathsThatDoNotLeaveAreUnchanged(t *testing.T) { |
| 239 | h, km := keymailSigninApp(t) |
| 240 | |
| 241 | rr := beginSignin(t, h, "stranger@elsewhere.test") |
| 242 | if rr.Code != http.StatusSeeOther || rr.Header().Get("Location") != "/signin?sent=1" { |
| 243 | t.Errorf("non-owner: %d %q, want 303 /signin?sent=1", rr.Code, rr.Header().Get("Location")) |
| 244 | } |
| 245 | if len(km.hits) != 0 { |
| 246 | t.Errorf("a non-owner's sign-in reached the network: %v", km.hits) |
| 247 | } |
| 248 | |
| 249 | // An owner whose domain has no Keymail (the fake 404s other hosts). |
| 250 | km.host = "nowhere.invalid" |
| 251 | rr = beginSignin(t, h, dialTestOwner) |
| 252 | if rr.Code != http.StatusSeeOther || !strings.HasPrefix(rr.Header().Get("Location"), "/") { |
| 253 | t.Errorf("magic-link path: %d %q, want a same-origin 303", rr.Code, rr.Header().Get("Location")) |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | // authorizeHref is the authorize URL a continuation page carries. |
| 258 | var authorizeHref = regexp.MustCompile(`href="(https://example\.test/oauth/authorize\?[^"]+)"`) |
| 259 | |
| 260 | // A Keymail sign-in that comes back verified for an address Admit refuses |
| 261 | // is the door, in auth's own 403, through the Refused hook (app.refused): |
| 262 | // not rastrillo's bare text, not a redirect to a query anybody could |
| 263 | // type, and no session. beginGuard refuses a non-owner before Begin |
| 264 | // (gate 33), so the refusal is reached the way the magic link's is: the |
| 265 | // address is an owner when the sign-in starts and is not one when it |
| 266 | // comes back. The admitted half runs first, through the same continuation |
| 267 | // page, and its session opens the index — or the refusal proves nothing. |
| 268 | func TestKeymailRefusalIsTheDoor(t *testing.T) { |
| 269 | km := &fakeKeymail{host: "example.test"} |
| 270 | dbPath := filepath.Join(t.TempDir(), "app.db") |
| 271 | h := keymailSigninAppWith(t, dbPath, []string{dialTestOwner}, km) |
| 272 | |
| 273 | // begin starts a Keymail sign-in on h and returns the browser that |
| 274 | // carries it and the callback Keymail would send it to. |
| 275 | begin := func() (*client, string) { |
| 276 | t.Helper() |
| 277 | c := newClient(t, h) |
| 278 | rr := c.do(http.MethodPost, testOrigin+"/auth/begin", url.Values{"address": {dialTestOwner}}) |
| 279 | m := authorizeHref.FindStringSubmatch(rr.Body.String()) |
| 280 | if rr.Code != http.StatusOK || m == nil { |
| 281 | t.Fatalf("POST /auth/begin = %d with no Keymail continuation:\n%s", rr.Code, rr.Body.String()) |
| 282 | } |
| 283 | u, err := url.Parse(html.UnescapeString(m[1])) |
| 284 | if err != nil { |
| 285 | t.Fatal(err) |
| 286 | } |
| 287 | return c, testOrigin + "/auth/callback?" + url.Values{"state": {u.Query().Get("state")}, "code": {km.approve(dialTestOwner)}}.Encode() |
| 288 | } |
| 289 | |
| 290 | c, callback := begin() |
| 291 | cb := c.get(callback) |
| 292 | if cb.Code != http.StatusSeeOther || !sessionSet(cb) { |
| 293 | t.Fatalf("admitted Keymail callback = %d (session %v), want 303 and a session:\n%s", cb.Code, sessionSet(cb), cb.Body.String()) |
| 294 | } |
| 295 | dest := cb.Header().Get("Location") |
| 296 | for i := 0; i < 3 && dest != "/"; i++ { |
| 297 | dest = location(t, c.get(testOrigin+dest)) |
| 298 | } |
| 299 | if got := c.get(testOrigin + "/"); dest != "/" || got.Code != http.StatusOK || !strings.Contains(got.Body.String(), "estilo-account") { |
| 300 | t.Fatalf("the Keymail session did not open the index (landed %q, GET / = %d)", dest, got.Code) |
| 301 | } |
| 302 | |
| 303 | // Begun as an owner; back after the owner was removed. |
| 304 | c, callback = begin() |
| 305 | c.h = keymailSigninAppWith(t, dbPath, []string{"someone-else@example.test"}, km) |
| 306 | cb = c.get(callback) |
| 307 | if cb.Code != http.StatusForbidden || cb.Header().Get("Location") != "" || sessionSet(cb) { |
| 308 | t.Fatalf("refused Keymail callback = %d %q (session %v), want 403 and the door itself", cb.Code, cb.Header().Get("Location"), sessionSet(cb)) |
| 309 | } |
| 310 | door := html.UnescapeString(cb.Body.String()) |
| 311 | for _, want := range []string{"estilo-door", `name="address"`, `rst-tone="negative"`, "That address can’t sign in to Memoria."} { |
| 312 | if !strings.Contains(door, want) { |
| 313 | t.Errorf("the refusal door lacks %q:\n%s", want, door) |
| 314 | } |
| 315 | } |
| 316 | if strings.Contains(door, "verified but not admitted") { |
| 317 | t.Error("the refusal is still rastrillo's bare text") |
| 318 | } |
| 319 | // The sentence means "you proved this address and it is not |
| 320 | // admitted": nobody may summon it by typing a URL. |
| 321 | if spoof := html.UnescapeString(newClient(t, h).get(testOrigin + "/signin?err=refused").Body.String()); strings.Contains(spoof, "can’t sign in to Memoria") { |
| 322 | t.Error("anybody can show the refusal by visiting /signin?err=refused") |
| 323 | } |
| 324 | } |
| 325 | |
| 326 | func contains(xs []string, want string) bool { |
| 327 | for _, x := range xs { |
| 328 | if x == want { |
| 329 | return true |
| 330 | } |
| 331 | } |
| 332 | return false |
| 333 | } |
| 334 | |
| 335 | var _ = httptest.NewRecorder |
| 336 | |