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 "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
14var 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.
20const 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.
50const 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.
78const 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.
95func 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.
106func 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.
119func (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.
137func (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.
145func (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.
162func (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.
178func (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.
201func 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
225func 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
234func webauthnJS() []byte { return webauthn.JS() }
235