carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3// Passkey step-up (stepup.go, spec §7a). The properties under test are the
4// ones that are easy to lose and expensive to lose: the gate cannot be skipped
5// server-side, the held action cannot be swapped or replayed, the whole thing
6// fails CLOSED when Keymail can't or won't re-authenticate, and it never
7// becomes an oracle telling a stranger which accounts exist.
8
9import (
10 "net/http"
11 "net/url"
12 "strconv"
13 "strings"
14 "testing"
15 "time"
16)
17
18// completeStepUp plays the browser's half of a re-authentication: it takes the
19// redirect the gate produced, checks the console really asked Keymail for a
20// fresh gesture, and lands the callback. What comes back is the RESUMED
21// action's own response — the point of the whole round trip.
22func completeStepUp(t *testing.T, fx *consoleFixture, c *http.Client, loc string) (status int, location, body string) {
23 t.Helper()
24 u, err := url.Parse(loc)
25 if err != nil {
26 t.Fatal(err)
27 }
28 km, _ := url.Parse(fx.fk.srv.URL)
29 if u.Host != km.Host || u.Path != "/oauth/authorize" {
30 t.Fatalf("step-up redirect went to %v, want %s/oauth/authorize", u, km.Host)
31 }
32 q := u.Query()
33 if q.Get("prompt") != "login" {
34 t.Fatalf("step-up authorize did not ask for a fresh gesture: %v", q)
35 }
36 if q.Get("max_age") != strconv.Itoa(int(StepUpWindow.Seconds())) {
37 t.Fatalf("step-up max_age = %q, want %d", q.Get("max_age"), int(StepUpWindow.Seconds()))
38 }
39 if q.Get("redirect_uri") != fx.ts.URL+"/auth/stepup/callback" {
40 t.Fatalf("step-up redirect_uri = %q, want the step-up callback", q.Get("redirect_uri"))
41 }
42 code := "stepup-" + q.Get("state")[:8]
43 fx.fk.mu.Lock()
44 fx.fk.challenge = q.Get("code_challenge")
45 fx.fk.expectCode = code
46 if fx.fk.authTime.IsZero() {
47 fx.fk.authTime = time.Now()
48 }
49 fx.fk.mu.Unlock()
50 return get(t, c, fx.ts.URL+"/auth/stepup/callback?code="+url.QueryEscape(code)+
51 "&state="+url.QueryEscape(q.Get("state")))
52}
53
54// postGated is postForm for an action behind the step-up gate: it transparently
55// walks the passkey round trip when one is demanded, and is otherwise postForm.
56func postGated(t *testing.T, fx *consoleFixture, c *http.Client, u string, form url.Values) (int, string, string) {
57 t.Helper()
58 status, loc, body := postForm(t, c, u, form)
59 if status == http.StatusSeeOther && strings.HasPrefix(loc, fx.fk.srv.URL) {
60 return completeStepUp(t, fx, c, loc)
61 }
62 return status, loc, body
63}
64
65// memberFixture signs in an owner and adds a second member, which is the
66// smallest world in which removal, transfer and deletion all mean something.
67func memberFixture(t *testing.T) (*consoleFixture, *http.Client, string) {
68 t.Helper()
69 fx := newConsoleFixture(t)
70 c := newBrowser(t)
71 signUp(t, fx, c, "code-stepup")
72 if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
73 t.Fatal(err)
74 }
75 return fx, c, sessionToken(t, fx, c)
76}
77
78// TestStepUpIsUnskippable is the whole feature in one test: a signed-in owner
79// with a perfectly good CSRF token cannot remove a member by POSTing. The gate
80// is server-side state, not a button that renders differently.
81func TestStepUpIsUnskippable(t *testing.T) {
82 fx, c, csrf := memberFixture(t)
83
84 status, loc, _ := postForm(t, c, fx.u("/members/remove"),
85 url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}})
86 if status != http.StatusSeeOther || !strings.HasPrefix(loc, fx.fk.srv.URL) {
87 t.Fatalf("un-stepped-up removal = %d -> %q, want a redirect into Keymail", status, loc)
88 }
89 // Nothing happened yet — the redirect is a question, not a deferral.
90 if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleMember {
91 t.Fatalf("membership changed before the passkey confirmation (role %q)", role)
92 }
93 // The held action is parked, and it did NOT park a live session token with
94 // it: sessions are stored hashed here, and a form blob is no exemption.
95 var stashed, leaked int
96 fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&stashed)
97 fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions WHERE form LIKE ?`, "%"+csrf+"%").Scan(&leaked)
98 if stashed != 1 {
99 t.Fatalf("%d pending actions parked, want exactly 1", stashed)
100 }
101 if leaked != 0 {
102 t.Fatal("a pending action stored the raw session token")
103 }
104
105 // Complete the gesture: the held action resumes and lands.
106 status, loc, body := completeStepUp(t, fx, c, loc)
107 if status != http.StatusSeeOther {
108 t.Fatalf("resumed removal = %d -> %q:\n%s", status, loc, body)
109 }
110 if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != "" {
111 t.Fatalf("kim is still a %q after a confirmed removal", role)
112 }
113
114 // And presence persists for the window, so a second action in the same
115 // few minutes does not demand a second touch.
116 if err := fx.st.AddMember(OpsAccountID, "kim@keymail.dev", RoleMember, time.Now()); err != nil {
117 t.Fatal(err)
118 }
119 if status, _, _ := postForm(t, c, fx.u("/members/remove"),
120 url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}); status != http.StatusSeeOther {
121 t.Fatalf("second removal inside the window = %d, want 303", status)
122 }
123 if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != "" {
124 t.Fatalf("second removal did not take (role %q)", role)
125 }
126}
127
128// TestStepUpFailsClosed covers every way the identity provider can decline to
129// prove presence. Not one of them may end with the action going through.
130func TestStepUpFailsClosed(t *testing.T) {
131 t.Run("keymail cannot re-authenticate", func(t *testing.T) {
132 fx, c, csrf := memberFixture(t)
133 fx.fk.mu.Lock()
134 fx.fk.reauth = false // a deployment whose IdP lacks prompt=login
135 fx.fk.mu.Unlock()
136 status, _, body := postForm(t, c, fx.u("/members/remove"),
137 url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}})
138 if status != http.StatusServiceUnavailable {
139 t.Fatalf("removal against a step-up-less Keymail = %d, want 503", status)
140 }
141 if !strings.Contains(body, "passkey") {
142 t.Fatalf("the refusal does not say why:\n%s", body)
143 }
144 if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleMember {
145 t.Fatal("membership changed despite a fail-closed refusal")
146 }
147 })
148
149 t.Run("keymail unreachable", func(t *testing.T) {
150 fx, c, csrf := memberFixture(t)
151 fx.fk.srv.Close() // the IdP is simply not there
152 status, _, _ := postForm(t, c, fx.u("/members/remove"),
153 url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}})
154 if status != http.StatusServiceUnavailable {
155 t.Fatalf("removal with Keymail down = %d, want 503", status)
156 }
157 if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleMember {
158 t.Fatal("membership changed despite Keymail being unreachable")
159 }
160 })
161
162 t.Run("no auth_time in the grant", func(t *testing.T) {
163 // The subtle one: Keymail says it supports step-up, the ceremony
164 // completes, and the token response carries no auth_time. That is not
165 // proof of anything and must not be treated as any.
166 fx, c, csrf := memberFixture(t)
167 status, loc, _ := postForm(t, c, fx.u("/members/remove"),
168 url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}})
169 if status != http.StatusSeeOther {
170 t.Fatalf("gate = %d, want 303", status)
171 }
172 u, _ := url.Parse(loc)
173 q := u.Query()
174 fx.fk.mu.Lock()
175 fx.fk.challenge, fx.fk.expectCode = q.Get("code_challenge"), "nostamp"
176 fx.fk.authTime = time.Time{} // reports nothing
177 fx.fk.mu.Unlock()
178 status, _, _ = get(t, c, fx.ts.URL+"/auth/stepup/callback?code=nostamp&state="+url.QueryEscape(q.Get("state")))
179 if status != http.StatusForbidden {
180 t.Fatalf("callback with no auth_time = %d, want 403", status)
181 }
182 if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleMember {
183 t.Fatal("membership changed on an auth_time-less grant")
184 }
185 })
186
187 t.Run("stale auth_time", func(t *testing.T) {
188 fx, c, csrf := memberFixture(t)
189 fx.fk.mu.Lock()
190 fx.fk.authTime = time.Now().Add(-2 * StepUpWindow) // yesterday's gesture
191 fx.fk.mu.Unlock()
192 status, loc, _ := postForm(t, c, fx.u("/members/remove"),
193 url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}})
194 if status != http.StatusSeeOther {
195 t.Fatalf("gate = %d, want 303", status)
196 }
197 status, _, _ = completeStepUp(t, fx, c, loc)
198 if status != http.StatusForbidden {
199 t.Fatalf("callback with a stale auth_time = %d, want 403", status)
200 }
201 if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleMember {
202 t.Fatal("membership changed on a stale gesture")
203 }
204 })
205
206 t.Run("somebody else's passkey", func(t *testing.T) {
207 // Keymail's identity service is anonymous: a fresh assertion proves
208 // SOME passkey was verified. The address comparison is what makes it
209 // proof about THIS session's owner.
210 fx, c, csrf := memberFixture(t)
211 status, loc, _ := postForm(t, c, fx.u("/members/remove"),
212 url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}})
213 if status != http.StatusSeeOther {
214 t.Fatalf("gate = %d, want 303", status)
215 }
216 fx.setUser("attacker@keymail.dev") // their own passkey, their own address
217 status, _, body := completeStepUp(t, fx, c, loc)
218 if status != http.StatusForbidden {
219 t.Fatalf("step-up with another identity = %d, want 403:\n%s", status, body)
220 }
221 if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleMember {
222 t.Fatal("membership changed on somebody else's gesture")
223 }
224 })
225}
226
227// TestPendingActionIsNotACapability pins the held action's three limits: it
228// cannot be replayed, it cannot be redeemed by another session, and the
229// browser cannot change what it says.
230func TestPendingActionIsNotACapability(t *testing.T) {
231 fx, _, csrf := memberFixture(t)
232 now := time.Now()
233
234 raw, err := fx.st.StashPendingAction(csrf, "paul@keymail.dev", "POST",
235 fx.path("/members/remove"), "address=kim%40keymail.dev", now)
236 if err != nil {
237 t.Fatal(err)
238 }
239 // Another session cannot spend it, even knowing the token.
240 other := newBrowser(t)
241 fx.setUser("kim@keymail.dev")
242 signUp(t, fx, other, "code-other")
243 if _, ok := fx.st.TakePendingAction(raw, sessionToken(t, fx, other), now); ok {
244 t.Fatal("a pending action was redeemed by a different session")
245 }
246 // ...and that failed attempt burned it, so the rightful session cannot
247 // either — burn-then-check, exactly like the OAuth code.
248 if _, ok := fx.st.TakePendingAction(raw, csrf, now); ok {
249 t.Fatal("a pending action survived a wrong-session attempt")
250 }
251
252 raw, err = fx.st.StashPendingAction(csrf, "paul@keymail.dev", "POST", fx.path("/members/remove"), "", now)
253 if err != nil {
254 t.Fatal(err)
255 }
256 if _, ok := fx.st.TakePendingAction(raw, csrf, now); !ok {
257 t.Fatal("the rightful session could not redeem its own pending action")
258 }
259 if _, ok := fx.st.TakePendingAction(raw, csrf, now); ok {
260 t.Fatal("a pending action was redeemed twice")
261 }
262
263 raw, err = fx.st.StashPendingAction(csrf, "paul@keymail.dev", "POST", fx.path("/members/remove"), "", now)
264 if err != nil {
265 t.Fatal(err)
266 }
267 if _, ok := fx.st.TakePendingAction(raw, csrf, now.Add(stepUpFlowTTL+time.Second)); ok {
268 t.Fatal("an expired pending action was redeemed")
269 }
270}
271
272// TestStepUpIsNotAnOracle is the property the account gate exists for: a
273// signed-in stranger POSTing at an owner-only action must get the same
274// byte-identical 404 an unrouted URL gets — never a redirect into Keymail,
275// which would confirm the account is real.
276func TestStepUpIsNotAnOracle(t *testing.T) {
277 fx, _, _ := memberFixture(t)
278 stranger := newBrowser(t)
279 fx.setUser("stranger@keymail.dev")
280 signUp(t, fx, stranger, "code-stranger")
281 csrf := sessionToken(t, fx, stranger)
282
283 _, _, missing := postForm(t, stranger, fx.ts.URL+"/a/zzzzz/members/remove",
284 url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}})
285
286 for _, path := range []string{"/members/remove", "/members/transfer", "/delete"} {
287 status, loc, body := postForm(t, stranger, fx.u(path),
288 url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}, "confirm": {"ops"}})
289 if status != http.StatusNotFound {
290 t.Fatalf("%s as a non-member = %d -> %q, want 404", path, status, loc)
291 }
292 if body != missing {
293 t.Fatalf("%s as a non-member answers differently from a nonexistent account:\n%q\nvs\n%q",
294 path, body, missing)
295 }
296 }
297 // A member who is not an owner gets the same silence.
298 member := newBrowser(t)
299 fx.setUser("kim@keymail.dev")
300 signUp(t, fx, member, "code-kim")
301 mcsrf := sessionToken(t, fx, member)
302 status, _, body := postForm(t, member, fx.u("/members/remove"),
303 url.Values{"csrf": {mcsrf}, "address": {"paul@keymail.dev"}})
304 if status != http.StatusNotFound || body != missing {
305 t.Fatalf("a member probing an owner action = %d, body %q", status, body)
306 }
307 // And no pending action was parked for any of them: the gate never ran.
308 var stashed int
309 fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&stashed)
310 if stashed != 0 {
311 t.Fatalf("%d pending actions parked by requests that should never have reached the gate", stashed)
312 }
313}
314
315// TestCSRFStillOutranksStepUp: the two checks are independent, and the older
316// one runs first. A cross-site POST must be refused outright, not parked as a
317// pending action for the victim to unknowingly confirm.
318func TestCSRFStillOutranksStepUp(t *testing.T) {
319 fx, c, _ := memberFixture(t)
320 for _, path := range []string{"/members/remove", "/members/transfer", "/delete"} {
321 status, _, _ := postForm(t, c, fx.u(path),
322 url.Values{"csrf": {"wrong"}, "address": {"kim@keymail.dev"}, "confirm": {"ops"}})
323 if status != http.StatusForbidden {
324 t.Fatalf("%s with a bad CSRF token = %d, want 403", path, status)
325 }
326 }
327 var stashed int
328 fx.st.db.QueryRow(`SELECT COUNT(*) FROM pending_actions`).Scan(&stashed)
329 if stashed != 0 {
330 t.Fatal("a cross-site POST parked a pending action")
331 }
332}
333
334// TestOwnershipActionsBehindStepUp walks transfer and deletion the way a
335// browser does, and pins the guards that keep an account administrable.
336func TestOwnershipActionsBehindStepUp(t *testing.T) {
337 fx, c, csrf := memberFixture(t)
338
339 // Ownership goes to an existing member, and the old owner steps down.
340 status, loc, body := postGated(t, fx, c, fx.u("/members/transfer"),
341 url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}})
342 if status != http.StatusSeeOther {
343 t.Fatalf("transfer = %d -> %q:\n%s", status, loc, body)
344 }
345 if role, _ := fx.st.Role(OpsAccountID, "kim@keymail.dev"); role != RoleOwner {
346 t.Fatalf("kim's role after transfer = %q, want owner", role)
347 }
348 if role, _ := fx.st.Role(OpsAccountID, "paul@keymail.dev"); role != RoleMember {
349 t.Fatalf("paul's role after transfer = %q, want member", role)
350 }
351 // And paul, now a member, meets the same 404 every non-owner does.
352 if status, _, _ := postForm(t, c, fx.u("/members/remove"),
353 url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}}); status != http.StatusNotFound {
354 t.Fatalf("the former owner can still act = %d, want 404", status)
355 }
356
357 // Deletion: an account holding apps is refused even with a confirmed
358 // passkey, and ops is refused outright.
359 fx2, c2, csrf2 := memberFixture(t)
360 status, _, _ = postGated(t, fx2, c2, fx2.u("/delete"), url.Values{"csrf": {csrf2}, "confirm": {"ops"}})
361 if status != http.StatusSeeOther {
362 t.Fatalf("delete = %d, want a 303 back to the account", status)
363 }
364 if a, _ := fx2.st.AccountBySqid(fx2.sqid); a == nil {
365 t.Fatal("the ops account was deleted")
366 }
367}
368
369// TestLastOwnerStillProtected: step-up does not become a way around the
370// invariant that every account keeps somebody who can administer it.
371func TestLastOwnerStillProtected(t *testing.T) {
372 fx, c, csrf := memberFixture(t)
373 status, _, _ := postGated(t, fx, c, fx.u("/members/remove"),
374 url.Values{"csrf": {csrf}, "address": {"paul@keymail.dev"}})
375 if status != http.StatusSeeOther {
376 t.Fatalf("self-removal = %d, want a 303 with a refusal flash", status)
377 }
378 if role, _ := fx.st.Role(OpsAccountID, "paul@keymail.dev"); role != RoleOwner {
379 t.Fatalf("the last owner removed themselves (role now %q)", role)
380 }
381}
382
383// TestSignOutClearsPresence: proof of presence belongs to a session and dies
384// with it.
385func TestSignOutClearsPresence(t *testing.T) {
386 fx, c, csrf := memberFixture(t)
387 status, loc, _ := postForm(t, c, fx.u("/members/remove"),
388 url.Values{"csrf": {csrf}, "address": {"kim@keymail.dev"}})
389 if status != http.StatusSeeOther {
390 t.Fatalf("gate = %d", status)
391 }
392 if _, _, _ = completeStepUp(t, fx, c, loc); !fx.st.FreshStepUp(csrf, "paul@keymail.dev", time.Now(), StepUpWindow) {
393 t.Fatal("no presence recorded after a completed step-up")
394 }
395 if status, _, _ := postForm(t, c, fx.ts.URL+"/auth/signout", url.Values{"csrf": {csrf}}); status != http.StatusSeeOther {
396 t.Fatalf("signout = %d", status)
397 }
398 if fx.st.FreshStepUp(csrf, "paul@keymail.dev", time.Now(), StepUpWindow) {
399 t.Fatal("presence outlived the session it was proved for")
400 }
401}
402