eleven / messenger Public

Clone
git clone https://amadan.net/eleven/messenger

Plain git — no account needed to clone.

Download

Download this file

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.
15package main
16
17import (
18 "encoding/json"
19 "net/http"
20 "strings"
21 "sync"
22 "time"
23)
24
25const (
26 enrolMarkTTL = 30 * time.Minute
27 enrolMarkCap = 10000 // memory guard; enrolments are low-volume
28 nonceMinLen = 8
29 nonceMaxLen = 128
30)
31
32type enrolMarks struct {
33 mu sync.Mutex
34 done map[string]time.Time
35 now func() time.Time // injectable clock for tests
36}
37
38func newEnrolMarks() *enrolMarks {
39 return &enrolMarks{done: map[string]time.Time{}, now: time.Now}
40}
41
42// purgeLocked drops expired marks; callers hold mu.
43func (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
52func (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
62func (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
69func 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.
76func (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.
97func (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