SEANK.H.LIAO

go webauthn passkeys

implementing login without passwords.

webauthn and passkeys in go

Previously I had my private services running in a Tailscale tailnet (vpn). I still love them, but... my Pixel 8 can't connect to multiple VPNs at once: I have Tailscale for private stuff, Google One for general purpose. So it's time to do zero trust and implement proper auth into applications.

webauthn

WebAuthn has been around for a while, primarily used as an unphishable second factor: you provide a username, and password, and your hardware key signs a challenge from the server. It's unphishable because: it's hardware you need physical control over, and because the client (browser) is coopted into the security flow so challenges are domain bound: so typo domain squatting doesn't work, you can't relay a challenge from a different site By just signing the challenge, the hardware keys don't need to store any data for each site its used with. While commonly used as a second factor, it could in theory be used for passwordless flows with just a usernme.

WebAuthn/Fido2 also had a different way of working: Resident Keys, also known as Discoverable Credentials, or the most recent rebrand: Passkeys. This time the hardware key stores a dedicated key per relying party (site) and userid pair, allowing a site to directly query for a user. This in theory enables just using a usernameless/passwordless flow, but most large sites tend to ask for the username anyway. Because the hardware key is storing dedicated key material, the number of usable keys are somewhat limited (25 for a yubikey 5).

implementing in go

