eleven / messenger Public

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

Plain git — no account needed to clone.

Download

Download this file

1// WebAuthn ceremonies. Two of them:
2//
3// - register (attestation): creates a user + credential. Adding a credential
4// to an EXISTING user (a second passkey = recovery redundancy) requires a
5// FRESH ceremony proof — never a link token; otherwise a brand-new
6// anonymous user is minted.
7// - login (discoverable assertion): proves possession of an enrolled
8// passkey; success mints a device link token + a short-lived ceremony proof.
9//
10// The PRF extension is requested and evaluated entirely CLIENT-side (its
11// output derives the KEK — see web/static/crypto.js). The server never asks
12// for it and never receives its output; it merely tolerates whatever
13// clientExtensionResults arrive (the library parses them as an opaque map).
14package main
15
16import (
17 "encoding/base64"
18 "encoding/json"
19 "errors"
20 "net/http"
21 "time"
22
23 "github.com/go-webauthn/webauthn/protocol"
24 "github.com/go-webauthn/webauthn/webauthn"
25)
26
27// waRelyingParty wraps the library handle so main.go stays import-clean.
28type waRelyingParty struct{ *webauthn.WebAuthn }
29
30func newRelyingParty(rpID, origin string) (*waRelyingParty, error) {
31 wa, err := webauthn.New(&webauthn.Config{
32 // The display name is deliberately constant and generic: passkey managers
33 // show it, and it must not carry anything user-identifying. There is no
34 // account name here by design, so the slot names the SERVICE the passkey
35 // belongs to — the system sheet reads «… with your passkey for "Sign in
36 // with Eleven"». (The label is baked into a credential at enrolment, so
37 // existing passkeys keep their old name unless renamed in the user's
38 // passkey manager.)
39 RPDisplayName: "Sign in with Eleven",
40 RPID: rpID,
41 RPOrigins: []string{origin},
42 })
43 if err != nil {
44 return nil, err
45 }
46 return &waRelyingParty{wa}, nil
47}
48
49// homeUser adapts our anonymous user row to the library's User interface. The
50// name/display-name are the same fixed string for everyone — an account here
51// has no name, and the passkey entry in the user's manager shouldn't invent one.
52type homeUser struct {
53 id string
54 creds []webauthn.Credential
55}
56
57func (u *homeUser) WebAuthnID() []byte { return []byte(u.id) }
58func (u *homeUser) WebAuthnName() string { return "Sign in with Eleven" }
59func (u *homeUser) WebAuthnDisplayName() string { return "Sign in with Eleven" }
60func (u *homeUser) WebAuthnCredentials() []webauthn.Credential { return u.creds }
61
62// ---- in-flight ceremony sessions -------------------------------------------
63//
64// Between begin and finish the only state a ceremony needs is the library's
65// SessionData plus a couple of our own fields — and the client already
66// round-trips an opaque handle through the ?session= query param. So instead
67// of an in-memory map (which pinned a ceremony to one process), the whole
68// entry is AES-GCM sealed into that param (stateless.go) and the server keeps
69// nothing. Same param, same endpoints — the wire flow is unchanged.
70//
71// One deliberate trade against the old map: sessions are no longer single-use
72// (there is nothing to delete on take). Within sessionTTL a captured finish
73// request could be replayed — but its bearer already holds everything that
74// finish returned, and the authenticator's signed response is still bound to
75// this one challenge, so replay buys nothing a first read didn't.
76
77const sessionTTL = 5 * time.Minute
78
79type sessionEntry struct {
80 Data *webauthn.SessionData `json:"d"`
81 Kind string `json:"k"` // "register" | "login"
82 // UserID + Existing carry the register flow's decision from begin to
83 // finish: which user the new credential attaches to, and whether that user
84 // already exists (vs. is created on finish).
85 UserID string `json:"u,omitempty"`
86 Existing bool `json:"e,omitempty"`
87 Expires int64 `json:"x"` // unix seconds; enforced server-side on open
88}
89
90// sealSession stamps the expiry and seals the entry into the opaque string the
91// client will echo back on finish.
92func (s *Server) sealSession(e sessionEntry) (string, error) {
93 e.Expires = time.Now().Add(sessionTTL).Unix()
94 b, err := json.Marshal(e)
95 if err != nil {
96 return "", err
97 }
98 return s.keys.seal(b)
99}
100
101// openSession unseals a presented session param and checks it's the right
102// kind of ceremony and still fresh. Tampering, a wrong key, garbage — all
103// fail the GCM open and land here as !ok.
104func (s *Server) openSession(token, kind string) (sessionEntry, bool) {
105 raw, ok := s.keys.open(token)
106 if !ok {
107 return sessionEntry{}, false
108 }
109 var e sessionEntry
110 if json.Unmarshal(raw, &e) != nil || e.Kind != kind || e.Data == nil ||
111 time.Now().Unix() > e.Expires {
112 return sessionEntry{}, false
113 }
114 return e, true
115}
116
117// ---- register ---------------------------------------------------------------
118
119func (s *Server) registerBegin(w http.ResponseWriter, r *http.Request) {
120 // A fresh ceremony proof means "add a passkey to MY account"; with none,
121 // this is a brand-new enrolment. A link token is deliberately NOT accepted
122 // here: an enrolled credential can log in and mint fresh ceremony proofs,
123 // so accepting one would escalate a stolen read/write token into permanent
124 // account takeover (enrol attacker passkey -> ceremony -> destroy blob,
125 // revoke devices). Link tokens authorise exactly blob read/write and the
126 // devices listing — nothing else. A presented token is refused outright
127 // rather than ignored, so a client that meant "add to my account" can't
128 // silently enrol a second, empty account instead.
129 userID, existing := s.ceremonyUser(r)
130 if !existing {
131 if bearerToken(r) != "" {
132 http.Error(w, "fresh passkey ceremony required to add a passkey", http.StatusForbidden)
133 return
134 }
135 id, err := randToken()
136 if err != nil {
137 http.Error(w, "server error", http.StatusInternalServerError)
138 return
139 }
140 // The new user id is minted now but only written to the DB when the
141 // ceremony verifies, so abandoned ceremonies leave no rows behind.
142 userID = id
143 }
144 u := &homeUser{id: userID}
145 // Discoverable (resident) credentials are required: login is by credential,
146 // not by naming an account — there is nothing to name an account by. User
147 // verification is required too — a ceremony proof means "the user just did
148 // Face ID / PIN", not merely "the authenticator was present".
149 opts := []webauthn.RegistrationOption{
150 webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
151 ResidentKey: protocol.ResidentKeyRequirementRequired,
152 UserVerification: protocol.VerificationRequired,
153 }),
154 // Applied after WithAuthenticatorSelection: also sets the legacy
155 // requireResidentKey bool for older clients.
156 webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
157 }
158 if existing {
159 creds, err := s.store.credentialsFor(userID)
160 if err != nil {
161 http.Error(w, "db error", http.StatusInternalServerError)
162 return
163 }
164 u.creds = creds
165 var excl []protocol.CredentialDescriptor
166 for _, c := range creds {
167 excl = append(excl, c.Descriptor())
168 }
169 opts = append(opts, webauthn.WithExclusions(excl))
170 }
171 creation, session, err := s.wa.BeginRegistration(u, opts...)
172 if err != nil {
173 http.Error(w, "begin registration failed", http.StatusInternalServerError)
174 return
175 }
176 sid, err := s.sealSession(sessionEntry{Data: session, Kind: "register", UserID: userID, Existing: existing})
177 if err != nil {
178 http.Error(w, "server error", http.StatusInternalServerError)
179 return
180 }
181 writeJSON(w, http.StatusOK, map[string]any{"session": sid, "options": creation})
182}
183
184func (s *Server) registerFinish(w http.ResponseWriter, r *http.Request) {
185 ent, ok := s.openSession(r.URL.Query().Get("session"), "register")
186 if !ok {
187 http.Error(w, "unknown or expired session", http.StatusBadRequest)
188 return
189 }
190 u := &homeUser{id: ent.UserID}
191 if ent.Existing {
192 creds, err := s.store.credentialsFor(ent.UserID)
193 if err != nil {
194 http.Error(w, "db error", http.StatusInternalServerError)
195 return
196 }
197 u.creds = creds
198 }
199 cred, err := s.wa.FinishRegistration(u, *ent.Data, r)
200 if err != nil {
201 http.Error(w, "registration failed", http.StatusBadRequest)
202 return
203 }
204 if !ent.Existing {
205 if err := s.store.createUser(ent.UserID); err != nil {
206 http.Error(w, "db error", http.StatusInternalServerError)
207 return
208 }
209 }
210 if err := s.store.insertCredential(ent.UserID, cred); err != nil {
211 http.Error(w, "db error", http.StatusInternalServerError)
212 return
213 }
214 s.finishCeremony(w, ent.UserID, cred)
215}
216
217// ---- login --------------------------------------------------------------------
218
219func (s *Server) loginBegin(w http.ResponseWriter, r *http.Request) {
220 // Discoverable login: no allowCredentials, the authenticator offers whatever
221 // passkeys it holds for this RP. The client adds the PRF eval extension to
222 // these options itself — the server has no business in that exchange.
223 assertion, session, err := s.wa.BeginDiscoverableLogin(
224 webauthn.WithUserVerification(protocol.VerificationRequired))
225 if err != nil {
226 http.Error(w, "begin login failed", http.StatusInternalServerError)
227 return
228 }
229 sid, err := s.sealSession(sessionEntry{Data: session, Kind: "login"})
230 if err != nil {
231 http.Error(w, "server error", http.StatusInternalServerError)
232 return
233 }
234 writeJSON(w, http.StatusOK, map[string]any{"session": sid, "options": assertion})
235}
236
237func (s *Server) loginFinish(w http.ResponseWriter, r *http.Request) {
238 ent, ok := s.openSession(r.URL.Query().Get("session"), "login")
239 if !ok {
240 http.Error(w, "unknown or expired session", http.StatusBadRequest)
241 return
242 }
243 var userID string
244 cred, err := s.wa.FinishDiscoverableLogin(func(rawID, userHandle []byte) (webauthn.User, error) {
245 uid := string(userHandle)
246 creds, err := s.store.credentialsFor(uid)
247 if err != nil || len(creds) == 0 {
248 return nil, errors.New("unknown user")
249 }
250 userID = uid
251 return &homeUser{id: uid, creds: creds}, nil
252 }, *ent.Data, r)
253 if err != nil {
254 http.Error(w, "login failed", http.StatusUnauthorized)
255 return
256 }
257 // Persist the updated sign count / backup flags for clone detection.
258 if err := s.store.updateCredential(cred); err != nil {
259 http.Error(w, "db error", http.StatusInternalServerError)
260 return
261 }
262 s.finishCeremony(w, userID, cred)
263}
264
265// finishCeremony is the shared tail of both verified ceremonies: mint a device
266// link token (ongoing bearer auth) and a ceremony proof (the short-lived
267// "I just did Face ID" credential destructive ops demand), and tell the client
268// which credential id it used, so it can pick its wrapped DEK out of the blob.
269func (s *Server) finishCeremony(w http.ResponseWriter, userID string, cred *webauthn.Credential) {
270 tok, err := s.store.createLinkToken(userID)
271 if err != nil {
272 http.Error(w, "db error", http.StatusInternalServerError)
273 return
274 }
275 writeJSON(w, http.StatusOK, map[string]any{
276 "token": tok,
277 "proof": s.keys.mintCeremonyProof(userID),
278 "credential_id": base64.RawURLEncoding.EncodeToString(cred.ID),
279 })
280}
281