A password and a TOTP code are both shared secrets: the server keeps something that can be stolen from it, and the person keeps something that can be talked out of them. A passkey is neither. The private half never leaves the device, and the signature is bound to the origin that asked for it — so a convincing copy of the sign-in page at another address gets a signature it cannot use. Logging in asks for no email. The device knows which account its key belongs to, which is not only convenience: it means the sign-in page cannot be used to find out who has an account here. A key that verified its owner is both factors at once and completes the login; one that only proved possession still meets the TOTP step, exactly as a password does. The relying party id is derived from VERDANDE_BASE_URL rather than configured separately — a second setting that has to agree with the first is one that will one day disagree, and the failure is a login nobody can complete. An IP address is refused up front: no browser accepts one, and without the check the server issues a challenge happily and the browser throws SecurityError long afterwards. The list endpoint reports whether this deployment can offer them at all, so the interface never shows a button that cannot work. A challenge is stored server-side, answerable exactly once, and taken rather than read — two requests racing the same one would both succeed, which is the shape of a replay. A sign count that has gone backwards means the credential was cloned and is refused rather than logged. The Go tests cover who may reach these endpoints and that challenges are spent. The ceremony itself needs an authenticator, so the smoke test drives Chromium's virtual one over CDP — the only thing that can tell whether the base64url conversions are right, since one wrong byte produces a signature that will not verify and reads as a broken key rather than a broken encoder. That test runs at localhost in its own context while the suite stays on the loopback address, because WebAuthn refuses an IP and resolving localhost is slow enough here to cost the suite minutes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
405 lines
14 KiB
Go
405 lines
14 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/go-webauthn/webauthn/protocol"
|
|
"github.com/go-webauthn/webauthn/webauthn"
|
|
|
|
"github.com/kristianwind/verdande/internal/auth"
|
|
"github.com/kristianwind/verdande/internal/store"
|
|
)
|
|
|
|
// Passkeys: a key on a device instead of a secret in a head.
|
|
//
|
|
// Four endpoints, in two pairs. Registration is done by somebody already signed in
|
|
// — you cannot be handed a way into an account you have not proved you own.
|
|
// Logging in is done by somebody who is not, and is the one place here that is
|
|
// reachable without a session.
|
|
//
|
|
// Each ceremony is two requests because the browser has to talk to the
|
|
// authenticator in between: the server issues a challenge, the device signs it,
|
|
// the server checks the signature against what it remembers issuing. The challenge
|
|
// lives in the database rather than in a cookie, because the whole point of it is
|
|
// that the server chose it and remembers choosing it.
|
|
|
|
const challengeTTL = 5 * time.Minute
|
|
|
|
// passkeyAvailable answers 503 rather than 404 when the relying party could not be
|
|
// built. 404 would read as "this version does not have passkeys"; the truth is
|
|
// that this deployment cannot offer them, which is a different thing to fix.
|
|
func (s *Server) passkeyAvailable(w http.ResponseWriter, r *http.Request) bool {
|
|
if s.passkeys != nil {
|
|
return true
|
|
}
|
|
writeError(w, http.StatusServiceUnavailable, CodeInternal,
|
|
"passkeys are unavailable on this instance: VERDANDE_BASE_URL is not an address an authenticator will accept")
|
|
return false
|
|
}
|
|
|
|
type passkeyJSON struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
// AAGUID identifies the authenticator model, which is what lets the list say
|
|
// something recognisable beside a key somebody named three months ago.
|
|
AAGUID string `json:"aaguid,omitempty"`
|
|
Discoverable bool `json:"discoverable"`
|
|
UserVerified bool `json:"user_verified"`
|
|
CreatedAt string `json:"created_at"`
|
|
LastUsedAt string `json:"last_used_at,omitempty"`
|
|
}
|
|
|
|
func (s *Server) handleListPasskeys(w http.ResponseWriter, r *http.Request) {
|
|
keys, err := s.db.ListPasskeys(r.Context(), userFrom(r.Context()).ID)
|
|
if err != nil {
|
|
s.internal(w, r, "list passkeys", err)
|
|
return
|
|
}
|
|
out := make([]passkeyJSON, 0, len(keys))
|
|
for _, k := range keys {
|
|
j := passkeyJSON{
|
|
ID: k.ID, Name: k.Name, AAGUID: k.AAGUID,
|
|
Discoverable: k.Discoverable, UserVerified: k.UserVerified,
|
|
CreatedAt: k.CreatedAt.Format(time.RFC3339),
|
|
}
|
|
if !k.LastUsedAt.IsZero() {
|
|
j.LastUsedAt = k.LastUsedAt.Format(time.RFC3339)
|
|
}
|
|
out = append(out, j)
|
|
}
|
|
// Whether this deployment can offer them at all, alongside the ones that exist.
|
|
// The interface asks one question rather than discovering the answer from a 503
|
|
// it triggered by offering a button — an address an authenticator refuses is a
|
|
// deployment fact, not an error somebody caused.
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"passkeys": out, "available": s.passkeys != nil,
|
|
})
|
|
}
|
|
|
|
// --- registering a key ------------------------------------------------------------
|
|
|
|
func (s *Server) handleBeginPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
|
|
if !s.passkeyAvailable(w, r) {
|
|
return
|
|
}
|
|
user := userFrom(r.Context())
|
|
|
|
account, err := s.passkeyUser(r, user.ID, user.Email, user.Name)
|
|
if err != nil {
|
|
s.internal(w, r, "load passkeys", err)
|
|
return
|
|
}
|
|
|
|
// Resident keys, and the authenticator asked to verify its owner. Both are
|
|
// what make this a *passkey* rather than a second factor: only a discoverable
|
|
// credential can start a login with no email typed, and only a verified one
|
|
// stands in for the password rather than beside it.
|
|
creation, session, err := s.passkeys.BeginRegistration(account,
|
|
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementPreferred),
|
|
webauthn.WithAuthenticatorSelection(protocol.AuthenticatorSelection{
|
|
ResidentKey: protocol.ResidentKeyRequirementPreferred,
|
|
UserVerification: protocol.VerificationPreferred,
|
|
}),
|
|
// Excluding what they already have, so a second registration on the same
|
|
// device replaces nothing and reports itself instead of silently making a
|
|
// duplicate.
|
|
webauthn.WithExclusions(account.excludeList()),
|
|
)
|
|
if err != nil {
|
|
s.internal(w, r, "begin passkey registration", err)
|
|
return
|
|
}
|
|
s.issueChallenge(w, r, user.ID, "register", session, creation)
|
|
}
|
|
|
|
type finishRegistrationRequest struct {
|
|
ChallengeID string `json:"challenge_id"`
|
|
Name string `json:"name"`
|
|
Credential json.RawMessage `json:"credential"`
|
|
}
|
|
|
|
func (s *Server) handleFinishPasskeyRegistration(w http.ResponseWriter, r *http.Request) {
|
|
if !s.passkeyAvailable(w, r) {
|
|
return
|
|
}
|
|
var req finishRegistrationRequest
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
return
|
|
}
|
|
user := userFrom(r.Context())
|
|
|
|
session, ok := s.takeChallenge(w, r, req.ChallengeID, "register", user.ID)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
parsed, err := protocol.ParseCredentialCreationResponseBody(strings.NewReader(string(req.Credential)))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, CodeBadRequest, "that response could not be read")
|
|
return
|
|
}
|
|
|
|
account, err := s.passkeyUser(r, user.ID, user.Email, user.Name)
|
|
if err != nil {
|
|
s.internal(w, r, "load passkeys", err)
|
|
return
|
|
}
|
|
|
|
credential, err := s.passkeys.CreateCredential(account, *session, parsed)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, CodeBadRequest, "that key could not be verified")
|
|
return
|
|
}
|
|
|
|
name := strings.TrimSpace(req.Name)
|
|
if name == "" {
|
|
name = "Passkey"
|
|
}
|
|
key := &store.Passkey{
|
|
UserID: user.ID,
|
|
CredentialID: auth.EncodeCredentialID(credential.ID),
|
|
PublicKey: credential.PublicKey,
|
|
AAGUID: auth.EncodeCredentialID(credential.Authenticator.AAGUID),
|
|
SignCount: credential.Authenticator.SignCount,
|
|
Discoverable: credential.Flags.BackupEligible || parsed.Response.AttestationObject.AuthData.Flags.HasBackupState(),
|
|
UserVerified: credential.Flags.UserVerified,
|
|
Name: name,
|
|
}
|
|
if err := s.db.CreatePasskey(r.Context(), key); err != nil {
|
|
s.internal(w, r, "store passkey", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusCreated, passkeyJSON{
|
|
ID: key.ID, Name: key.Name, AAGUID: key.AAGUID,
|
|
Discoverable: key.Discoverable, UserVerified: key.UserVerified,
|
|
CreatedAt: key.CreatedAt.Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
// --- signing in with a key ---------------------------------------------------------
|
|
|
|
func (s *Server) handleBeginPasskeyLogin(w http.ResponseWriter, r *http.Request) {
|
|
if !s.passkeyAvailable(w, r) {
|
|
return
|
|
}
|
|
|
|
// Discoverable: nobody has said who they are yet, and that is the point. The
|
|
// device knows which account its key belongs to, so there is no email to type
|
|
// and no list of accounts for anybody to probe.
|
|
assertion, session, err := s.passkeys.BeginDiscoverableLogin()
|
|
if err != nil {
|
|
s.internal(w, r, "begin passkey login", err)
|
|
return
|
|
}
|
|
s.issueChallenge(w, r, "", "login", session, assertion)
|
|
}
|
|
|
|
type finishLoginRequest struct {
|
|
ChallengeID string `json:"challenge_id"`
|
|
Credential json.RawMessage `json:"credential"`
|
|
}
|
|
|
|
func (s *Server) handleFinishPasskeyLogin(w http.ResponseWriter, r *http.Request) {
|
|
if !s.passkeyAvailable(w, r) {
|
|
return
|
|
}
|
|
var req finishLoginRequest
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
return
|
|
}
|
|
|
|
session, ok := s.takeChallenge(w, r, req.ChallengeID, "login", "")
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
parsed, err := protocol.ParseCredentialRequestResponseBody(strings.NewReader(string(req.Credential)))
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, CodeBadRequest, "that response could not be read")
|
|
return
|
|
}
|
|
|
|
// Which account, resolved from the credential the device signed with. The
|
|
// handler is given the raw id and returns the owner; the library checks the
|
|
// signature against that owner's stored public key.
|
|
var matched *store.Passkey
|
|
credential, err := s.passkeys.ValidateDiscoverableLogin(
|
|
func(rawID, userHandle []byte) (webauthn.User, error) {
|
|
key, err := s.db.PasskeyByCredentialID(r.Context(), auth.EncodeCredentialID(rawID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
matched = key
|
|
owner, err := s.db.UserByID(r.Context(), key.UserID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.passkeyUser(r, owner.ID, owner.Email, owner.Name)
|
|
}, *session, parsed)
|
|
if err != nil || matched == nil {
|
|
// One message, as with a wrong password: telling "no such key" apart from
|
|
// "bad signature" is a way to learn which credentials exist here.
|
|
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key was not accepted")
|
|
return
|
|
}
|
|
|
|
// A counter that goes backwards means the credential has been cloned. Not
|
|
// every authenticator keeps one, so zero on both sides is silence rather than
|
|
// suspicion — but a real counter that has gone down is the one signal this
|
|
// design gives, and it is refused rather than logged.
|
|
if credential.Authenticator.CloneWarning {
|
|
s.log.Warn("passkey clone warning", "passkey", matched.ID, "user", matched.UserID)
|
|
writeError(w, http.StatusUnauthorized, CodeUnauthorized, "that key was not accepted")
|
|
return
|
|
}
|
|
|
|
owner, err := s.db.UserByID(r.Context(), matched.UserID)
|
|
if err != nil {
|
|
s.internal(w, r, "load account", err)
|
|
return
|
|
}
|
|
|
|
if err := s.db.TouchPasskey(r.Context(), matched.ID, credential.Authenticator.SignCount); err != nil {
|
|
s.log.Warn("record passkey use", "err", err)
|
|
}
|
|
s.loginLimiter.reset(clientIP(r))
|
|
|
|
// A verified key is both factors at once: the device proved possession and the
|
|
// person proved they were there, which is exactly what a password plus a code
|
|
// is for. One that only proved possession still stops at the code.
|
|
pending := owner.TOTPEnabled && !credential.Flags.UserVerified
|
|
ttl := s.cfg.SessionTTL
|
|
if pending {
|
|
ttl = 10 * time.Minute
|
|
}
|
|
token, _, err := s.db.CreateSession(r.Context(), owner.ID, r.UserAgent(), clientIP(r), ttl, pending)
|
|
if err != nil {
|
|
s.internal(w, r, "create session", err)
|
|
return
|
|
}
|
|
s.setSessionCookie(w, token, ttl)
|
|
|
|
if pending {
|
|
writeJSON(w, http.StatusOK, loginResponse{TOTPRequired: true})
|
|
return
|
|
}
|
|
uj := toUserJSON(owner)
|
|
writeJSON(w, http.StatusOK, loginResponse{User: &uj})
|
|
}
|
|
|
|
// --- managing them ------------------------------------------------------------------
|
|
|
|
func (s *Server) handleRenamePasskey(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
}
|
|
if err := decodeJSON(w, r, &req); err != nil {
|
|
return
|
|
}
|
|
name := strings.TrimSpace(req.Name)
|
|
if name == "" {
|
|
writeFieldErrors(w, map[string]string{"name": "required"})
|
|
return
|
|
}
|
|
if err := s.db.RenamePasskey(r.Context(), chi.URLParam(r, "passkeyID"),
|
|
userFrom(r.Context()).ID, name); err != nil {
|
|
s.storeError(w, r, "rename passkey", err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
func (s *Server) handleDeletePasskey(w http.ResponseWriter, r *http.Request) {
|
|
if err := s.db.DeletePasskey(r.Context(), chi.URLParam(r, "passkeyID"),
|
|
userFrom(r.Context()).ID); err != nil {
|
|
s.storeError(w, r, "delete passkey", err)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// --- shared plumbing -----------------------------------------------------------------
|
|
|
|
// passkeyAccount is the library's view of an account, plus the rows behind it.
|
|
type passkeyAccount struct {
|
|
*auth.PasskeyUser
|
|
stored []store.Passkey
|
|
}
|
|
|
|
func (a *passkeyAccount) excludeList() []protocol.CredentialDescriptor {
|
|
out := make([]protocol.CredentialDescriptor, 0, len(a.stored))
|
|
for _, c := range a.PasskeyUser.Credentials {
|
|
out = append(out, c.Descriptor())
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Server) passkeyUser(r *http.Request, userID, email, name string) (*passkeyAccount, error) {
|
|
keys, err := s.db.ListPasskeys(r.Context(), userID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
credentials := make([]webauthn.Credential, 0, len(keys))
|
|
for _, k := range keys {
|
|
raw, err := auth.DecodeCredentialID(k.CredentialID)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
credentials = append(credentials, webauthn.Credential{
|
|
ID: raw,
|
|
PublicKey: k.PublicKey,
|
|
Authenticator: webauthn.Authenticator{
|
|
SignCount: k.SignCount,
|
|
},
|
|
})
|
|
}
|
|
return &passkeyAccount{
|
|
PasskeyUser: &auth.PasskeyUser{
|
|
ID: userID, Email: email, Name: name, Credentials: credentials,
|
|
},
|
|
stored: keys,
|
|
}, nil
|
|
}
|
|
|
|
// issueChallenge stores the server's side of a ceremony and hands the browser the
|
|
// options plus the id it will answer with.
|
|
func (s *Server) issueChallenge(w http.ResponseWriter, r *http.Request, userID, purpose string, session *webauthn.SessionData, options any) {
|
|
encoded, err := json.Marshal(session)
|
|
if err != nil {
|
|
s.internal(w, r, "encode webauthn session", err)
|
|
return
|
|
}
|
|
id, err := s.db.StoreChallenge(r.Context(), userID, string(session.Challenge), purpose, encoded, challengeTTL)
|
|
if err != nil {
|
|
s.internal(w, r, "store challenge", err)
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"challenge_id": id, "options": options})
|
|
}
|
|
|
|
// takeChallenge reads a challenge once and checks it belongs where it is being
|
|
// used. A registration challenge answered by a login — or one account's answered
|
|
// by another — is refused rather than merely failing later.
|
|
func (s *Server) takeChallenge(w http.ResponseWriter, r *http.Request, id, purpose, userID string) (*webauthn.SessionData, bool) {
|
|
owner, raw, err := s.db.TakeChallenge(r.Context(), id, purpose)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, CodeBadRequest,
|
|
"that attempt has expired or was already used; start again")
|
|
return nil, false
|
|
}
|
|
if userID != "" && owner != userID {
|
|
writeError(w, http.StatusBadRequest, CodeBadRequest, "that attempt belongs to another account")
|
|
return nil, false
|
|
}
|
|
var session webauthn.SessionData
|
|
if err := json.Unmarshal(raw, &session); err != nil {
|
|
s.internal(w, r, "decode webauthn session", err)
|
|
return nil, false
|
|
}
|
|
return &session, true
|
|
}
|