github.com/go-webauthn/webauthn is pretty much the only implementation, and it's fine. On the Go side, it's primarily BeginDiscoverableLogin, securely store the session data (eg as a secure cookie), and verify with FinishDisoverableLogin. You'll want cookies to offload all the session datas to clients. My code can be found here: go.seankhliao.com/mono/cmd/authsvr.

  1func (a *App) startLogin() http.Handler {
  2        return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
  3                ctx, span := a.o.T.Start(r.Context(), "startLogin")
  4                defer span.End()
  5
  6                data, wanSess, err := a.wan.BeginDiscoverableLogin()
  7                if err != nil {
  8                        a.jsonErr(ctx, rw, "webauthn begin login", err, http.StatusInternalServerError, struct{}{})
  9                        return
 10                }
 11
 12                wanSessCook, err := a.storeSecret("webauthn_login_start", wanSess)
 13                if err != nil {
 14                        a.jsonErr(ctx, rw, "store session data", err, http.StatusInternalServerError, struct{}{})
 15                        return
 16                }
 17                http.SetCookie(rw, wanSessCook)
 18
 19                a.jsonOk(ctx, rw, data)
 20        })
 21}
 22
 23type LoginResponse struct {
 24        Status   string `json:"status"`
 25        Redirect string `json:"redirect,omitempty"`
 26}
 27
 28func (a *App) finishLogin() http.Handler {
 29        return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
 30                ctx, span := a.o.T.Start(r.Context(), "finishLogin")
 31                defer span.End()
 32
 33                wanSessCook, err := r.Cookie("webauthn_login_start")
 34                if err != nil {
 35                        a.jsonErr(ctx, rw, "get session cookie", err, http.StatusBadRequest, struct{}{})
 36                        return
 37                }
 38                var wanSess webauthn.SessionData
 39                err = a.readSecret("webauthn_login_start", wanSessCook, &wanSess)
 40                if err != nil {
 41                        a.jsonErr(ctx, rw, "read session cookie", err, http.StatusBadRequest, struct{}{})
 42                        return
 43                }
 44
 45                // check
 46                // rawID == credential id
 47                // userHandle == user.id in creation request (from user.WebAuthnID)
 48                handler := func(rawID, userHandle []byte) (webauthn.User, error) {
 49                        var user User
 50                        err := a.db.View(func(tx *bbolt.Tx) error {
 51                                bkt := tx.Bucket(bucketUser)
 52                                b := bkt.Get(userHandle)
 53                                err := json.Unmarshal(b, &user)
 54                                if err != nil {
 55                                        return fmt.Errorf("decode user data: %w", err)
 56                                }
 57                                return nil
 58                        })
 59                        return user, err
 60                }
 61                cred, err := a.wan.FinishDiscoverableLogin(handler, wanSess, r)
 62                if err != nil {
 63                        a.jsonErr(ctx, rw, "webauthn finish login", err, http.StatusBadRequest, struct{}{})
 64                        return
 65                }
 66
 67                if cred.Authenticator.CloneWarning {
 68                        a.jsonErr(ctx, rw, "cloned authenticator", err, http.StatusBadRequest, struct{}{})
 69                        return
 70                }
 71
 72                rawSessToken := make([]byte, 32)
 73                rand.Read(rawSessToken)
 74                sessToken := hex.EncodeToString(rawSessToken)
 75                http.SetCookie(rw, &http.Cookie{
 76                        Name:     AuthCookieName,
 77                        Value:    sessToken,
 78                        Path:     "/",
 79                        Domain:   a.cookieDomain,
 80                        MaxAge:   60 * 60 * 24 * 365,
 81                        HttpOnly: true,
 82                        Secure:   true,
 83                        SameSite: http.SameSiteLaxMode,
 84                })
 85
 86                err = a.db.Update(func(tx *bbolt.Tx) error {
 87                        bkt := tx.Bucket(bucketCred)
 88                        email := bkt.Get(cred.ID)
 89                        bkt = tx.Bucket(bucketUser)
 90                        b := bkt.Get(email)
 91                        var user User
 92                        err := json.Unmarshal(b, &user)
 93                        if err != nil {
 94                                return fmt.Errorf("decode user data: %w", err)
 95                        }
 96                        for i := range user.Creds {
 97                                if string(user.Creds[i].ID) == string(cred.ID) {
 98                                        user.Creds[i].Authenticator.SignCount = cred.Authenticator.SignCount
 99                                        break
100                                }
101                        }
102                        b, err = json.Marshal(user)
103                        if err != nil {
104                                return fmt.Errorf("encode user data: %w", err)
105                        }
106                        err = bkt.Put(email, b)
107                        if err != nil {
108                                return fmt.Errorf("update user data: %w", err)
109                        }
110
111                        info := SessionInfo{
112                                UserID:      user.ID,
113                                Email:       user.Email,
114                                StartTime:   time.Now(),
115                                UserAgent:   r.UserAgent(),
116                                LoginCredID: hex.EncodeToString(cred.ID),
117                        }
118                        b, err = json.Marshal(info)
119                        if err != nil {
120                                return fmt.Errorf("encode sesion info: %w", err)
121                        }
122
123                        bkt = tx.Bucket(bucketSession)
124                        err = bkt.Put([]byte(sessToken), b)
125                        if err != nil {
126                                return fmt.Errorf("store session token: %w", err)
127                        }
128
129                        return nil
130                })
131                if err != nil {
132                        a.jsonErr(ctx, rw, "create new session", err, http.StatusBadRequest, struct{}{})
133                        return
134                }
135
136                res := LoginResponse{
137                        Status: "ok",
138                }
139
140                u, err := url.Parse(r.FormValue("redirect"))
141                if err == nil {
142                        if strings.HasSuffix(u.Hostname(), "liao.dev") {
143                                res.Redirect = u.String()
144                        }
145                }
146
147                a.jsonOk(ctx, rw, res)
148        })
149}
150
151func (a *App) registerStart() http.Handler {
152        return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
153                ctx := r.Context()
154                ctx, span := a.o.T.Start(ctx, "registerStart")
155                defer span.End()
156
157                adminKey := r.FormValue("adminkey")
158                if adminKey != a.adminKey {
159                        a.jsonErr(ctx, rw, "mismatched admin key", errors.New("unauthed admin key"), http.StatusUnauthorized, struct{}{})
160                        return
161                }
162
163                email := r.PathValue("email")
164                if email == "" {
165                        a.jsonErr(ctx, rw, "empty email pathvalue", errors.New("no email"), http.StatusBadRequest, struct{}{})
166                        return
167                }
168
169                var user User
170                err := a.db.Update(func(tx *bbolt.Tx) error {
171                        bkt := tx.Bucket(bucketUser)
172                        b := bkt.Get([]byte(email))
173                        if len(b) == 0 {
174                                user.Email = email
175                                id, _ := rand.Int(rand.Reader, big.NewInt(math.MaxInt64))
176                                user.ID = id.Int64()
177                                b, err := json.Marshal(user)
178                                if err != nil {
179                                        return fmt.Errorf("marshal new user: %w", err)
180                                }
181                                return bkt.Put([]byte(email), b)
182                        }
183                        return json.Unmarshal(b, &user)
184                })
185                if err != nil {
186                        a.jsonErr(ctx, rw, "get user from email", err, http.StatusInternalServerError, struct{}{})
187                        return
188                }
189
190                var exlcusions []protocol.CredentialDescriptor
191                for _, cred := range user.Creds {
192                        exlcusions = append(exlcusions, cred.Descriptor())
193                }
194
195                data, wanSess, err := a.wan.BeginRegistration(user, webauthn.WithExclusions(exlcusions))
196                if err != nil {
197                        a.jsonErr(ctx, rw, "webauthn begin registration", err, http.StatusInternalServerError, struct{}{})
198                        return
199                }
200
201                wanSessCook, err := a.storeSecret("webauthn_register_start", wanSess)
202                if err != nil {
203                        a.jsonErr(ctx, rw, "store session cookie", err, http.StatusInternalServerError, struct{}{})
204                        return
205                }
206                http.SetCookie(rw, wanSessCook)
207
208                a.jsonOk(ctx, rw, data)
209        })
210}
211
212func (a *App) registerFinish() http.Handler {
213        return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
214                ctx := r.Context()
215                ctx, span := a.o.T.Start(ctx, "registerFinish")
216                defer span.End()
217
218                adminKey := r.FormValue("adminkey")
219                if adminKey != a.adminKey {
220                        a.jsonErr(ctx, rw, "mismatched admin key", errors.New("unauthed admin key"), http.StatusUnauthorized, struct{}{})
221                        return
222                }
223
224                email := r.PathValue("email")
225                if email == "" {
226                        a.jsonErr(ctx, rw, "empty email pathvalue", errors.New("no email"), http.StatusBadRequest, struct{}{})
227                        return
228                }
229
230                wanSessCook, err := r.Cookie("webauthn_register_start")
231                if err != nil {
232                        a.jsonErr(ctx, rw, "get session cookie", err, http.StatusBadRequest, struct{}{})
233                        return
234                }
235                var wanSess webauthn.SessionData
236                err = a.readSecret("webauthn_register_start", wanSessCook, &wanSess)
237                if err != nil {
238                        a.jsonErr(ctx, rw, "decode session cookie", err, http.StatusBadRequest, struct{}{})
239                        return
240                }
241
242                err = a.db.Update(func(tx *bbolt.Tx) error {
243                        bkt := tx.Bucket(bucketUser)
244                        b := bkt.Get([]byte(email))
245                        var user User
246                        err := json.Unmarshal(b, &user)
247                        if err != nil {
248                                return fmt.Errorf("decode user: %w", err)
249                        }
250
251                        cred, err := a.wan.FinishRegistration(user, wanSess, r)
252                        if err != nil {
253                                return fmt.Errorf("finish registration: %w", err)
254                        }
255                        user.Creds = append(user.Creds, *cred)
256
257                        b, err = json.Marshal(user)
258                        if err != nil {
259                                return fmt.Errorf("encode user: %w", err)
260                        }
261
262                        err = bkt.Put([]byte(email), b)
263                        if err != nil {
264                                return fmt.Errorf("update user")
265                        }
266
267                        bkt = tx.Bucket(bucketCred)
268                        err = bkt.Put(cred.ID, []byte(email))
269                        if err != nil {
270                                return fmt.Errorf("link cred to user")
271                        }
272                        return nil
273                })
274                if err != nil {
275                        a.jsonErr(ctx, rw, "store registration", err, http.StatusInternalServerError, err)
276                        return
277                }
278
279                a.jsonOk(ctx, rw, struct{}{})
280        })
281}

