| 1 | // The enrolment completion oracle. An instance page that launches a passkey |
| 2 | // enrolment can't always receive the success redirect: iOS home-screen apps |
| 3 | // open cross-origin links in an in-app overlay with its own storage, and the |
| 4 | // native wrapper hands off to the system browser — in both, the redirect (and |
| 5 | // its nonce marker) lands in the wrong browsing context and the page that |
| 6 | // started the enrolment never learns it finished. So /enroll also reports |
| 7 | // completion here, keyed by the same client-random nonce the instance minted, |
| 8 | // and the instance polls GET /api/enroll-status until it confirms. |
| 9 | // |
| 10 | // Privacy: the nonce is random client entropy that never carries identity — |
| 11 | // this server learns nothing it didn't already know from storing the backup, |
| 12 | // and a forged "done" could only quiet an instance's own backup nudge. Marks |
| 13 | // live in memory only: enrolments are short-lived (the poll spans minutes), |
| 14 | // and losing them on a restart just falls back to the redirect path. |
| 15 | package main |
| 16 | |
| 17 | import ( |
| 18 | "encoding/json" |
| 19 | "net/http" |
| 20 | "strings" |
| 21 | "sync" |
| 22 | "time" |
| 23 | ) |
| 24 | |
| 25 | const ( |
| 26 | enrolMarkTTL = 30 * time.Minute |
| 27 | enrolMarkCap = 10000 // memory guard; enrolments are low-volume |
| 28 | nonceMinLen = 8 |
| 29 | nonceMaxLen = 128 |
| 30 | ) |
| 31 | |
| 32 | type enrolMarks struct { |
| 33 | mu sync.Mutex |
| 34 | done map[string]time.Time |
| 35 | now func() time.Time // injectable clock for tests |
| 36 | } |
| 37 | |
| 38 | func newEnrolMarks() *enrolMarks { |
| 39 | return &enrolMarks{done: map[string]time.Time{}, now: time.Now} |
| 40 | } |
| 41 | |
| 42 | // purgeLocked drops expired marks; callers hold mu. |
| 43 | func (m *enrolMarks) purgeLocked() { |
| 44 | cutoff := m.now().Add(-enrolMarkTTL) |
| 45 | for n, t := range m.done { |
| 46 | if t.Before(cutoff) { |
| 47 | delete(m.done, n) |
| 48 | } |
| 49 | } |
| 50 | } |
| 51 | |
| 52 | func (m *enrolMarks) mark(nonce string) { |
| 53 | m.mu.Lock() |
| 54 | defer m.mu.Unlock() |
| 55 | m.purgeLocked() |
| 56 | if len(m.done) >= enrolMarkCap { |
| 57 | return // refuse rather than evict — a flood mustn't unmark real enrolments |
| 58 | } |
| 59 | m.done[nonce] = m.now() |
| 60 | } |
| 61 | |
| 62 | func (m *enrolMarks) check(nonce string) bool { |
| 63 | m.mu.Lock() |
| 64 | defer m.mu.Unlock() |
| 65 | t, ok := m.done[nonce] |
| 66 | return ok && t.After(m.now().Add(-enrolMarkTTL)) |
| 67 | } |
| 68 | |
| 69 | func validNonce(n string) bool { |
| 70 | return len(n) >= nonceMinLen && len(n) <= nonceMaxLen |
| 71 | } |
| 72 | |
| 73 | // enrollDoneHandler (POST /api/enroll-done, rate-limited): the enrol page |
| 74 | // reports a finished ceremony. Same-origin only in practice (called by |
| 75 | // enroll.js), but nothing here needs a stronger gate — see the file header. |
| 76 | func (s *Server) enrollDoneHandler(w http.ResponseWriter, r *http.Request) { |
| 77 | var body struct { |
| 78 | Nonce string `json:"nonce"` |
| 79 | } |
| 80 | if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&body); err != nil { |
| 81 | http.Error(w, "bad request", http.StatusBadRequest) |
| 82 | return |
| 83 | } |
| 84 | nonce := strings.TrimSpace(body.Nonce) |
| 85 | if !validNonce(nonce) { |
| 86 | http.Error(w, "bad nonce", http.StatusBadRequest) |
| 87 | return |
| 88 | } |
| 89 | s.enrol.mark(nonce) |
| 90 | w.WriteHeader(http.StatusNoContent) |
| 91 | } |
| 92 | |
| 93 | // enrollStatusHandler (GET /api/enroll-status?nonce=...) answers "has an |
| 94 | // enrolment with this nonce completed?". CORS-open on purpose: the poller is |
| 95 | // an instance page on an arbitrary origin, and the nonce itself is the |
| 96 | // capability — without it the endpoint reveals nothing. |
| 97 | func (s *Server) enrollStatusHandler(w http.ResponseWriter, r *http.Request) { |
| 98 | w.Header().Set("Access-Control-Allow-Origin", "*") |
| 99 | w.Header().Set("Content-Type", "application/json") |
| 100 | nonce := r.URL.Query().Get("nonce") |
| 101 | done := validNonce(nonce) && s.enrol.check(nonce) |
| 102 | json.NewEncoder(w).Encode(map[string]bool{"done": done}) |
| 103 | } |
| 104 | |