feat(auth): passkeys, as a way in and as a second factor

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>
This commit is contained in:
Kristian Wind
2026-08-18 11:56:54 +02:00
co-authored by Claude Opus 5
parent 982c3aab18
commit fe52818ab6
21 changed files with 1769 additions and 29 deletions
+3 -1
View File
@@ -46,7 +46,9 @@ func newTestServerWith(t *testing.T, configure func(*config.Config)) *testServer
t.Cleanup(func() { db.Close() })
cfg := &config.Config{
BaseURL: "http://127.0.0.1",
// `localhost`, not a loopback IP: passkeys refuse an IP address as a relying
// party id, and every browser makes the same exception for localhost.
BaseURL: "http://localhost",
DataDir: t.TempDir(),
SessionTTL: 24 * time.Hour,
InviteTTL: 7 * 24 * time.Hour,
+1 -1
View File
@@ -1172,7 +1172,7 @@ func TestTheGmailClientCanBeSetFromTheInterface(t *testing.T) {
}
// The single most likely thing to be wrong, so it is spelled out rather than
// left for somebody to reconstruct.
if empty["redirect_uri"] != "http://127.0.0.1/oauth/gmail/callback" {
if empty["redirect_uri"] != "http://localhost/oauth/gmail/callback" {
t.Errorf("redirect_uri = %v", empty["redirect_uri"])
}
+404
View File
@@ -0,0 +1,404 @@
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
}
+201
View File
@@ -0,0 +1,201 @@
package httpapi
import (
"encoding/json"
"net/http"
"testing"
"github.com/kristianwind/verdande/internal/store"
)
// What can be tested without an authenticator, which is most of the surface that
// actually goes wrong.
//
// The signature itself cannot be produced here — that needs a real device or a
// virtual one, and Playwright's CDP virtual authenticator is where that belongs.
// What is testable is everything around it: who may reach these endpoints, that a
// challenge is answerable exactly once, that one account's challenge cannot be
// answered by another, and that an expired one is refused. Those are the parts a
// mistake would make silently permissive.
func TestPasskeyRegistrationIsSessionsOnly(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
token := ts.apiToken(t, admin.ID)
// A leaked API token must not be able to add its own way in: that would turn a
// theft into a tenancy.
for _, c := range []struct{ method, path string }{
{"GET", "/api/v1/auth/passkeys"},
{"POST", "/api/v1/auth/passkeys/register/begin"},
{"POST", "/api/v1/auth/passkeys/register/finish"},
} {
req, _ := http.NewRequest(c.method, ts.URL+c.path, nil)
req.Header.Set("Authorization", "Bearer "+token)
resp, err := (&http.Client{}).Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
if resp.StatusCode != http.StatusForbidden {
t.Errorf("%s %s with an API token: status %d, want 403", c.method, c.path, resp.StatusCode)
}
}
}
// Beginning a registration hands back options and a challenge id, and the options
// carry the things that make this a passkey rather than a second factor.
func TestBeginningARegistrationAsksForADiscoverableVerifiedKey(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
resp, body := ts.do(t, "POST", "/api/v1/auth/passkeys/register/begin", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("begin: status %d, body %v", resp.StatusCode, body)
}
if body["challenge_id"] == "" || body["challenge_id"] == nil {
t.Fatal("no challenge id came back")
}
options, _ := body["options"].(map[string]any)
publicKey, _ := options["publicKey"].(map[string]any)
if publicKey == nil {
t.Fatalf("no publicKey in the options: %v", options)
}
selection, _ := publicKey["authenticatorSelection"].(map[string]any)
if selection["residentKey"] != "preferred" {
t.Errorf("residentKey = %v — only a discoverable credential can start a login "+
"with no email typed", selection["residentKey"])
}
if selection["userVerification"] != "preferred" {
t.Errorf("userVerification = %v — a key that only proves possession cannot "+
"stand in for the password", selection["userVerification"])
}
// The user handle must be the account id rather than the email: it is written
// into the credential and cannot be changed, so an email there would orphan
// every key the moment somebody changed their address.
user, _ := publicKey["user"].(map[string]any)
if user["name"] != "kristian@example.dk" {
t.Errorf("user.name = %v", user["name"])
}
if id, _ := user["id"].(string); id == "" {
t.Error("no user handle in the options")
}
}
// A challenge is answerable exactly once, and only by whom it was issued to.
func TestAChallengeIsSpentWhenItIsUsed(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
_, begun := ts.do(t, "POST", "/api/v1/auth/passkeys/register/begin", nil)
challenge := begun["challenge_id"].(string)
// Somebody else's challenge, answered by this account. Refused before the
// signature is even looked at.
resp, _ := other.do(t, "POST", "/api/v1/auth/passkeys/register/finish", map[string]any{
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
})
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("another account's challenge: status %d, want 400", resp.StatusCode)
}
// And it is spent: the rightful owner cannot use it either now, because taking
// it is what deleted it. That is deliberate — a challenge that survives a
// failed attempt is a challenge somebody can keep trying.
resp, _ = ts.do(t, "POST", "/api/v1/auth/passkeys/register/finish", map[string]any{
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
})
if resp.StatusCode != http.StatusBadRequest {
t.Errorf("a spent challenge: status %d, want 400", resp.StatusCode)
}
}
// Logging in with a key needs no session and says nothing about who exists here.
func TestPasskeyLoginRevealsNothingAboutWhoHasAKey(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
// No session at all — a fresh client, which is the whole point.
fresh := &testServer{Server: ts.Server, db: ts.db, client: newJarClient(t)}
resp, body := fresh.do(t, "POST", "/api/v1/auth/passkey/login/begin", nil)
if resp.StatusCode != http.StatusOK {
t.Fatalf("begin login: status %d, body %v", resp.StatusCode, body)
}
challenge := body["challenge_id"].(string)
options, _ := body["options"].(map[string]any)
publicKey, _ := options["publicKey"].(map[string]any)
// A discoverable login must not list credentials: that list would say which
// keys exist on this instance to anybody who asked.
if allowed, present := publicKey["allowCredentials"]; present && allowed != nil {
if list, _ := allowed.([]any); len(list) > 0 {
t.Errorf("the login options list %d credentials; a discoverable login must not",
len(list))
}
}
// A garbage answer is refused with the same message a wrong key would get.
resp, _ = fresh.do(t, "POST", "/api/v1/auth/passkey/login/finish", map[string]any{
"challenge_id": challenge, "credential": json.RawMessage(`{}`),
})
if resp.StatusCode != http.StatusBadRequest && resp.StatusCode != http.StatusUnauthorized {
t.Errorf("a junk assertion: status %d, want 400 or 401", resp.StatusCode)
}
}
// A key belongs to its owner, and nobody else can rename or remove it.
func TestAPasskeyCanOnlyBeManagedByItsOwner(t *testing.T) {
ts := newTestServer(t)
ts.bootstrap(t)
other := ts.newUser(t, "anden@example.dk", "Anden")
admin, err := ts.db.UserByEmail(t.Context(), "kristian@example.dk")
if err != nil {
t.Fatal(err)
}
// Written straight to the store: registering one properly needs a device.
key := &store.Passkey{
UserID: admin.ID, CredentialID: "abc", PublicKey: []byte{1, 2, 3}, Name: "Min bærbare",
}
if err := ts.db.CreatePasskey(t.Context(), key); err != nil {
t.Fatal(err)
}
_, listed := ts.do(t, "GET", "/api/v1/auth/passkeys", nil)
if keys, _ := listed["passkeys"].([]any); len(keys) != 1 {
t.Fatalf("the owner sees %d keys, want 1", len(keys))
}
_, theirs := other.do(t, "GET", "/api/v1/auth/passkeys", nil)
if keys, _ := theirs["passkeys"].([]any); len(keys) != 0 {
t.Errorf("somebody else sees %d of this account's keys", len(keys))
}
for _, c := range []struct {
what, method string
body any
}{
{"rename", "PATCH", map[string]any{"name": "min nu"}},
{"delete", "DELETE", nil},
} {
resp, _ := other.do(t, c.method, "/api/v1/auth/passkeys/"+key.ID, c.body)
if resp.StatusCode != http.StatusNotFound {
t.Errorf("%s somebody else's key: status %d, want 404", c.what, resp.StatusCode)
}
}
if resp, _ := ts.do(t, "PATCH", "/api/v1/auth/passkeys/"+key.ID,
map[string]any{"name": "Telefonen"}); resp.StatusCode != http.StatusNoContent {
t.Errorf("the owner renaming: status %d", resp.StatusCode)
}
if resp, _ := ts.do(t, "DELETE", "/api/v1/auth/passkeys/"+key.ID, nil); resp.StatusCode != http.StatusNoContent {
t.Errorf("the owner deleting: status %d", resp.StatusCode)
}
}
+29
View File
@@ -16,6 +16,7 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/kristianwind/verdande/internal/auth"
"github.com/kristianwind/verdande/internal/config"
"github.com/kristianwind/verdande/internal/mail"
"github.com/kristianwind/verdande/internal/mcp"
@@ -39,6 +40,12 @@ type Server struct {
mcp *mcp.Server
updates *update.Checker
// Nil when the base URL is not something an authenticator will accept — a bare
// hostname with no scheme, say. The endpoints answer 503 rather than the
// process refusing to start: passkeys are one way in among three, and an
// instance should not be unreachable because one of them cannot be offered.
passkeys *auth.WebAuthn
// Password guessing is the attack this application is actually exposed to, so
// the endpoints that check a secret are limited separately and more tightly
// than the rest of the API.
@@ -63,6 +70,11 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
resetLimiter: newLimiter(5, time.Hour),
}
s.csp = contentSecurityPolicy(scriptHashes(web))
if w, err := auth.NewWebAuthn(cfg.BaseURL, "verdande"); err == nil {
s.passkeys = w
} else {
log.Warn("passkeys are unavailable", "err", err, "base_url", cfg.BaseURL)
}
s.mcp = s.buildMCP()
s.updates = update.New(Version, cfg.UpdateCheck)
@@ -132,6 +144,14 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Method(http.MethodPost, "/password/reset",
s.rateLimit(s.resetLimiter, http.HandlerFunc(s.handleResetPassword)))
// Signing in with a passkey. Rate-limited like the password path: a
// signature cannot be guessed, but the endpoint can still be used to
// hammer the database.
r.Method(http.MethodPost, "/passkey/login/begin",
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleBeginPasskeyLogin)))
r.Method(http.MethodPost, "/passkey/login/finish",
s.rateLimit(s.loginLimiter, http.HandlerFunc(s.handleFinishPasskeyLogin)))
// The second step of a login: a session that has passed the password
// but not yet the code, and can reach nothing else.
r.Group(func(r chi.Router) {
@@ -160,6 +180,15 @@ func New(cfg *config.Config, db *store.DB, log *slog.Logger, web fs.FS) *Server
r.Use(s.requireSession)
r.Get("/sessions", s.handleListSessions)
r.Delete("/sessions/{sessionID}", s.handleDeleteSession)
// Registering a key, and the list of them. Sessions only, for
// the same reason: a leaked token must not be able to add its
// own way in — that would turn a theft into a tenancy.
r.Get("/passkeys", s.handleListPasskeys)
r.Post("/passkeys/register/begin", s.handleBeginPasskeyRegistration)
r.Post("/passkeys/register/finish", s.handleFinishPasskeyRegistration)
r.Patch("/passkeys/{passkeyID}", s.handleRenamePasskey)
r.Delete("/passkeys/{passkeyID}", s.handleDeletePasskey)
})
})
})