| 1 | package home |
| 2 | |
| 3 | import ( |
| 4 | "embed" |
| 5 | "encoding/base64" |
| 6 | "net/http" |
| 7 | "time" |
| 8 | |
| 9 | "amadan.net/rastrillo/rastrillo/passkey" |
| 10 | "amadan.net/rastrillo/rastrillo/webauthn" |
| 11 | ) |
| 12 | |
| 13 | //go:embed static/passkey.js static/tokens.css static/assertion.js |
| 14 | var staticFS embed.FS |
| 15 | |
| 16 | // confirmPath is where the Gate sends a first-factor-verified caller who |
| 17 | // still has a passkey to assert. Named rather than defaulted: it happens |
| 18 | // to be passkey's own default, and a route this file mounts should not |
| 19 | // depend on a default in another package staying put. |
| 20 | const confirmPath = "/passkey/confirm" |
| 21 | |
| 22 | // codesFreshness is how recent a session must be to see recovery codes. |
| 23 | // |
| 24 | // Short, because the page shows ten codes once. Long enough to exceed a |
| 25 | // passkey ceremony round trip, or enrolling and then being asked for |
| 26 | // your codes would bounce straight back to a step-up. |
| 27 | // |
| 28 | // A FACT ABOUT RequireFresh THAT A LATER STEP MUST NOT ASSUME AWAY: |
| 29 | // freshness is not the same as "a real assertion". passkey.SignInRecovery |
| 30 | // mints its session with AuthTime: time.Now(), so redeeming a recovery |
| 31 | // code produces a session that satisfies RequireFresh at any duration. |
| 32 | // That is right here — somebody who just proved possession of a recovery |
| 33 | // code should be able to replace the set — but §5.2 says recovery is |
| 34 | // "sign-in only — step-up still demands a real assertion", and |
| 35 | // RequireFresh alone does not deliver that. §14's owner write is |
| 36 | // specified to sit behind step-up; if it is built on RequireFresh it |
| 37 | // will accept a recovery code instead. Asserted in |
| 38 | // TestRecoveryCodeProducesAFreshSession so the fact cannot change |
| 39 | // silently. |
| 40 | // |
| 41 | // It must be at least enrolFreshness. static/passkey.js sends the |
| 42 | // browser to /passkey/codes the instant registration finishes, so a |
| 43 | // window shorter than the one that let them enrol bounces them to |
| 44 | // re-authenticate at the exact moment they hold a passkey and zero |
| 45 | // recovery codes — the worst possible moment, and precisely the outcome |
| 46 | // the sentence above says this value was chosen to avoid. That sentence |
| 47 | // was true until enrolFreshness was added at a larger value and did not |
| 48 | // revisit it. TestFreshnessWindowsDoNotStrandAnEnroller pins the |
| 49 | // ordering. |
| 50 | const codesFreshness = enrolFreshness |
| 51 | |
| 52 | // enrolFreshness is how recent a session must be to ADD a passkey. |
| 53 | // |
| 54 | // rastrillo's own RegisterBegin asks only for a valid session, and for |
| 55 | // most apps that is right. It is not right here, because of what the |
| 56 | // Gate plus §5.2 add up to: there is no operator reset, by design. |
| 57 | // |
| 58 | // The vector, spelled out because it is not obvious. Somebody with a |
| 59 | // stolen session cookie — good for 30 days — visits /passkey/enrol and |
| 60 | // enrols THEIR authenticator. From that moment the real owner's magic |
| 61 | // link lands on the Gate and stops, because the Gate demands a passkey |
| 62 | // they do not have; and they have no recovery codes, because they never |
| 63 | // enrolled. A compromise that would have expired with the cookie becomes |
| 64 | // a permanent lockout with nobody to appeal to. |
| 65 | // |
| 66 | // Requiring freshness costs a person who signed in a week ago one more |
| 67 | // sign-in: RequireFresh redirects a stale GET to the sign-in page with |
| 68 | // reauth=1, so it is a detour and not a dead end. It costs an attacker |
| 69 | // the inbox, which is the whole point. |
| 70 | // |
| 71 | // Ten minutes: enrolling means reading a page, deciding, and completing |
| 72 | // an authenticator gesture. The codes page runs on the same window and |
| 73 | // not a shorter one — see codesFreshness, which is defined as this value |
| 74 | // precisely because a shorter one stranded people mid-flow. An earlier |
| 75 | // version of this sentence said "rather than the codes page's five", |
| 76 | // which was true when written and stood falsely for two commits after |
| 77 | // codesFreshness was redefined as this value. |
| 78 | const enrolFreshness = 10 * time.Minute |
| 79 | |
| 80 | // passkeyConfig is the config the app builds. |
| 81 | // |
| 82 | // It is returned rather than inlined so a test can assert on what the |
| 83 | // APP configures instead of on a literal it wrote itself — an RP-ID |
| 84 | // guard that declares its own config asserts a constant against itself. |
| 85 | // passkey.Handlers keeps its config unexported, so the App retains this |
| 86 | // value for that purpose. |
| 87 | // |
| 88 | // LegacyRPID is deliberately empty. It exists for an instance that has |
| 89 | // changed hostname, so passkeys enrolled under the old name still |
| 90 | // assert; this app has had exactly one credential origin, and naming a |
| 91 | // legacy RP that was never used would widen what assertions are accepted |
| 92 | // for no benefit. It is accepted for assertions only — never for |
| 93 | // registration — which is what makes it a quiet way to widen the RP ID |
| 94 | // rather than an obvious one. |
| 95 | func passkeyConfig(a *App) passkey.Config { |
| 96 | return passkey.Config{ |
| 97 | Sessions: a.sessions, |
| 98 | DB: a.db.Writer(), |
| 99 | Origin: a.origins.Credential, |
| 100 | ConfirmPath: confirmPath, |
| 101 | Logger: a.logger, |
| 102 | } |
| 103 | } |
| 104 | |
| 105 | // newPasskeys wires the second factor. |
| 106 | func newPasskeys(a *App) (*passkey.Handlers, error) { |
| 107 | a.passkeyCfg = passkeyConfig(a) |
| 108 | return passkey.New(a.passkeyCfg) |
| 109 | } |
| 110 | |
| 111 | // pageData is what every passkey page needs: the values the glue reads |
| 112 | // off <body> to run a ceremony. |
| 113 | // |
| 114 | // UserName is the person ref, NOT the address. The credential origin |
| 115 | // never renders an account-authored string (§4), and this app does not |
| 116 | // hold the address anyway. A person ref reads poorly in a password |
| 117 | // manager; that is the cost of §0, and it is accepted rather than worked |
| 118 | // around. |
| 119 | func (a *App) passkeyPageData(subject string, extra map[string]any) map[string]any { |
| 120 | data := map[string]any{ |
| 121 | "RPID": a.credentialHost, |
| 122 | "LegacyRPID": a.passkeyCfg.LegacyRPID, |
| 123 | "UserID": base64.RawURLEncoding.EncodeToString([]byte(subject)), |
| 124 | "UserName": subject, |
| 125 | } |
| 126 | for k, v := range extra { |
| 127 | data[k] = v |
| 128 | } |
| 129 | return data |
| 130 | } |
| 131 | |
| 132 | // handlePasskeyConfirm is the Gate's landing page: the ceremony button |
| 133 | // and the recovery form on one screen. |
| 134 | // |
| 135 | // The recovery form is a plain POST and works with script off, which is |
| 136 | // the point — recovery is exactly the moment WebAuthn is not working. |
| 137 | func (a *App) handlePasskeyConfirm(w http.ResponseWriter, r *http.Request) { |
| 138 | a.render(w, r, http.StatusOK, "passkey_confirm.html", a.passkeyPageData("", map[string]any{ |
| 139 | "RecoveryFailed": r.URL.Query().Get("recovery") == "failed", |
| 140 | })) |
| 141 | } |
| 142 | |
| 143 | // handlePasskeyEnrol offers the optional second factor to a signed-in |
| 144 | // person. |
| 145 | func (a *App) handlePasskeyEnrol(w http.ResponseWriter, r *http.Request) { |
| 146 | sess, ok := a.sessions.From(r) |
| 147 | if !ok { |
| 148 | http.Redirect(w, r, "/signin", http.StatusSeeOther) |
| 149 | return |
| 150 | } |
| 151 | a.render(w, r, http.StatusOK, "passkey_enrol.html", a.passkeyPageData(sess.Subject, nil)) |
| 152 | } |
| 153 | |
| 154 | // handleRecoveryCodesPage shows how many codes are left, and the form |
| 155 | // that replaces them. |
| 156 | // |
| 157 | // Regenerating is a POST, not a GET. The plan's route list named only a |
| 158 | // GET, but minting ten codes and invalidating the previous ten is a |
| 159 | // state change, and this whole step exists on the principle that a GET |
| 160 | // does not change state — a link prefetcher would otherwise silently |
| 161 | // invalidate somebody's recovery codes. |
| 162 | func (a *App) handleRecoveryCodesPage(w http.ResponseWriter, r *http.Request) { |
| 163 | sess, ok := a.sessions.From(r) |
| 164 | if !ok { |
| 165 | http.Redirect(w, r, "/signin", http.StatusSeeOther) |
| 166 | return |
| 167 | } |
| 168 | remaining, err := a.passkeys.RecoveryCodesRemaining(sess.Subject) |
| 169 | if err != nil { |
| 170 | a.logger.Error("count recovery codes", "person", sess.Subject, "err", err) |
| 171 | a.serverError(w, r) |
| 172 | return |
| 173 | } |
| 174 | a.render(w, r, http.StatusOK, "recovery_codes.html", map[string]any{"Remaining": remaining}) |
| 175 | } |
| 176 | |
| 177 | // handleRegenerateRecoveryCodes mints a fresh set and shows it once. |
| 178 | func (a *App) handleRegenerateRecoveryCodes(w http.ResponseWriter, r *http.Request) { |
| 179 | sess, ok := a.sessions.From(r) |
| 180 | if !ok { |
| 181 | http.Redirect(w, r, "/signin", http.StatusSeeOther) |
| 182 | return |
| 183 | } |
| 184 | codes, err := a.passkeys.RegenerateRecoveryCodes(sess.Subject) |
| 185 | if err != nil { |
| 186 | a.logger.Error("regenerate recovery codes", "person", sess.Subject, "err", err) |
| 187 | a.serverError(w, r) |
| 188 | return |
| 189 | } |
| 190 | a.render(w, r, http.StatusOK, "recovery_codes.html", map[string]any{"Codes": codes}) |
| 191 | } |
| 192 | |
| 193 | // serveAsset serves one of the two script files. |
| 194 | // |
| 195 | // Stable and unfingerprinted on purpose: the scriptless sweep's |
| 196 | // allowlist has to name them. |
| 197 | // |
| 198 | // The Content-Type is set explicitly even though Go's mime table already |
| 199 | // maps .mjs. A wrong MIME type makes a browser refuse a module silently, |
| 200 | // which is the worst way for this to fail. |
| 201 | func serveAsset(body []byte, contentType string) http.HandlerFunc { |
| 202 | version := contentVersion(body) |
| 203 | etag := `"` + version + `"` |
| 204 | return func(w http.ResponseWriter, r *http.Request) { |
| 205 | w.Header().Set("Content-Type", contentType) |
| 206 | w.Header().Set("ETag", etag) |
| 207 | // A URL carrying this body's version can never serve anything |
| 208 | // else, so the browser keeps it for a year without asking. Any |
| 209 | // other request — the bare path, a stale version — revalidates, |
| 210 | // and the ETag makes that a 304 rather than the bytes again. |
| 211 | if r.URL.Query().Get("v") == version { |
| 212 | w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") |
| 213 | } else { |
| 214 | w.Header().Set("Cache-Control", "no-cache") |
| 215 | } |
| 216 | if etagMatches(r.Header.Get("If-None-Match"), etag) { |
| 217 | w.WriteHeader(http.StatusNotModified) |
| 218 | return |
| 219 | } |
| 220 | w.WriteHeader(http.StatusOK) |
| 221 | w.Write(body) |
| 222 | } |
| 223 | } |
| 224 | |
| 225 | func staticAsset(name string) []byte { |
| 226 | b, err := staticFS.ReadFile("static/" + name) |
| 227 | if err != nil { |
| 228 | // Embedded above; unreachable unless the build is broken. |
| 229 | panic(err) |
| 230 | } |
| 231 | return b |
| 232 | } |
| 233 | |
| 234 | func webauthnJS() []byte { return webauthn.JS() } |
| 235 | |