// The enrolment completion oracle. An instance page that launches a passkey // enrolment can't always receive the success redirect: iOS home-screen apps // open cross-origin links in an in-app overlay with its own storage, and the // native wrapper hands off to the system browser — in both, the redirect (and // its nonce marker) lands in the wrong browsing context and the page that // started the enrolment never learns it finished. So /enroll also reports // completion here, keyed by the same client-random nonce the instance minted, // and the instance polls GET /api/enroll-status until it confirms. // // Privacy: the nonce is random client entropy that never carries identity — // this server learns nothing it didn't already know from storing the backup, // and a forged "done" could only quiet an instance's own backup nudge. Marks // live in memory only: enrolments are short-lived (the poll spans minutes), // and losing them on a restart just falls back to the redirect path. package main import ( "encoding/json" "net/http" "strings" "sync" "time" ) const ( enrolMarkTTL = 30 * time.Minute enrolMarkCap = 10000 // memory guard; enrolments are low-volume nonceMinLen = 8 nonceMaxLen = 128 ) type enrolMarks struct { mu sync.Mutex done map[string]time.Time now func() time.Time // injectable clock for tests } func newEnrolMarks() *enrolMarks { return &enrolMarks{done: map[string]time.Time{}, now: time.Now} } // purgeLocked drops expired marks; callers hold mu. func (m *enrolMarks) purgeLocked() { cutoff := m.now().Add(-enrolMarkTTL) for n, t := range m.done { if t.Before(cutoff) { delete(m.done, n) } } } func (m *enrolMarks) mark(nonce string) { m.mu.Lock() defer m.mu.Unlock() m.purgeLocked() if len(m.done) >= enrolMarkCap { return // refuse rather than evict — a flood mustn't unmark real enrolments } m.done[nonce] = m.now() } func (m *enrolMarks) check(nonce string) bool { m.mu.Lock() defer m.mu.Unlock() t, ok := m.done[nonce] return ok && t.After(m.now().Add(-enrolMarkTTL)) } func validNonce(n string) bool { return len(n) >= nonceMinLen && len(n) <= nonceMaxLen } // enrollDoneHandler (POST /api/enroll-done, rate-limited): the enrol page // reports a finished ceremony. Same-origin only in practice (called by // enroll.js), but nothing here needs a stronger gate — see the file header. func (s *Server) enrollDoneHandler(w http.ResponseWriter, r *http.Request) { var body struct { Nonce string `json:"nonce"` } if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1024)).Decode(&body); err != nil { http.Error(w, "bad request", http.StatusBadRequest) return } nonce := strings.TrimSpace(body.Nonce) if !validNonce(nonce) { http.Error(w, "bad nonce", http.StatusBadRequest) return } s.enrol.mark(nonce) w.WriteHeader(http.StatusNoContent) } // enrollStatusHandler (GET /api/enroll-status?nonce=...) answers "has an // enrolment with this nonce completed?". CORS-open on purpose: the poller is // an instance page on an arbitrary origin, and the nonce itself is the // capability — without it the endpoint reveals nothing. func (s *Server) enrollStatusHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Content-Type", "application/json") nonce := r.URL.Query().Get("nonce") done := validNonce(nonce) && s.enrol.check(nonce) json.NewEncoder(w).Encode(map[string]bool{"done": done}) }