oficina / home Public

Clone
git clone https://amadan.net/oficina/home

Plain git — no account needed to clone.

Download

Download this file

1package home
2
3import (
4 "encoding/base64"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "net/url"
9 "strings"
10 "testing"
11 "time"
12
13 "amadan.net/rastrillo/rastrillo/webauthn/authtest"
14)
15
16// signInFully takes a person all the way to a full session and returns
17// the session cookie.
18func signInFully(t *testing.T, app *testApp, addr string) *http.Cookie {
19 t.Helper()
20 signIn(t, app, addr)
21 rec := redeem(t, app, capturedLink(t, app))
22 c := sessionCookie(rec)
23 if c == nil {
24 t.Fatalf("no session cookie after redeeming (status %d)", rec.Code)
25 }
26 return c
27}
28
29// postJSON drives one of the ceremony endpoints.
30func postJSON(t *testing.T, app *testApp, path string, cookie *http.Cookie, body any) *httptest.ResponseRecorder {
31 t.Helper()
32 var payload string
33 if body != nil {
34 b, err := json.Marshal(body)
35 if err != nil {
36 t.Fatal(err)
37 }
38 payload = string(b)
39 } else {
40 payload = "{}"
41 }
42 req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(payload))
43 req.Host = credentialHost
44 req.Header.Set("Content-Type", "application/json")
45 req.Header.Set("Origin", Origin)
46 if cookie != nil {
47 req.AddCookie(cookie)
48 }
49 w := httptest.NewRecorder()
50 app.Router().ServeHTTP(w, req)
51 return w
52}
53
54func challengeFrom(t *testing.T, rec *httptest.ResponseRecorder) []byte {
55 t.Helper()
56 if rec.Code != http.StatusOK {
57 t.Fatalf("begin: status %d, body %s", rec.Code, rec.Body.String())
58 }
59 var body struct {
60 Challenge string `json:"challenge"`
61 }
62 if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil {
63 t.Fatalf("decode challenge: %v (body %s)", err, rec.Body.String())
64 }
65 raw, err := base64.RawURLEncoding.DecodeString(body.Challenge)
66 if err != nil {
67 t.Fatalf("challenge is not base64url: %v", err)
68 }
69 return raw
70}
71
72// enrol runs the real registration ceremony against a software
73// authenticator — rastrillo's own, rather than a hand-rolled credential.
74func enrol(t *testing.T, app *testApp, cookie *http.Cookie) *authtest.Authenticator {
75 t.Helper()
76 a, err := authtest.New()
77 if err != nil {
78 t.Fatal(err)
79 }
80 challenge := challengeFrom(t, postJSON(t, app, "/passkey/register/begin", cookie, nil))
81 clientData, attestation := a.Create(challenge, authtest.Options{RPID: app.credentialHost, Origin: Origin})
82 rec := postJSON(t, app, "/passkey/register/finish", cookie, map[string]string{
83 "clientDataJSON": base64.RawURLEncoding.EncodeToString(clientData),
84 "attestationObject": base64.RawURLEncoding.EncodeToString(attestation),
85 })
86 if rec.Code != http.StatusOK {
87 t.Fatalf("RegisterFinish: status %d, body %s", rec.Code, rec.Body.String())
88 }
89 return a
90}
91
92// TestEnrolledPersonCannotSignInWithOneFactor is what the whole task is
93// for. A magic link alone must not open an enrolled account.
94func TestEnrolledPersonCannotSignInWithOneFactor(t *testing.T) {
95 app := newTestApp(t)
96 const addr = "person@example.invalid"
97
98 cookie := signInFully(t, app, addr)
99 enrol(t, app, cookie)
100
101 // A fresh sign-in, first factor only.
102 signIn(t, app, addr)
103 rec := redeem(t, app, capturedLink(t, app))
104
105 if got := rec.Header().Get("Location"); got != confirmPath {
106 t.Errorf("redemption went to %q, want %q — the Gate did not take over", got, confirmPath)
107 }
108 if c := sessionCookie(rec); c != nil {
109 t.Fatal("one factor minted a full session for an enrolled person")
110 }
111}
112
113// The control: an account with no passkey is unaffected.
114func TestUnenrolledPersonSignsInWithOneFactor(t *testing.T) {
115 app := newTestApp(t)
116 rec := redeemAfterSignIn(t, app, "nobody@example.invalid")
117 if got := rec.Header().Get("Location"); got != "/" {
118 t.Errorf("redemption went to %q, want / — the Gate should pass an unenrolled account through", got)
119 }
120 if sessionCookie(rec) == nil {
121 t.Error("an unenrolled person got no session")
122 }
123}
124
125func redeemAfterSignIn(t *testing.T, app *testApp, addr string) *httptest.ResponseRecorder {
126 t.Helper()
127 signIn(t, app, addr)
128 return redeem(t, app, capturedLink(t, app))
129}
130
131// This source tripwire names the two app-owned session boundaries. Behavioural
132// tests prove the credential Gate and the directory assertion exchange; aliases
133// can evade a source search, so the search alone is not a security proof.
134func TestSessionIssuanceStaysAtCredentialAndDirectoryBoundaries(t *testing.T) {
135 got := grepPackage(t, "a.sessions.SignIn(")
136 if len(got) != 2 || got[0] != "directory.go" || got[1] != "signin.go" {
137 t.Fatalf("unexpected app session boundaries: %v", got)
138 }
139 gate := grepPackage(t, "a.passkeys.Gate(")
140 if len(gate) != 1 || gate[0] != "signin.go" {
141 t.Fatalf("credential gate moved: %v", gate)
142 }
143}
144
145// A recovery code signs somebody in with no script at all — a plain
146// form POST, which is the point: recovery is exactly the moment
147// WebAuthn is not working.
148func TestRecoveryCodeCompletesSignInWithoutScript(t *testing.T) {
149 app := newTestApp(t)
150 const addr = "person@example.invalid"
151
152 cookie := signInFully(t, app, addr)
153 enrol(t, app, cookie)
154
155 codes, err := app.passkeys.RegenerateRecoveryCodes(subjectOf(t, app, cookie))
156 if err != nil {
157 t.Fatal(err)
158 }
159 if len(codes) != 10 {
160 t.Fatalf("got %d recovery codes, want 10 (§5.2 says ten)", len(codes))
161 }
162
163 // First factor again, which now stops at the Gate and leaves the
164 // pending half-session cookie behind.
165 signIn(t, app, addr)
166 gated := redeem(t, app, capturedLink(t, app))
167
168 form := url.Values{"code": {codes[0]}}
169 req := httptest.NewRequest(http.MethodPost, "/passkey/signin/recovery", strings.NewReader(form.Encode()))
170 req.Host = credentialHost
171 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
172 req.Header.Set("Origin", Origin)
173 for _, c := range (&http.Response{Header: gated.Header()}).Cookies() {
174 req.AddCookie(c)
175 }
176 w := httptest.NewRecorder()
177 app.Router().ServeHTTP(w, req)
178
179 if sessionCookie(w) == nil {
180 t.Fatalf("a recovery code did not sign anybody in (status %d, location %q)",
181 w.Code, w.Header().Get("Location"))
182 }
183
184 // And it is single-use.
185 var n int
186 if err := app.db.Writer().QueryRow("SELECT count(*) FROM passkey_recovery_codes").Scan(&n); err != nil {
187 t.Fatal(err)
188 }
189 if n != 9 {
190 t.Errorf("%d codes left after redeeming one of ten, want 9", n)
191 }
192}
193
194func subjectOf(t *testing.T, app *testApp, cookie *http.Cookie) string {
195 t.Helper()
196 req := httptest.NewRequest(http.MethodGet, "/", nil)
197 req.Host = credentialHost
198 req.AddCookie(cookie)
199 sess, ok := app.sessions.From(req)
200 if !ok {
201 t.Fatal("the session cookie does not resolve")
202 }
203 return sess.Subject
204}
205
206// Both script files must stay on stable, unfingerprinted paths, because
207// the scriptless sweep's allowlist names them.
208func TestScriptAssetsAreServedOnNamedPaths(t *testing.T) {
209 app := newTestApp(t)
210 for _, path := range []string{"/static/webauthn.mjs", "/static/passkey.js"} {
211 req := httptest.NewRequest(http.MethodGet, path, nil)
212 req.Host = credentialHost
213 w := httptest.NewRecorder()
214 app.Router().ServeHTTP(w, req)
215 if w.Code != http.StatusOK {
216 t.Errorf("GET %s = %d, want 200", path, w.Code)
217 }
218 if ct := w.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/javascript") {
219 t.Errorf("GET %s Content-Type = %q; a wrong MIME type blocks a module silently", path, ct)
220 }
221 if w.Body.Len() == 0 {
222 t.Errorf("GET %s served an empty body", path)
223 }
224 }
225}
226
227// The codes page shows ten codes once, so a merely valid session is not
228// enough — it wants a recent one. Without this, a stolen session cookie
229// of any age reads somebody's whole recovery set.
230func TestRecoveryCodesPageNeedsAFreshSession(t *testing.T) {
231 app := newTestApp(t)
232 cookie := signInFully(t, app, "person@example.invalid")
233
234 get := func() int {
235 req := httptest.NewRequest(http.MethodGet, "/passkey/codes", nil)
236 req.Host = credentialHost
237 req.AddCookie(cookie)
238 w := httptest.NewRecorder()
239 app.Router().ServeHTTP(w, req)
240 return w.Code
241 }
242
243 if code := get(); code != http.StatusOK {
244 t.Fatalf("a fresh session got %d from /passkey/codes, want 200", code)
245 }
246
247 // Age the session past the freshness window. Both columns, because
248 // sessions.Fresh falls back to At when AuthTime is zero — checking
249 // only one would leave this passing on a session that is still
250 // fresh by the other.
251 old := time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339)
252 if _, err := app.db.Writer().Exec(
253 `UPDATE sessions SET auth_time = ?, created_at = ?`, old, old); err != nil {
254 t.Fatal(err)
255 }
256
257 if code := get(); code == http.StatusOK {
258 t.Error("a day-old session still reads the recovery codes page")
259 }
260}
261
262// A recovery code produces a session that satisfies RequireFresh,
263// because passkey.SignInRecovery mints one with AuthTime: time.Now().
264//
265// This test does not object to that — replacing the code set right after
266// spending one is the sensible behaviour. It pins the fact, because §5.2
267// says recovery is "sign-in only — step-up still demands a real
268// assertion" and RequireFresh does not distinguish the two. §14's owner
269// write is specified to sit behind step-up; built on RequireFresh it
270// would take a recovery code instead, and nothing else in the tree would
271// notice.
272func TestRecoveryCodeProducesAFreshSession(t *testing.T) {
273 app := newTestApp(t)
274 const addr = "person@example.invalid"
275
276 cookie := signInFully(t, app, addr)
277 enrol(t, app, cookie)
278 codes, err := app.passkeys.RegenerateRecoveryCodes(subjectOf(t, app, cookie))
279 if err != nil {
280 t.Fatal(err)
281 }
282
283 signIn(t, app, addr)
284 gated := redeem(t, app, capturedLink(t, app))
285
286 form := url.Values{"code": {codes[0]}}
287 req := httptest.NewRequest(http.MethodPost, "/passkey/signin/recovery", strings.NewReader(form.Encode()))
288 req.Host = credentialHost
289 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
290 req.Header.Set("Origin", Origin)
291 for _, c := range (&http.Response{Header: gated.Header()}).Cookies() {
292 req.AddCookie(c)
293 }
294 w := httptest.NewRecorder()
295 app.Router().ServeHTTP(w, req)
296
297 recovered := sessionCookie(w)
298 if recovered == nil {
299 t.Fatalf("the recovery code did not sign anybody in (status %d)", w.Code)
300 }
301
302 // The RequireFresh-guarded page opens to it.
303 page := httptest.NewRequest(http.MethodGet, "/passkey/codes", nil)
304 page.Host = credentialHost
305 page.AddCookie(recovered)
306 pw := httptest.NewRecorder()
307 app.Router().ServeHTTP(pw, page)
308 if pw.Code != http.StatusOK {
309 t.Errorf("a recovery-code session got %d from the RequireFresh page; if this has "+
310 "changed, the note on codesFreshness needs revisiting", pw.Code)
311 }
312}
313
314// A stolen long-lived session cookie must not be able to enrol its own
315// passkey. With no operator reset (§5.2), enrolment by an attacker turns
316// a compromise that would have expired with the cookie into a permanent
317// lockout: the owner's next magic link lands on the Gate and stops, and
318// they have no recovery codes because they never enrolled.
319func TestEnrolmentNeedsAFreshSession(t *testing.T) {
320 app := newTestApp(t)
321 cookie := signInFully(t, app, "person@example.invalid")
322
323 // Fresh, straight after signing in: the normal path still works.
324 if rec := postJSON(t, app, "/passkey/register/begin", cookie, nil); rec.Code != http.StatusOK {
325 t.Fatalf("a fresh session got %d from register/begin, want 200", rec.Code)
326 }
327
328 // Aged past the window. Both columns, because sessions.Fresh falls
329 // back to At when AuthTime is zero.
330 old := time.Now().Add(-24 * time.Hour).UTC().Format(time.RFC3339)
331 if _, err := app.db.Writer().Exec(
332 `UPDATE sessions SET auth_time = ?, created_at = ?`, old, old); err != nil {
333 t.Fatal(err)
334 }
335
336 if rec := postJSON(t, app, "/passkey/register/begin", cookie, nil); rec.Code == http.StatusOK {
337 t.Error("a day-old session began a passkey enrolment")
338 }
339 if rec := postJSON(t, app, "/passkey/register/finish", cookie,
340 map[string]string{"clientDataJSON": "x", "attestationObject": "y"}); rec.Code == http.StatusOK {
341 t.Error("a day-old session finished a passkey enrolment")
342 }
343
344 // The page itself redirects to re-authenticate rather than 404ing or
345 // 403ing: a detour, not a dead end.
346 req := httptest.NewRequest(http.MethodGet, "/passkey/enrol", nil)
347 req.Host = credentialHost
348 req.AddCookie(cookie)
349 w := httptest.NewRecorder()
350 app.Router().ServeHTTP(w, req)
351 if w.Code != http.StatusSeeOther && w.Code != http.StatusFound {
352 t.Errorf("GET /passkey/enrol with a stale session = %d, want a redirect to re-auth", w.Code)
353 }
354}
355
356// The codes window must be at least as wide as the enrolment window.
357//
358// passkey.js sends the browser to /passkey/codes the moment registration
359// finishes. If a session can enrol but cannot then read its codes, the
360// person is bounced to re-authenticate holding a passkey and zero
361// recovery codes. That was real: enrolFreshness was 10 minutes and
362// codesFreshness 5, so a 7-minute-old session enrolled and was then
363// refused.
364func TestFreshnessWindowsDoNotStrandAnEnroller(t *testing.T) {
365 if codesFreshness < enrolFreshness {
366 t.Fatalf("codesFreshness (%s) is shorter than enrolFreshness (%s): a session can "+
367 "enrol a passkey and then be refused its recovery codes",
368 codesFreshness, enrolFreshness)
369 }
370}
371
372// And the same thing end to end, at a session age that used to fall in
373// the gap: enrol, then read the codes page, without re-authenticating.
374func TestEnrolThenReadCodesWithoutReauthenticating(t *testing.T) {
375 app := newTestApp(t)
376 cookie := signInFully(t, app, "person@example.invalid")
377
378 // Seven minutes old: inside the old enrol window, outside the old
379 // codes window.
380 aged := time.Now().Add(-7 * time.Minute).UTC().Format(time.RFC3339)
381 if _, err := app.db.Writer().Exec(
382 `UPDATE sessions SET auth_time = ?, created_at = ?`, aged, aged); err != nil {
383 t.Fatal(err)
384 }
385
386 enrol(t, app, cookie)
387
388 req := httptest.NewRequest(http.MethodGet, "/passkey/codes", nil)
389 req.Host = credentialHost
390 req.AddCookie(cookie)
391 w := httptest.NewRecorder()
392 app.Router().ServeHTTP(w, req)
393 if w.Code != http.StatusOK {
394 t.Errorf("a session that just enrolled got %d from /passkey/codes; it is holding "+
395 "a passkey and no recovery codes", w.Code)
396 }
397}
398