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 for sensitive actions (spec §7a).
4//
5// Membership answers *who you are*. It does not answer *are you here now* — and
6// for the handful of actions where that is the whole question (approving a
7// `carlos auth login` device grant, removing a member, transferring ownership,
8// deleting an account, revoking CLI tokens) a session cookie lifted from a
9// laptop is currently indistinguishable from the person it belongs to.
10//
11// The route is a FRESH Keymail authentication, not console-native WebAuthn:
12// identity belongs in one place in this platform, and a console holding its own
13// credentials would be a second identity system with a second attack surface.
14// Keymail already asserts with `UserVerification: VerificationRequired`; what it
15// gained for this is `prompt=login` and an `auth_time` claim (keymail
16// docs/apps.md), so the console can DEMAND a gesture and then VERIFY one
17// happened rather than trusting the redirect it got back.
18//
19// Three properties this file exists to hold, in order of how easily they are
20// lost:
21//
22// 1. UNSKIPPABLE SERVER-SIDE. The gate is not a UI affordance. requireFreshAuth
23// reads database state written only by a verified callback; a direct POST
24// with a valid session and a valid CSRF token is refused all the same.
25//
26// 2. THE PENDING ACTION IS NOT A CAPABILITY. The action is preserved across the
27// round trip so a human is not dumped back at square one, but it is stored
28// SERVER-SIDE, bound to the session that stashed it, single-use (burned by
29// row deletion) and short-lived. The browser carries an opaque token in an
30// HttpOnly cookie and nothing else — it cannot swap the action, replay it,
31// or hand it to another session.
32//
33// 3. NO ORACLE. Step-up runs AFTER the membership/ownership gate, never before.
34// A non-member probing an owner-only action must still get the
35// byte-identical http.NotFound, not a redirect to Keymail that would confirm
36// the account exists.
37//
38// The CSRF check stays exactly where it was. Step-up is in addition to it: CSRF
39// answers "did this browser mean to send this?", step-up answers "is the human
40// still here?", and neither substitutes for the other.
41
42import (
43 "context"
44 "crypto/subtle"
45 "database/sql"
46 "log"
47 "net/http"
48 "net/url"
49 "strings"
50 "time"
51)
52
53const (
54 // StepUpWindow is how recent the authenticator gesture must be for a
55 // sensitive action to proceed.
56 //
57 // Five minutes. Shorter is not obviously safer here and is definitely
58 // worse: the gated flows are multi-step (approving a device grant means
59 // typing a code, reading the terminal's description, then deciding), and a
60 // window that expires mid-decision trains people to click through
61 // authenticator prompts without reading them. Longer starts to defeat the
62 // point — the threat is a session stolen and used soon after, and every
63 // extra minute is a minute of that theft that lands inside the window.
64 // Five minutes also covers the round trip itself (redirect, consent card,
65 // a slow authenticator) with room to spare, so the common case is one
66 // touch, not two.
67 //
68 // It is measured from the GESTURE (Keymail's auth_time), not from when the
69 // console heard about it — the freshness that matters is the human's, and
70 // timing the console's own bookkeeping would let a slow network buy time.
71 StepUpWindow = 5 * time.Minute
72
73 // stepUpFlowTTL bounds the round trip: mint state, go to Keymail, come
74 // back. Ten minutes is the same order as the sign-in flow's own cookie.
75 stepUpFlowTTL = 10 * time.Minute
76
77 // stepUpSkew tolerates a small clock difference between this console and
78 // the Keymail deployment. Deliberately tiny — this is skew tolerance, not
79 // a second freshness window.
80 stepUpSkew = 60 * time.Second
81
82 // stepUpCookie carries state + PKCE verifier + the pending-action token
83 // across the round trip. HttpOnly, like every other cookie here, and
84 // never a query parameter: a URL ends up in history, referrers and logs,
85 // and the pending-action token must not.
86 stepUpCookie = "carlos_console_stepup"
87)
88
89// stepUpUnavailable is what a human sees when the identity provider cannot
90// re-authenticate them, or cannot be reached to try. Failing CLOSED is the
91// whole design: the alternative — quietly proceeding on session-only
92// authority — is precisely the hole this feature exists to close, and it would
93// close it only on the days Keymail happens to be up.
94const stepUpUnavailable = "This action needs you to confirm it's really you with your passkey, " +
95 "and Keymail can't be asked right now. Nothing has been changed. Try again in a moment — " +
96 "if this keeps happening, this deployment's Keymail does not support re-authentication " +
97 "and these actions stay blocked until it does."
98
99// stepUpHoldFailed is the message when the console cannot hold on to the
100// action, or (an email session) cannot send the confirmation for it.
101const stepUpHoldFailed = "Could not hold on to that action. Please try again."
102
103// ---- storage ----------------------------------------------------------------
104
105// migrateStepUp adds this feature's two tables. Additive and idempotent, and
106// kept here rather than in store.go's list so the feature is one file to read
107// and one file to remove (the authstore.go precedent).
108func (s *Store) migrateStepUp() error {
109 for _, stmt := range []string{
110 // One row per session that has stepped up. Keyed by the session hash,
111 // so it dies with the session and cannot be moved to another one.
112 // address is stored alongside for a belt-and-braces check: a session
113 // row could in principle be re-pointed, and a step-up recorded for one
114 // identity must never satisfy another.
115 `CREATE TABLE IF NOT EXISTS step_ups (
116 session_hash TEXT PRIMARY KEY,
117 address TEXT NOT NULL,
118 auth_time INTEGER NOT NULL,
119 recorded_at INTEGER NOT NULL
120);`,
121 // The action a human was part-way through when the gate stopped them.
122 // Stored server-side precisely so it cannot be edited in flight: the
123 // browser holds an opaque token, never the method, path or fields.
124 `CREATE TABLE IF NOT EXISTS pending_actions (
125 token_hash TEXT PRIMARY KEY,
126 session_hash TEXT NOT NULL,
127 address TEXT NOT NULL,
128 method TEXT NOT NULL,
129 path TEXT NOT NULL,
130 form TEXT NOT NULL,
131 created_at INTEGER NOT NULL,
132 expires_at INTEGER NOT NULL
133);`,
134 } {
135 if _, err := s.db.Exec(stmt); err != nil {
136 return err
137 }
138 }
139 return nil
140}
141
142// RecordStepUp remembers that this session's owner proved presence at
143// authTime. Upsert: a newer gesture replaces an older one.
144func (s *Store) RecordStepUp(rawSession, address string, authTime, now time.Time) error {
145 _, err := s.db.Exec(`INSERT INTO step_ups (session_hash, address, auth_time, recorded_at)
146VALUES (?, ?, ?, ?)
147ON CONFLICT(session_hash) DO UPDATE SET
148 address = excluded.address, auth_time = excluded.auth_time, recorded_at = excluded.recorded_at`,
149 hashToken(rawSession), address, authTime.Unix(), now.Unix())
150 return err
151}
152
153// FreshStepUp is the gate's one question. Both the session AND the address must
154// match, and the gesture must be inside the window; anything else — no row, a
155// stale row, a row belonging to another identity — is false.
156func (s *Store) FreshStepUp(rawSession, address string, now time.Time, window time.Duration) bool {
157 var storedAddr string
158 var authTime int64
159 err := s.db.QueryRow(`SELECT address, auth_time FROM step_ups WHERE session_hash = ?`,
160 hashToken(rawSession)).Scan(&storedAddr, &authTime)
161 if err != nil || storedAddr != address {
162 return false
163 }
164 age := now.Sub(time.Unix(authTime, 0))
165 return age >= -stepUpSkew && age <= window
166}
167
168// ClearStepUp drops a session's proof of presence. Called on sign-out: the
169// session is gone, and so is any authority it had accrued.
170func (s *Store) ClearStepUp(rawSession string) error {
171 _, err := s.db.Exec(`DELETE FROM step_ups WHERE session_hash = ?`, hashToken(rawSession))
172 return err
173}
174
175// pendingAction is a POST held mid-flight.
176type pendingAction struct {
177 Method string
178 Path string
179 Form string
180}
181
182// StashPendingAction parks the request the gate interrupted and returns the
183// opaque token that redeems it. Housekeeping on the write path: expired stashes
184// have nothing left to resume.
185func (s *Store) StashPendingAction(rawSession, address, method, path, form string, now time.Time) (string, error) {
186 s.db.Exec(`DELETE FROM pending_actions WHERE expires_at < ?`, now.Unix())
187 raw, err := NewVerifier()
188 if err != nil {
189 return "", err
190 }
191 _, err = s.db.Exec(`INSERT INTO pending_actions
192(token_hash, session_hash, address, method, path, form, created_at, expires_at)
193VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
194 hashToken(raw), hashToken(rawSession), address, method, path, form,
195 now.Unix(), now.Add(stepUpFlowTTL).Unix())
196 if err != nil {
197 return "", err
198 }
199 return raw, nil
200}
201
202// TakePendingAction redeems a stash exactly once, and only for the session that
203// created it. Burn-then-check, like the OAuth code and the confirmation link: the
204// row is deleted before any verdict is returned, so a wrong-session or expired
205// attempt cannot be retried into a right one.
206func (s *Store) TakePendingAction(rawToken, rawSession string, now time.Time) (*pendingAction, bool) {
207 if rawToken == "" {
208 return nil, false
209 }
210 return s.takePendingActionByHash(hashToken(rawToken), rawSession, now)
211}
212
213// takePendingActionByHash is TakePendingAction for a caller that holds only
214// the token's hash (an email step-up challenge stores the hash, never the
215// token): the same transaction, single-use delete, session binding and expiry
216// check.
217func (s *Store) takePendingActionByHash(hash, rawSession string, now time.Time) (*pendingAction, bool) {
218 if hash == "" {
219 return nil, false
220 }
221 tx, err := s.db.Begin()
222 if err != nil {
223 return nil, false
224 }
225 defer tx.Rollback()
226 var sessionHash string
227 var expires int64
228 var p pendingAction
229 err = tx.QueryRow(`SELECT session_hash, method, path, form, expires_at
230FROM pending_actions WHERE token_hash = ?`, hash).
231 Scan(&sessionHash, &p.Method, &p.Path, &p.Form, &expires)
232 if err == sql.ErrNoRows {
233 return nil, false
234 }
235 if err != nil {
236 return nil, false
237 }
238 if _, err := tx.Exec(`DELETE FROM pending_actions WHERE token_hash = ?`, hash); err != nil {
239 return nil, false
240 }
241 if err := tx.Commit(); err != nil {
242 return nil, false
243 }
244 if subtle.ConstantTimeCompare([]byte(sessionHash), []byte(hashToken(rawSession))) != 1 {
245 return nil, false
246 }
247 if now.Unix() > expires {
248 return nil, false
249 }
250 return &p, true
251}
252
253// ---- the gate ---------------------------------------------------------------
254
255// requireFreshAuth is the step-up gate. It returns true only when this session
256// has proved presence inside StepUpWindow; otherwise it has ALREADY written the
257// response (a redirect into Keymail's re-authentication, for an email session
258// the "Confirm it's you" page of an emailed link and code, or a fail-closed
259// message) and the caller must simply return.
260//
261// Call it LAST — after the membership/ownership gate and after the CSRF check.
262// Order is load-bearing twice over: a non-member must get the byte-identical
263// 404 rather than a redirect that confirms the account exists, and a
264// cross-site POST must be refused before it can park a pending action.
265func (s *Server) requireFreshAuth(w http.ResponseWriter, r *http.Request, addr, raw string) bool {
266 now := time.Now()
267 if s.store.FreshStepUp(raw, addr, now, StepUpWindow) {
268 return true
269 }
270 // A session that signed in by email re-confirms by email (spec 6). Only
271 // that method does: a Keymail session, or one whose method cannot be
272 // read, takes the passkey path below and never an email instead.
273 if _, method, ok := s.store.SessionInfo(raw, now); ok && method == MethodEmail {
274 s.emailStepUp(w, r, addr, raw, now)
275 return false
276 }
277 // Ask the identity provider whether it can do this at all before sending a
278 // human off to try. Unreachable and "no" are the same answer here — see
279 // stepUpUnavailable.
280 ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
281 defer cancel()
282 switch supported, err := s.km.SupportsStepUp(ctx); {
283 case err != nil:
284 log.Printf("console: step-up capability check: %v", err)
285 s.renderMessage(w, r, http.StatusServiceUnavailable, "Can't confirm it's you", stepUpUnavailable)
286 return false
287 case !supported:
288 log.Printf("console: step-up refused: %s does not advertise re-authentication", s.cfg.Keymail)
289 s.renderMessage(w, r, http.StatusServiceUnavailable, "Can't confirm it's you", stepUpUnavailable)
290 return false
291 }
292 state, err := NewVerifier()
293 if err != nil {
294 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the confirmation. Please try again.")
295 return false
296 }
297 verifier, err := NewVerifier()
298 if err != nil {
299 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the confirmation. Please try again.")
300 return false
301 }
302 pending, ok := s.stashRequest(w, r, addr, raw, now)
303 if !ok {
304 return false
305 }
306 s.setCookie(w, stepUpCookie, state+"."+verifier+"."+pending, int(stepUpFlowTTL.Seconds()))
307 http.Redirect(w, r, s.km.StepUpURL(state, ChallengeS256(verifier), StepUpWindow), http.StatusSeeOther)
308 return false
309}
310
311// stashRequest parks the request the gate interrupted (StashPendingAction)
312// and returns the token that redeems it. On false it has already written the
313// response.
314func (s *Server) stashRequest(w http.ResponseWriter, r *http.Request, addr, raw string, now time.Time) (string, bool) {
315 // The handler has already read fields off this request, so PostForm is
316 // parsed and cached; Encode gives back exactly what it will be re-fed.
317 if err := r.ParseForm(); err != nil {
318 s.renderMessage(w, r, http.StatusBadRequest, "Refused", "That request could not be read.")
319 return "", false
320 }
321 // The csrf field is deliberately NOT stored. It is the raw session token,
322 // and sessions are kept hashed here precisely so a database read is not a
323 // session; parking a live one in a form blob would quietly undo that. The
324 // resume re-injects it from the cookie the resuming request carries — which
325 // costs nothing in safety, because the cross-site question was already
326 // answered before this stash was ever created.
327 form := url.Values{}
328 for k, v := range r.PostForm {
329 if k != "csrf" {
330 form[k] = v
331 }
332 }
333 pending, err := s.store.StashPendingAction(raw, addr, r.Method, r.URL.Path, form.Encode(), now)
334 if err != nil {
335 log.Printf("console: stash pending action: %v", err)
336 s.renderMessage(w, r, http.StatusInternalServerError, "Error", stepUpHoldFailed)
337 return "", false
338 }
339 return pending, true
340}
341
342// handleStepUpCallback is where a re-authentication lands. It trusts nothing
343// about the redirect itself: the code is exchanged server-side, and the grant
344// that comes back must name the SAME address as the session and carry an
345// auth_time inside the window. Only then is presence recorded and the held
346// action resumed.
347func (s *Server) handleStepUpCallback(w http.ResponseWriter, r *http.Request) {
348 addr, raw, ok := s.session(r)
349 if !ok {
350 http.Redirect(w, r, "/", http.StatusFound)
351 return
352 }
353 c, err := r.Cookie(stepUpCookie)
354 if err != nil || c.Value == "" {
355 s.renderMessage(w, r, http.StatusBadRequest, "Confirmation expired",
356 "That confirmation is stale or was started in another browser. Nothing has been changed — please try the action again.")
357 return
358 }
359 s.clearCookie(w, stepUpCookie)
360 parts := strings.SplitN(c.Value, ".", 3)
361 if len(parts) != 3 || parts[0] == "" ||
362 subtle.ConstantTimeCompare([]byte(parts[0]), []byte(r.URL.Query().Get("state"))) != 1 {
363 s.renderMessage(w, r, http.StatusBadRequest, "Confirmation mismatch",
364 "That response didn't match this browser's confirmation attempt. Nothing has been changed — please try the action again.")
365 return
366 }
367 verifier, pendingToken := parts[1], parts[2]
368 if e := r.URL.Query().Get("error"); e != "" {
369 // Cancelled at Keymail, or refused there. Not an error state — but
370 // emphatically not a pass either.
371 s.store.TakePendingAction(pendingToken, raw, time.Now()) // burn the stash
372 s.renderMessage(w, r, http.StatusOK, "Not confirmed",
373 "You didn't confirm it was you, so nothing has been changed.")
374 return
375 }
376 code := r.URL.Query().Get("code")
377 if code == "" {
378 s.renderMessage(w, r, http.StatusBadRequest, "Not confirmed",
379 "Keymail sent no authorization code. Nothing has been changed — please try the action again.")
380 return
381 }
382 ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
383 defer cancel()
384 grant, err := s.km.ExchangeGrant(ctx, code, verifier, true)
385 if err != nil {
386 log.Printf("console: step-up exchange: %v", err)
387 s.renderMessage(w, r, http.StatusBadGateway, "Can't confirm it's you", stepUpUnavailable)
388 return
389 }
390 now := time.Now()
391 // The identity check. Keymail's identity service is deliberately anonymous —
392 // a fresh assertion there proves SOME registered passkey was verified, not
393 // whose. This comparison is what turns it into proof about this session's
394 // owner: step up with somebody else's passkey and the address won't match.
395 if !strings.EqualFold(grant.Address, addr) {
396 log.Printf("console: step-up address mismatch for session of %s", addr)
397 s.renderMessage(w, r, http.StatusForbidden, "Not confirmed",
398 "That passkey belongs to a different Keymail address than the one you're signed in as. Nothing has been changed.")
399 return
400 }
401 // A re-authorization rotates the console's grant at Keymail, so the stored
402 // token would otherwise be dead by the next invite or confirmation send.
403 if err := s.store.SetGrantToken(grant.Address, grant.Token, now); err != nil {
404 log.Printf("console: step-up grant refresh: %v", err)
405 }
406 // Fail closed on a missing or stale auth_time. An IdP that did not report
407 // when the gesture happened has not proved that one did.
408 age := now.Sub(grant.AuthTime)
409 if grant.AuthTime.IsZero() || age > StepUpWindow || age < -stepUpSkew {
410 log.Printf("console: step-up produced no usable auth_time (zero=%v age=%s)", grant.AuthTime.IsZero(), age)
411 s.renderMessage(w, r, http.StatusForbidden, "Can't confirm it's you", stepUpUnavailable)
412 return
413 }
414 if err := s.store.RecordStepUp(raw, addr, grant.AuthTime, now); err != nil {
415 log.Printf("console: record step-up: %v", err)
416 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not record your confirmation. Please try the action again.")
417 return
418 }
419 pending, ok := s.store.TakePendingAction(pendingToken, raw, now)
420 if !ok {
421 // Confirmed, but the held action is gone (expired, already resumed, or
422 // belonged to another session). Say so plainly rather than guessing.
423 s.renderMessage(w, r, http.StatusOK, "Confirmed",
424 "Thanks — that's you. The action you were doing has expired, so nothing was changed. Please try it again.")
425 return
426 }
427 s.resume(w, r, pending)
428}
429
430// resume re-runs the held POST through the console's own router, so it meets
431// every gate it would have met the first time — session, CSRF, membership,
432// ownership, and the step-up gate that now passes. Re-dispatching rather than
433// calling a handler directly is the point: there is no bypass path, because
434// there is no second door.
435func (s *Server) resume(w http.ResponseWriter, r *http.Request, p *pendingAction) {
436 _, raw, ok := s.session(r)
437 if !ok {
438 http.Redirect(w, r, "/", http.StatusFound)
439 return
440 }
441 form, err := url.ParseQuery(p.Form)
442 if err != nil {
443 log.Printf("console: resume %s %s: %v", p.Method, p.Path, err)
444 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not resume that action. Please try it again.")
445 return
446 }
447 // Put back the one field the stash refuses to hold (see requireFreshAuth):
448 // the resumed request must satisfy the same CSRF check as any other, and
449 // the token it satisfies it with is this browser's own, read from its
450 // cookie, not something recovered from storage.
451 form.Set("csrf", raw)
452 req, err := http.NewRequestWithContext(r.Context(), p.Method, p.Path, strings.NewReader(form.Encode()))
453 if err != nil {
454 log.Printf("console: resume %s %s: %v", p.Method, p.Path, err)
455 s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not resume that action. Please try it again.")
456 return
457 }
458 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
459 // The session cookie rides along — the resumed request is the same browser,
460 // and every gate it passes it passes on its own credentials, not on ours.
461 req.Header["Cookie"] = r.Header["Cookie"]
462 if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
463 req.Header.Set("X-Forwarded-For", xff)
464 }
465 req.Host = r.Host
466 req.RemoteAddr = r.RemoteAddr
467 s.mux.ServeHTTP(w, req)
468}
469