package console // Passkey step-up for sensitive actions (spec §7a). // // Membership answers *who you are*. It does not answer *are you here now* — and // for the handful of actions where that is the whole question (approving a // `carlos auth login` device grant, removing a member, transferring ownership, // deleting an account, revoking CLI tokens) a session cookie lifted from a // laptop is currently indistinguishable from the person it belongs to. // // The route is a FRESH Keymail authentication, not console-native WebAuthn: // identity belongs in one place in this platform, and a console holding its own // credentials would be a second identity system with a second attack surface. // Keymail already asserts with `UserVerification: VerificationRequired`; what it // gained for this is `prompt=login` and an `auth_time` claim (keymail // docs/apps.md), so the console can DEMAND a gesture and then VERIFY one // happened rather than trusting the redirect it got back. // // Three properties this file exists to hold, in order of how easily they are // lost: // // 1. UNSKIPPABLE SERVER-SIDE. The gate is not a UI affordance. requireFreshAuth // reads database state written only by a verified callback; a direct POST // with a valid session and a valid CSRF token is refused all the same. // // 2. THE PENDING ACTION IS NOT A CAPABILITY. The action is preserved across the // round trip so a human is not dumped back at square one, but it is stored // SERVER-SIDE, bound to the session that stashed it, single-use (burned by // row deletion) and short-lived. The browser carries an opaque token in an // HttpOnly cookie and nothing else — it cannot swap the action, replay it, // or hand it to another session. // // 3. NO ORACLE. Step-up runs AFTER the membership/ownership gate, never before. // A non-member probing an owner-only action must still get the // byte-identical http.NotFound, not a redirect to Keymail that would confirm // the account exists. // // The CSRF check stays exactly where it was. Step-up is in addition to it: CSRF // answers "did this browser mean to send this?", step-up answers "is the human // still here?", and neither substitutes for the other. import ( "context" "crypto/subtle" "database/sql" "log" "net/http" "net/url" "strings" "time" ) const ( // StepUpWindow is how recent the authenticator gesture must be for a // sensitive action to proceed. // // Five minutes. Shorter is not obviously safer here and is definitely // worse: the gated flows are multi-step (approving a device grant means // typing a code, reading the terminal's description, then deciding), and a // window that expires mid-decision trains people to click through // authenticator prompts without reading them. Longer starts to defeat the // point — the threat is a session stolen and used soon after, and every // extra minute is a minute of that theft that lands inside the window. // Five minutes also covers the round trip itself (redirect, consent card, // a slow authenticator) with room to spare, so the common case is one // touch, not two. // // It is measured from the GESTURE (Keymail's auth_time), not from when the // console heard about it — the freshness that matters is the human's, and // timing the console's own bookkeeping would let a slow network buy time. StepUpWindow = 5 * time.Minute // stepUpFlowTTL bounds the round trip: mint state, go to Keymail, come // back. Ten minutes is the same order as the sign-in flow's own cookie. stepUpFlowTTL = 10 * time.Minute // stepUpSkew tolerates a small clock difference between this console and // the Keymail deployment. Deliberately tiny — this is skew tolerance, not // a second freshness window. stepUpSkew = 60 * time.Second // stepUpCookie carries state + PKCE verifier + the pending-action token // across the round trip. HttpOnly, like every other cookie here, and // never a query parameter: a URL ends up in history, referrers and logs, // and the pending-action token must not. stepUpCookie = "carlos_console_stepup" ) // stepUpUnavailable is what a human sees when the identity provider cannot // re-authenticate them, or cannot be reached to try. Failing CLOSED is the // whole design: the alternative — quietly proceeding on session-only // authority — is precisely the hole this feature exists to close, and it would // close it only on the days Keymail happens to be up. const stepUpUnavailable = "This action needs you to confirm it's really you with your passkey, " + "and Keymail can't be asked right now. Nothing has been changed. Try again in a moment — " + "if this keeps happening, this deployment's Keymail does not support re-authentication " + "and these actions stay blocked until it does." // stepUpHoldFailed is the message when the console cannot hold on to the // action, or (an email session) cannot send the confirmation for it. const stepUpHoldFailed = "Could not hold on to that action. Please try again." // ---- storage ---------------------------------------------------------------- // migrateStepUp adds this feature's two tables. Additive and idempotent, and // kept here rather than in store.go's list so the feature is one file to read // and one file to remove (the authstore.go precedent). func (s *Store) migrateStepUp() error { for _, stmt := range []string{ // One row per session that has stepped up. Keyed by the session hash, // so it dies with the session and cannot be moved to another one. // address is stored alongside for a belt-and-braces check: a session // row could in principle be re-pointed, and a step-up recorded for one // identity must never satisfy another. `CREATE TABLE IF NOT EXISTS step_ups ( session_hash TEXT PRIMARY KEY, address TEXT NOT NULL, auth_time INTEGER NOT NULL, recorded_at INTEGER NOT NULL );`, // The action a human was part-way through when the gate stopped them. // Stored server-side precisely so it cannot be edited in flight: the // browser holds an opaque token, never the method, path or fields. `CREATE TABLE IF NOT EXISTS pending_actions ( token_hash TEXT PRIMARY KEY, session_hash TEXT NOT NULL, address TEXT NOT NULL, method TEXT NOT NULL, path TEXT NOT NULL, form TEXT NOT NULL, created_at INTEGER NOT NULL, expires_at INTEGER NOT NULL );`, } { if _, err := s.db.Exec(stmt); err != nil { return err } } return nil } // RecordStepUp remembers that this session's owner proved presence at // authTime. Upsert: a newer gesture replaces an older one. func (s *Store) RecordStepUp(rawSession, address string, authTime, now time.Time) error { _, err := s.db.Exec(`INSERT INTO step_ups (session_hash, address, auth_time, recorded_at) VALUES (?, ?, ?, ?) ON CONFLICT(session_hash) DO UPDATE SET address = excluded.address, auth_time = excluded.auth_time, recorded_at = excluded.recorded_at`, hashToken(rawSession), address, authTime.Unix(), now.Unix()) return err } // FreshStepUp is the gate's one question. Both the session AND the address must // match, and the gesture must be inside the window; anything else — no row, a // stale row, a row belonging to another identity — is false. func (s *Store) FreshStepUp(rawSession, address string, now time.Time, window time.Duration) bool { var storedAddr string var authTime int64 err := s.db.QueryRow(`SELECT address, auth_time FROM step_ups WHERE session_hash = ?`, hashToken(rawSession)).Scan(&storedAddr, &authTime) if err != nil || storedAddr != address { return false } age := now.Sub(time.Unix(authTime, 0)) return age >= -stepUpSkew && age <= window } // ClearStepUp drops a session's proof of presence. Called on sign-out: the // session is gone, and so is any authority it had accrued. func (s *Store) ClearStepUp(rawSession string) error { _, err := s.db.Exec(`DELETE FROM step_ups WHERE session_hash = ?`, hashToken(rawSession)) return err } // pendingAction is a POST held mid-flight. type pendingAction struct { Method string Path string Form string } // StashPendingAction parks the request the gate interrupted and returns the // opaque token that redeems it. Housekeeping on the write path: expired stashes // have nothing left to resume. func (s *Store) StashPendingAction(rawSession, address, method, path, form string, now time.Time) (string, error) { s.db.Exec(`DELETE FROM pending_actions WHERE expires_at < ?`, now.Unix()) raw, err := NewVerifier() if err != nil { return "", err } _, err = s.db.Exec(`INSERT INTO pending_actions (token_hash, session_hash, address, method, path, form, created_at, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, hashToken(raw), hashToken(rawSession), address, method, path, form, now.Unix(), now.Add(stepUpFlowTTL).Unix()) if err != nil { return "", err } return raw, nil } // TakePendingAction redeems a stash exactly once, and only for the session that // created it. Burn-then-check, like the OAuth code and the confirmation link: the // row is deleted before any verdict is returned, so a wrong-session or expired // attempt cannot be retried into a right one. func (s *Store) TakePendingAction(rawToken, rawSession string, now time.Time) (*pendingAction, bool) { if rawToken == "" { return nil, false } return s.takePendingActionByHash(hashToken(rawToken), rawSession, now) } // takePendingActionByHash is TakePendingAction for a caller that holds only // the token's hash (an email step-up challenge stores the hash, never the // token): the same transaction, single-use delete, session binding and expiry // check. func (s *Store) takePendingActionByHash(hash, rawSession string, now time.Time) (*pendingAction, bool) { if hash == "" { return nil, false } tx, err := s.db.Begin() if err != nil { return nil, false } defer tx.Rollback() var sessionHash string var expires int64 var p pendingAction err = tx.QueryRow(`SELECT session_hash, method, path, form, expires_at FROM pending_actions WHERE token_hash = ?`, hash). Scan(&sessionHash, &p.Method, &p.Path, &p.Form, &expires) if err == sql.ErrNoRows { return nil, false } if err != nil { return nil, false } if _, err := tx.Exec(`DELETE FROM pending_actions WHERE token_hash = ?`, hash); err != nil { return nil, false } if err := tx.Commit(); err != nil { return nil, false } if subtle.ConstantTimeCompare([]byte(sessionHash), []byte(hashToken(rawSession))) != 1 { return nil, false } if now.Unix() > expires { return nil, false } return &p, true } // ---- the gate --------------------------------------------------------------- // requireFreshAuth is the step-up gate. It returns true only when this session // has proved presence inside StepUpWindow; otherwise it has ALREADY written the // response (a redirect into Keymail's re-authentication, for an email session // the "Confirm it's you" page of an emailed link and code, or a fail-closed // message) and the caller must simply return. // // Call it LAST — after the membership/ownership gate and after the CSRF check. // Order is load-bearing twice over: a non-member must get the byte-identical // 404 rather than a redirect that confirms the account exists, and a // cross-site POST must be refused before it can park a pending action. func (s *Server) requireFreshAuth(w http.ResponseWriter, r *http.Request, addr, raw string) bool { now := time.Now() if s.store.FreshStepUp(raw, addr, now, StepUpWindow) { return true } // A session that signed in by email re-confirms by email (spec 6). Only // that method does: a Keymail session, or one whose method cannot be // read, takes the passkey path below and never an email instead. if _, method, ok := s.store.SessionInfo(raw, now); ok && method == MethodEmail { s.emailStepUp(w, r, addr, raw, now) return false } // Ask the identity provider whether it can do this at all before sending a // human off to try. Unreachable and "no" are the same answer here — see // stepUpUnavailable. ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() switch supported, err := s.km.SupportsStepUp(ctx); { case err != nil: log.Printf("console: step-up capability check: %v", err) s.renderMessage(w, r, http.StatusServiceUnavailable, "Can't confirm it's you", stepUpUnavailable) return false case !supported: log.Printf("console: step-up refused: %s does not advertise re-authentication", s.cfg.Keymail) s.renderMessage(w, r, http.StatusServiceUnavailable, "Can't confirm it's you", stepUpUnavailable) return false } state, err := NewVerifier() if err != nil { s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the confirmation. Please try again.") return false } verifier, err := NewVerifier() if err != nil { s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not start the confirmation. Please try again.") return false } pending, ok := s.stashRequest(w, r, addr, raw, now) if !ok { return false } s.setCookie(w, stepUpCookie, state+"."+verifier+"."+pending, int(stepUpFlowTTL.Seconds())) http.Redirect(w, r, s.km.StepUpURL(state, ChallengeS256(verifier), StepUpWindow), http.StatusSeeOther) return false } // stashRequest parks the request the gate interrupted (StashPendingAction) // and returns the token that redeems it. On false it has already written the // response. func (s *Server) stashRequest(w http.ResponseWriter, r *http.Request, addr, raw string, now time.Time) (string, bool) { // The handler has already read fields off this request, so PostForm is // parsed and cached; Encode gives back exactly what it will be re-fed. if err := r.ParseForm(); err != nil { s.renderMessage(w, r, http.StatusBadRequest, "Refused", "That request could not be read.") return "", false } // The csrf field is deliberately NOT stored. It is the raw session token, // and sessions are kept hashed here precisely so a database read is not a // session; parking a live one in a form blob would quietly undo that. The // resume re-injects it from the cookie the resuming request carries — which // costs nothing in safety, because the cross-site question was already // answered before this stash was ever created. form := url.Values{} for k, v := range r.PostForm { if k != "csrf" { form[k] = v } } pending, err := s.store.StashPendingAction(raw, addr, r.Method, r.URL.Path, form.Encode(), now) if err != nil { log.Printf("console: stash pending action: %v", err) s.renderMessage(w, r, http.StatusInternalServerError, "Error", stepUpHoldFailed) return "", false } return pending, true } // handleStepUpCallback is where a re-authentication lands. It trusts nothing // about the redirect itself: the code is exchanged server-side, and the grant // that comes back must name the SAME address as the session and carry an // auth_time inside the window. Only then is presence recorded and the held // action resumed. func (s *Server) handleStepUpCallback(w http.ResponseWriter, r *http.Request) { addr, raw, ok := s.session(r) if !ok { http.Redirect(w, r, "/", http.StatusFound) return } c, err := r.Cookie(stepUpCookie) if err != nil || c.Value == "" { s.renderMessage(w, r, http.StatusBadRequest, "Confirmation expired", "That confirmation is stale or was started in another browser. Nothing has been changed — please try the action again.") return } s.clearCookie(w, stepUpCookie) parts := strings.SplitN(c.Value, ".", 3) if len(parts) != 3 || parts[0] == "" || subtle.ConstantTimeCompare([]byte(parts[0]), []byte(r.URL.Query().Get("state"))) != 1 { s.renderMessage(w, r, http.StatusBadRequest, "Confirmation mismatch", "That response didn't match this browser's confirmation attempt. Nothing has been changed — please try the action again.") return } verifier, pendingToken := parts[1], parts[2] if e := r.URL.Query().Get("error"); e != "" { // Cancelled at Keymail, or refused there. Not an error state — but // emphatically not a pass either. s.store.TakePendingAction(pendingToken, raw, time.Now()) // burn the stash s.renderMessage(w, r, http.StatusOK, "Not confirmed", "You didn't confirm it was you, so nothing has been changed.") return } code := r.URL.Query().Get("code") if code == "" { s.renderMessage(w, r, http.StatusBadRequest, "Not confirmed", "Keymail sent no authorization code. Nothing has been changed — please try the action again.") return } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() grant, err := s.km.ExchangeGrant(ctx, code, verifier, true) if err != nil { log.Printf("console: step-up exchange: %v", err) s.renderMessage(w, r, http.StatusBadGateway, "Can't confirm it's you", stepUpUnavailable) return } now := time.Now() // The identity check. Keymail's identity service is deliberately anonymous — // a fresh assertion there proves SOME registered passkey was verified, not // whose. This comparison is what turns it into proof about this session's // owner: step up with somebody else's passkey and the address won't match. if !strings.EqualFold(grant.Address, addr) { log.Printf("console: step-up address mismatch for session of %s", addr) s.renderMessage(w, r, http.StatusForbidden, "Not confirmed", "That passkey belongs to a different Keymail address than the one you're signed in as. Nothing has been changed.") return } // A re-authorization rotates the console's grant at Keymail, so the stored // token would otherwise be dead by the next invite or confirmation send. if err := s.store.SetGrantToken(grant.Address, grant.Token, now); err != nil { log.Printf("console: step-up grant refresh: %v", err) } // Fail closed on a missing or stale auth_time. An IdP that did not report // when the gesture happened has not proved that one did. age := now.Sub(grant.AuthTime) if grant.AuthTime.IsZero() || age > StepUpWindow || age < -stepUpSkew { log.Printf("console: step-up produced no usable auth_time (zero=%v age=%s)", grant.AuthTime.IsZero(), age) s.renderMessage(w, r, http.StatusForbidden, "Can't confirm it's you", stepUpUnavailable) return } if err := s.store.RecordStepUp(raw, addr, grant.AuthTime, now); err != nil { log.Printf("console: record step-up: %v", err) s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not record your confirmation. Please try the action again.") return } pending, ok := s.store.TakePendingAction(pendingToken, raw, now) if !ok { // Confirmed, but the held action is gone (expired, already resumed, or // belonged to another session). Say so plainly rather than guessing. s.renderMessage(w, r, http.StatusOK, "Confirmed", "Thanks — that's you. The action you were doing has expired, so nothing was changed. Please try it again.") return } s.resume(w, r, pending) } // resume re-runs the held POST through the console's own router, so it meets // every gate it would have met the first time — session, CSRF, membership, // ownership, and the step-up gate that now passes. Re-dispatching rather than // calling a handler directly is the point: there is no bypass path, because // there is no second door. func (s *Server) resume(w http.ResponseWriter, r *http.Request, p *pendingAction) { _, raw, ok := s.session(r) if !ok { http.Redirect(w, r, "/", http.StatusFound) return } form, err := url.ParseQuery(p.Form) if err != nil { log.Printf("console: resume %s %s: %v", p.Method, p.Path, err) s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not resume that action. Please try it again.") return } // Put back the one field the stash refuses to hold (see requireFreshAuth): // the resumed request must satisfy the same CSRF check as any other, and // the token it satisfies it with is this browser's own, read from its // cookie, not something recovered from storage. form.Set("csrf", raw) req, err := http.NewRequestWithContext(r.Context(), p.Method, p.Path, strings.NewReader(form.Encode())) if err != nil { log.Printf("console: resume %s %s: %v", p.Method, p.Path, err) s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not resume that action. Please try it again.") return } req.Header.Set("Content-Type", "application/x-www-form-urlencoded") // The session cookie rides along — the resumed request is the same browser, // and every gate it passes it passes on its own credentials, not on ours. req.Header["Cookie"] = r.Header["Cookie"] if xff := r.Header.Get("X-Forwarded-For"); xff != "" { req.Header.Set("X-Forwarded-For", xff) } req.Host = r.Host req.RemoteAddr = r.RemoteAddr s.mux.ServeHTTP(w, req) }