On the client side, javascript is a necessity to call the navigator.credentials Web Authentication API. Also some helper functions to turn Go base64 bytes to the right encoding:

  1// Base64url encode / decode, used by webauthn https://www.w3.org/TR/webauthn-2/
  2function bufferEncode(value) {
  3  return btoa(String.fromCharCode.apply(null, new Uint8Array(value)))
  4    .replace(/\+/g, "-")
  5    .replace(/\//g, "_")
  6    .replace(/=/g, "");
  7}
  8function bufferDecode(value) {
  9  return Uint8Array.from(
 10    atob(value.replace(/-/g, "+").replace(/_/g, "/")),
 11    (c) => c.charCodeAt(0),
 12  );
 13}
 14// login
 15async function loginUser() {
 16  const startResponse = await fetch("/login/start", {
 17    method: "POST",
 18  });
 19  if (!startResponse.ok) {
 20    alert("failed to start");
 21    return;
 22  }
 23  let opts = await startResponse.json();
 24  opts.publicKey.challenge = bufferDecode(opts.publicKey.challenge);
 25  if (opts.publicKey.allowCredentials) {
 26    opts.publicKey.allowCredentials.forEach(
 27      (it) => (it.id = bufferDecode(it.id)),
 28    );
 29  }
 30  const assertion = await navigator.credentials.get({
 31    publicKey: opts.publicKey,
 32  });
 33
 34  // technically possible to do this all client side?
 35  let windowParams = new URLSearchParams(document.location.search);
 36  let params = new URLSearchParams({ redirect: windowParams.get("redirect") });
 37  const finishResponse = await fetch(`/login/finish?${params}`, {
 38    method: "POST",
 39    body: JSON.stringify({
 40      id: assertion.id,
 41      rawId: bufferEncode(assertion.rawId),
 42      type: assertion.type,
 43      response: {
 44        authenticatorData: bufferEncode(assertion.response.authenticatorData),
 45        clientDataJSON: bufferEncode(assertion.response.clientDataJSON),
 46        signature: bufferEncode(assertion.response.signature),
 47        userHandle: bufferEncode(assertion.response.userHandle),
 48      },
 49    }),
 50  });
 51  if (!finishResponse.ok) {
 52    alert("failed to login");
 53    return;
 54  }
 55  const loginStatus = await finishResponse.json();
 56  if (loginStatus.redirect) {
 57    window.location.href = loginStatus.redirect;
 58    return;
 59  }
 60  window.location.reload();
 61}
 62// register
 63async function registerUser() {
 64  let email = encodeURIComponent(document.querySelector("#email").value);
 65  let adminKey = document.querySelector("#adminkey").value;
 66  let params = new URLSearchParams({ adminkey: adminKey });
 67
 68  const startResponse = await fetch(`/register/${email}/start?${params}`, {
 69    method: "POST",
 70  });
 71  if (!startResponse.ok) {
 72    alert("failed to start");
 73  }
 74  let opts = await startResponse.json();
 75  opts.publicKey.challenge = bufferDecode(opts.publicKey.challenge);
 76  opts.publicKey.user.id = bufferDecode(opts.publicKey.user.id);
 77  if (opts.publicKey.excludeCredentials) {
 78    opts.publicKey.excludeCredentials.forEach(
 79      (it) => (it.id = bufferDecode(it.id)),
 80    );
 81  }
 82  const cred = await navigator.credentials.create({
 83    publicKey: opts.publicKey,
 84  });
 85  const finishResponse = await fetch(`/register/${email}/finish?${params}`, {
 86    method: "POST",
 87    body: JSON.stringify({
 88      id: cred.id,
 89      rawId: bufferEncode(cred.rawId),
 90      type: cred.type,
 91      response: {
 92        attestationObject: bufferEncode(cred.response.attestationObject),
 93        clientDataJSON: bufferEncode(cred.response.clientDataJSON),
 94      },
 95    }),
 96  });
 97  if (!finishResponse.ok) {
 98    alert("failed to register");
 99    return;
100  }
101  alert("registered, plz login");
102}