From fe52818ab673ad13b7354b3dec621c42d2f3083e Mon Sep 17 00:00:00 2001 From: Kristian Wind Date: Tue, 18 Aug 2026 11:56:54 +0200 Subject: [PATCH] feat(auth): passkeys, as a way in and as a second factor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/openapi.yaml | 215 +++++++++++ go.mod | 13 +- go.sum | 30 +- internal/auth/auth_test.go | 30 ++ internal/auth/webauthn.go | 98 +++++ internal/httpapi/auth_test.go | 4 +- internal/httpapi/integrations_test.go | 2 +- internal/httpapi/passkey_handlers.go | 404 ++++++++++++++++++++ internal/httpapi/passkey_test.go | 201 ++++++++++ internal/httpapi/server.go | 29 ++ internal/store/migrations/0013_passkeys.sql | 73 ++++ internal/store/passkeys.go | 206 ++++++++++ web/e2e/smoke.spec.js | 128 ++++++- web/playwright.config.js | 13 +- web/src/lib/api.js | 9 + web/src/lib/components/SignIn.svelte | 61 +++ web/src/lib/locales/da.js | 24 +- web/src/lib/locales/en.js | 24 +- web/src/lib/passkey.js | 115 ++++++ web/src/routes/indstillinger/+page.svelte | 106 ++++- web/src/routes/projekt/[id]/+page.svelte | 13 +- 21 files changed, 1769 insertions(+), 29 deletions(-) create mode 100644 internal/auth/webauthn.go create mode 100644 internal/httpapi/passkey_handlers.go create mode 100644 internal/httpapi/passkey_test.go create mode 100644 internal/store/migrations/0013_passkeys.sql create mode 100644 internal/store/passkeys.go create mode 100644 web/src/lib/passkey.js diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 621a006..f313ebf 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -388,6 +388,195 @@ paths: "204": { description: Off. } "422": { $ref: "#/components/responses/Validation" } + /api/v1/auth/passkeys: + get: + tags: [auth] + summary: The keys registered on this account. + description: | + A passkey is a key on a device rather than a secret in a head. 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. + + One row per credential, because a person has a laptop and a phone and losing + one must not mean losing the way in. The name is theirs to write: a list + nobody can read is a list nobody revokes from. + + Sessions only, like `/tokens` — a leaked API token must not be able to add + its own way in, which would turn a theft into a tenancy. + responses: + "200": + description: OK + content: + application/json: + schema: + type: object + properties: + passkeys: + type: array + items: { $ref: "#/components/schemas/Passkey" } + available: + type: boolean + description: | + Whether this deployment can offer passkeys at all. False when + `VERDANDE_BASE_URL` is an IP address or otherwise not a + domain an authenticator will accept — the interface asks once + rather than discovering it from a 503 it caused by offering a + button. + "403": { $ref: "#/components/responses/Forbidden" } + + /api/v1/auth/passkeys/register/begin: + post: + tags: [auth] + summary: Start registering a key. Returns the options and a challenge id. + description: | + Registration is done by somebody already signed in: you cannot be handed a + way into an account you have not proved you own. + + The challenge is kept server-side rather than in a cookie, because the whole + point of it is that the server chose it and remembers choosing it. It is + answerable once and expires after five minutes. + responses: + "200": + description: Pass `options` to `navigator.credentials.create()`. + content: + application/json: + schema: + type: object + properties: + challenge_id: { type: string } + options: { type: object, additionalProperties: true } + "503": + description: | + `VERDANDE_BASE_URL` is not an address an authenticator will accept. 503 + rather than 404: this version has passkeys, this deployment cannot offer + them, and those are different things to fix. + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + + /api/v1/auth/passkeys/register/finish: + post: + tags: [auth] + summary: Finish registering a key. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + challenge_id: { type: string } + name: + type: string + description: What to call it in the list — "min bærbare". + credential: + type: object + additionalProperties: true + description: The response from `navigator.credentials.create()`. + responses: + "201": + description: Registered. + content: + application/json: + schema: { $ref: "#/components/schemas/Passkey" } + "400": + description: The attempt expired, was already used, or did not verify. + "503": + description: Passkeys are unavailable on this instance. + + /api/v1/auth/passkeys/{passkeyID}: + parameters: + - { name: passkeyID, in: path, required: true, schema: { type: string } } + patch: + tags: [auth] + summary: Rename a key. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + name: { type: string } + responses: + "204": { description: Renamed. } + "404": { $ref: "#/components/responses/NotFound" } + delete: + tags: [auth] + summary: Remove a key. + description: | + Scoped in the SQL rather than checked first: an id read in one step and + deleted in the next is a window, and the reason somebody is on this screen + is usually that they think a device is gone. + responses: + "204": { description: Removed. } + "404": { $ref: "#/components/responses/NotFound" } + + /api/v1/auth/passkey/login/begin: + post: + tags: [auth] + summary: Start signing in with a key. No session, and no email. + description: | + A discoverable login: 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 + address to type and no list of accounts for anybody to probe. + responses: + "200": + description: Pass `options` to `navigator.credentials.get()`. + content: + application/json: + schema: + type: object + properties: + challenge_id: { type: string } + options: { type: object, additionalProperties: true } + "429": { $ref: "#/components/responses/RateLimited" } + "503": + description: Passkeys are unavailable on this instance. + + /api/v1/auth/passkey/login/finish: + post: + tags: [auth] + summary: Finish signing in with a key. + description: | + A key that verified its owner — a PIN, a fingerprint, a face — is both + factors at once, and completes the login outright. One that only proved + possession still stops at the TOTP step, exactly as a password does. + + A counter that has gone backwards means the credential has been cloned. It + is refused rather than logged: that counter is the only signal this design + gives. + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + challenge_id: { type: string } + credential: + type: object + additionalProperties: true + description: The response from `navigator.credentials.get()`. + responses: + "200": + description: Signed in, or `totp_required` when the key was possession only. + content: + application/json: + schema: + type: object + properties: + user: { $ref: "#/components/schemas/User" } + totp_required: { type: boolean } + "401": + description: | + One message for an unknown key and for a bad signature. Telling them + apart is a way to learn which credentials exist here. + "429": { $ref: "#/components/responses/RateLimited" } + "503": + description: Passkeys are unavailable on this instance. + /api/v1/auth/sessions: get: tags: [auth] @@ -3127,6 +3316,32 @@ components: same thing once decoded, and telling "put it nowhere" apart from "I did not mention it" is what makes this a PATCH. + Passkey: + type: object + properties: + id: { type: string } + name: + type: string + description: What the owner called it. "min bærbare" is what makes a list reviewable. + aaguid: + type: string + description: | + Identifies the authenticator model, which is what lets a list say + something recognisable beside a key somebody named months ago. + discoverable: + type: boolean + description: | + Whether the credential lives on the device. Only these can start a login + with no email typed first. + user_verified: + type: boolean + description: | + Whether the authenticator verified the person — a PIN, a fingerprint, a + face. One that did is both factors at once; one that only proved + possession is a first factor and still meets the TOTP step. + created_at: { type: string, format: date-time } + last_used_at: { type: string, format: date-time } + ProjectGroup: type: object properties: diff --git a/go.mod b/go.mod index 1ccf47a..2f2cdf0 100644 --- a/go.mod +++ b/go.mod @@ -3,21 +3,30 @@ module github.com/kristianwind/verdande go 1.26.3 require ( + github.com/coder/websocket v1.8.15 github.com/go-chi/chi/v5 v5.3.1 + github.com/go-webauthn/webauthn v0.17.4 github.com/pquerna/otp v1.5.0 + github.com/teambition/rrule-go v1.8.2 golang.org/x/crypto v0.55.0 modernc.org/sqlite v1.56.0 ) require ( github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect - github.com/coder/websocket v1.8.15 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/fxamacker/cbor/v2 v2.9.2 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/go-webauthn/x v0.2.6 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/google/go-tpm v0.9.8 // indirect github.com/google/uuid v1.6.0 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/philhofer/fwd v1.2.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect - github.com/teambition/rrule-go v1.8.2 // indirect + github.com/tinylib/msgp v1.6.4 // indirect + github.com/x448/float16 v0.8.4 // indirect golang.org/x/sys v0.47.0 // indirect modernc.org/libc v1.74.4 // indirect modernc.org/mathutil v1.7.1 // indirect diff --git a/go.sum b/go.sum index 5240294..47ca1ee 100644 --- a/go.sum +++ b/go.sum @@ -2,12 +2,27 @@ github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8 github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8= github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= -github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78= +github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8= github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk= +github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8= +github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk= +github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo= +github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc= +github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFebv6EsYotImrt/Ppc5cXIriCSo= github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -18,6 +33,8 @@ github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsRe github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM= +github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs= @@ -25,10 +42,17 @@ github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1 github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= +github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ= +github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= @@ -39,6 +63,8 @@ golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= modernc.org/cc/v4 v4.29.1/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= diff --git a/internal/auth/auth_test.go b/internal/auth/auth_test.go index 6106d0f..d20dca9 100644 --- a/internal/auth/auth_test.go +++ b/internal/auth/auth_test.go @@ -280,3 +280,33 @@ func codeAt(t *testing.T, secret string, now time.Time) string { } return code } + +// The relying party is derived from the instance's own base URL, and the two ways +// that goes wrong are both silent until a browser refuses. +func TestPasskeysRefuseAnAddressNoBrowserWillAccept(t *testing.T) { + for _, c := range []struct { + base string + ok bool + why string + }{ + {"https://verdande.example.dk", true, "a real domain"}, + {"http://localhost:5173", true, "localhost, which every browser excepts"}, + {"https://verdande.example.dk:8443", true, "a port is dropped from the RP ID"}, + + // An IP address is not a domain. The browser throws SecurityError long + // after the server has happily issued a challenge, so it is refused here + // and the endpoints answer 503 instead. + {"http://127.0.0.1:8080", false, "an IPv4 literal"}, + {"http://[::1]:8080", false, "an IPv6 literal"}, + {"", false, "no address at all"}, + {"not a url", false, "not an address"}, + } { + _, err := NewWebAuthn(c.base, "verdande") + if c.ok && err != nil { + t.Errorf("NewWebAuthn(%q) refused %s: %v", c.base, c.why, err) + } + if !c.ok && err == nil { + t.Errorf("NewWebAuthn(%q) accepted %s, which a browser will not", c.base, c.why) + } + } +} diff --git a/internal/auth/webauthn.go b/internal/auth/webauthn.go new file mode 100644 index 0000000..f0cc89c --- /dev/null +++ b/internal/auth/webauthn.go @@ -0,0 +1,98 @@ +package auth + +import ( + "encoding/base64" + "fmt" + "net" + "net/url" + "strings" + + "github.com/go-webauthn/webauthn/webauthn" +) + +// Passkeys, wrapped so the rest of the app never touches the protocol types. +// +// 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. +// +// That binding is also the one thing that can be got wrong here, and it is +// invisible when it is: the RP ID has to be the registrable domain of the address +// people actually visit. Get it wrong and registration works, login works on your +// machine, and everybody behind a different hostname is quietly locked out. + +// WebAuthn is the relying party — this instance, as an authenticator sees it. +type WebAuthn struct{ *webauthn.WebAuthn } + +// NewWebAuthn derives the relying party from the instance's own base URL. +// +// Derived rather than configured, because a second setting that has to agree with +// VERDANDE_BASE_URL is a second setting that will one day disagree with it — and +// the failure is a login that cannot be completed, reported as "the button does +// nothing". +func NewWebAuthn(baseURL, displayName string) (*WebAuthn, error) { + parsed, err := url.Parse(strings.TrimSpace(baseURL)) + if err != nil || parsed.Host == "" { + return nil, fmt.Errorf("auth: %q is not a usable base URL for passkeys", baseURL) + } + + // The RP ID is the host without the port. A port in it is rejected by every + // browser, and localhost:5173 during development is exactly where somebody + // would first put one. + rpID := parsed.Hostname() + + // An IP address is not a domain, and no browser will accept one as an RP ID — + // `SecurityError: This is an invalid domain`, thrown in the browser, long after + // the server has happily issued a challenge. Refused here instead, so the + // endpoints answer 503 and the interface never offers a button that cannot + // work. `localhost` is the exception every browser makes, and it is what + // development runs on. + if ip := net.ParseIP(rpID); ip != nil { + return nil, fmt.Errorf( + "auth: passkeys need a domain name, and %q is an IP address — "+ + "set VERDANDE_BASE_URL to the address people actually visit", rpID) + } + + w, err := webauthn.New(&webauthn.Config{ + RPID: rpID, + RPDisplayName: displayName, + // The origin is the full scheme://host:port, which is what the browser + // sends and what the signature is checked against. + RPOrigins: []string{strings.TrimSuffix(parsed.Scheme+"://"+parsed.Host, "/")}, + }) + if err != nil { + return nil, fmt.Errorf("auth: passkeys: %w", err) + } + return &WebAuthn{w}, nil +} + +// A PasskeyUser is what the library needs to know about an account. +// +// The handle is the account id rather than anything a person chose. It is written +// into the credential on the device and cannot be changed afterwards, so it must +// not be an email address: changing your email would orphan every key you own. +type PasskeyUser struct { + ID string + Email string + Name string + Credentials []webauthn.Credential +} + +func (u *PasskeyUser) WebAuthnID() []byte { return []byte(u.ID) } +func (u *PasskeyUser) WebAuthnName() string { return u.Email } +func (u *PasskeyUser) WebAuthnDisplayName() string { return u.Name } +func (u *PasskeyUser) WebAuthnCredentials() []webauthn.Credential { return u.Credentials } + +// EncodeCredentialID renders a credential id for storage and for a URL. Raw bytes +// go in a BLOB well enough, but the id is also compared against what the browser +// sends back as base64url, and one encoding on both sides is one thing to be +// wrong about. +func EncodeCredentialID(raw []byte) string { + return base64.RawURLEncoding.EncodeToString(raw) +} + +func DecodeCredentialID(encoded string) ([]byte, error) { + return base64.RawURLEncoding.DecodeString(encoded) +} diff --git a/internal/httpapi/auth_test.go b/internal/httpapi/auth_test.go index 633510c..af2d55f 100644 --- a/internal/httpapi/auth_test.go +++ b/internal/httpapi/auth_test.go @@ -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, diff --git a/internal/httpapi/integrations_test.go b/internal/httpapi/integrations_test.go index afb1734..aac6431 100644 --- a/internal/httpapi/integrations_test.go +++ b/internal/httpapi/integrations_test.go @@ -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"]) } diff --git a/internal/httpapi/passkey_handlers.go b/internal/httpapi/passkey_handlers.go new file mode 100644 index 0000000..f2141c7 --- /dev/null +++ b/internal/httpapi/passkey_handlers.go @@ -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 +} diff --git a/internal/httpapi/passkey_test.go b/internal/httpapi/passkey_test.go new file mode 100644 index 0000000..8a278e4 --- /dev/null +++ b/internal/httpapi/passkey_test.go @@ -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) + } +} diff --git a/internal/httpapi/server.go b/internal/httpapi/server.go index 55138f2..1050c0a 100644 --- a/internal/httpapi/server.go +++ b/internal/httpapi/server.go @@ -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) }) }) }) diff --git a/internal/store/migrations/0013_passkeys.sql b/internal/store/migrations/0013_passkeys.sql new file mode 100644 index 0000000..811f857 --- /dev/null +++ b/internal/store/migrations/0013_passkeys.sql @@ -0,0 +1,73 @@ +-- Passkeys: a key on a device instead of a secret in a head. +-- +-- The account already has two ways in — a password, and a TOTP code on top of it. +-- Both are shared secrets: the server keeps something that can be stolen from it, +-- and the person keeps something that can be phished out of them. A passkey is +-- neither. The private half never leaves the device, the public half is all this +-- table holds, and the signature is bound to the origin — so a convincing copy of +-- the sign-in page at a different address gets nothing. +-- +-- One row per credential rather than per account, because a person has more than +-- one device and losing a laptop should not mean losing the way in. The name is +-- theirs to write: "min bærbare" is what makes a list of keys reviewable, and a +-- list nobody can read is a list nobody revokes from. +-- +-- `sign_count` is the authenticator's own counter. It is stored to be compared: +-- a counter that goes backwards means the credential has been cloned, which is +-- the one thing this design cannot otherwise detect. Not every authenticator keeps +-- one — the spec allows zero — so zero means "not offered" rather than "suspicious". +CREATE TABLE passkeys ( + id TEXT PRIMARY KEY, + user_id TEXT NOT NULL REFERENCES users (id) ON DELETE CASCADE, + -- The credential id the authenticator generated, raw bytes, base64url as + -- stored. Unique across the instance: it is what a login is looked up by, + -- before anybody has said who they are. + credential_id TEXT NOT NULL, + public_key BLOB NOT NULL, + -- The AAGUID identifies the authenticator model, which is what lets a list say + -- "iCloud Keychain" beside a key somebody named three months ago and forgot. + aaguid TEXT NOT NULL DEFAULT '', + sign_count INTEGER NOT NULL DEFAULT 0, + -- Whether the credential lives on the device itself (a resident key). Only + -- those can start a login with no email typed first, so the interface has to + -- know which it has before it offers that. + discoverable INTEGER NOT NULL DEFAULT 0, + -- Whether the authenticator verified the person — a PIN, a fingerprint, a + -- face. A key that only proves possession is a first factor; one that verified + -- its owner is both, and that is what decides whether a password is still + -- asked for. + user_verified INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + last_used_at INTEGER +); + +CREATE UNIQUE INDEX idx_passkeys_credential ON passkeys (credential_id); +CREATE INDEX idx_passkeys_user ON passkeys (user_id); + +-- The challenge a registration or a login is answered against. +-- +-- Kept server-side rather than in a cookie: the whole point of the challenge is +-- that the server chose it and remembers choosing it. One row per attempt, deleted +-- when it is answered, and swept when it expires — a challenge that outlives its +-- ceremony is a replay waiting to be tried. +-- +-- user_id is null for a login that has not said who it is yet, which is the case +-- a discoverable credential exists for. +CREATE TABLE webauthn_challenges ( + id TEXT PRIMARY KEY, + user_id TEXT REFERENCES users (id) ON DELETE CASCADE, + challenge TEXT NOT NULL, + -- 'register' or 'login'. A challenge issued for one must not be answerable by + -- the other: registering is done by somebody already signed in, and logging in + -- is not. + purpose TEXT NOT NULL, + -- The library's own session state, which carries the allowed credentials and + -- the verification requirement it will check the answer against. + session BLOB NOT NULL, + created_at INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + CHECK (purpose IN ('register', 'login')) +); + +CREATE INDEX idx_webauthn_challenges_expiry ON webauthn_challenges (expires_at); diff --git a/internal/store/passkeys.go b/internal/store/passkeys.go new file mode 100644 index 0000000..6cb147a --- /dev/null +++ b/internal/store/passkeys.go @@ -0,0 +1,206 @@ +package store + +import ( + "context" + "database/sql" + "errors" + "time" +) + +// A Passkey is one credential on one device. +// +// One row per credential rather than per account: a person has a laptop and a +// phone, and losing one must not mean losing the way in. The name is theirs to +// write — "min bærbare" is what makes the list reviewable, and a list nobody can +// read is a list nobody revokes from. +type Passkey struct { + ID string + UserID string + CredentialID string + PublicKey []byte + AAGUID string + SignCount uint32 + Discoverable bool + UserVerified bool + Name string + CreatedAt time.Time + LastUsedAt time.Time +} + +func (db *DB) CreatePasskey(ctx context.Context, p *Passkey) error { + if p.ID == "" { + p.ID = NewID() + } + p.CreatedAt = time.Now().UTC() + _, err := db.ExecContext(ctx, ` + INSERT INTO passkeys (id, user_id, credential_id, public_key, aaguid, sign_count, + discoverable, user_verified, name, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + p.ID, p.UserID, p.CredentialID, p.PublicKey, p.AAGUID, p.SignCount, + boolToInt(p.Discoverable), boolToInt(p.UserVerified), p.Name, p.CreatedAt.Unix()) + return err +} + +func (db *DB) ListPasskeys(ctx context.Context, userID string) ([]Passkey, error) { + rows, err := db.QueryContext(ctx, ` + SELECT id, user_id, credential_id, public_key, aaguid, sign_count, + discoverable, user_verified, name, created_at, COALESCE(last_used_at, 0) + FROM passkeys WHERE user_id = ? ORDER BY created_at`, userID) + if err != nil { + return nil, err + } + defer rows.Close() + return scanPasskeys(rows) +} + +// PasskeyByCredentialID is how a login finds the account, before anybody has said +// who they are. The credential id is unique across the instance, which is what +// makes that possible. +func (db *DB) PasskeyByCredentialID(ctx context.Context, credentialID string) (*Passkey, error) { + rows, err := db.QueryContext(ctx, ` + SELECT id, user_id, credential_id, public_key, aaguid, sign_count, + discoverable, user_verified, name, created_at, COALESCE(last_used_at, 0) + FROM passkeys WHERE credential_id = ?`, credentialID) + if err != nil { + return nil, err + } + defer rows.Close() + + list, err := scanPasskeys(rows) + if err != nil { + return nil, err + } + if len(list) == 0 { + return nil, ErrNotFound + } + return &list[0], nil +} + +// TouchPasskey records a use and the authenticator's counter. +// +// The counter is stored to be compared. One that goes backwards means the +// credential has been cloned, which is the single thing this design cannot +// otherwise detect — and not every authenticator keeps one, so zero means "not +// offered" rather than "suspicious". +func (db *DB) TouchPasskey(ctx context.Context, id string, signCount uint32) error { + _, err := db.ExecContext(ctx, + `UPDATE passkeys SET sign_count = ?, last_used_at = ? WHERE id = ?`, + signCount, time.Now().Unix(), id) + return err +} + +func (db *DB) RenamePasskey(ctx context.Context, id, userID, name string) error { + res, err := db.ExecContext(ctx, + `UPDATE passkeys SET name = ? WHERE id = ? AND user_id = ?`, name, id, userID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// DeletePasskey removes one key. Scoped in the SQL rather than checked first: an +// id read in one step and deleted in the next is a window, and the reason somebody +// is on this screen at all is usually that they think a device is gone. +func (db *DB) DeletePasskey(ctx context.Context, id, userID string) error { + res, err := db.ExecContext(ctx, + `DELETE FROM passkeys WHERE id = ? AND user_id = ?`, id, userID) + if err != nil { + return err + } + if n, _ := res.RowsAffected(); n == 0 { + return ErrNotFound + } + return nil +} + +// CountPasskeys is what stops somebody removing their last way in when it is the +// only one they have. +func (db *DB) CountPasskeys(ctx context.Context, userID string) (int, error) { + var n int + err := db.QueryRowContext(ctx, + `SELECT count(*) FROM passkeys WHERE user_id = ?`, userID).Scan(&n) + return n, err +} + +func scanPasskeys(rows *sql.Rows) ([]Passkey, error) { + out := []Passkey{} + for rows.Next() { + var p Passkey + var discoverable, verified int + var created, used int64 + if err := rows.Scan(&p.ID, &p.UserID, &p.CredentialID, &p.PublicKey, &p.AAGUID, + &p.SignCount, &discoverable, &verified, &p.Name, &created, &used); err != nil { + return nil, err + } + p.Discoverable = discoverable == 1 + p.UserVerified = verified == 1 + p.CreatedAt = time.Unix(created, 0).UTC() + if used > 0 { + p.LastUsedAt = time.Unix(used, 0).UTC() + } + out = append(out, p) + } + return out, rows.Err() +} + +// --- challenges ------------------------------------------------------------------- + +// StoreChallenge keeps the server's side of one ceremony. +// +// Server-side rather than in a cookie: the whole point of a challenge is that the +// server chose it and remembers choosing it. It is deleted when answered and swept +// when it expires — a challenge that outlives its ceremony is a replay waiting to +// be tried. +func (db *DB) StoreChallenge(ctx context.Context, userID, challenge, purpose string, session []byte, ttl time.Duration) (string, error) { + id := NewID() + now := time.Now() + _, err := db.ExecContext(ctx, ` + INSERT INTO webauthn_challenges (id, user_id, challenge, purpose, session, created_at, expires_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + id, nullString(userID), challenge, purpose, session, now.Unix(), now.Add(ttl).Unix()) + return id, err +} + +// TakeChallenge reads a challenge and removes it in the same transaction. +// +// Taken rather than read, because a challenge is answerable exactly once. Two +// requests racing the same one would otherwise both succeed, which is the whole +// shape of a replay. +func (db *DB) TakeChallenge(ctx context.Context, id, purpose string) (userID string, session []byte, err error) { + err = db.Tx(ctx, func(tx *sql.Tx) error { + var owner sql.NullString + var expires int64 + row := tx.QueryRowContext(ctx, + `SELECT user_id, session, expires_at FROM webauthn_challenges WHERE id = ? AND purpose = ?`, + id, purpose) + if err := row.Scan(&owner, &session, &expires); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + return err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM webauthn_challenges WHERE id = ?`, id); err != nil { + return err + } + if time.Now().Unix() > expires { + return ErrNotFound + } + userID = owner.String + return nil + }) + return userID, session, err +} + +// PurgeChallenges is run by the nightly sweep. An answered challenge deletes +// itself; an abandoned one is what this is for. +func (db *DB) PurgeChallenges(ctx context.Context) (int64, error) { + res, err := db.ExecContext(ctx, + `DELETE FROM webauthn_challenges WHERE expires_at < ?`, time.Now().Unix()) + if err != nil { + return 0, err + } + return res.RowsAffected() +} diff --git a/web/e2e/smoke.spec.js b/web/e2e/smoke.spec.js index 06934cf..0260e1a 100644 --- a/web/e2e/smoke.spec.js +++ b/web/e2e/smoke.spec.js @@ -50,9 +50,16 @@ function watchForTrouble(page) { page.on('pageerror', (error) => trouble.push(String(error))); page.on('response', (response) => { const url = response.url(); - if (url.includes('/api/') && !response.ok()) { - trouble.push(`${response.request().method()} ${new URL(url).pathname} → ${response.status()}`); - } + if (!url.includes('/api/') || response.ok()) return; + + // A 401 from /auth/me is the app asking "am I signed in?" and being told no, + // which is the correct answer on the sign-in page and after signing out. + // Every other non-ok response is worth failing over — that is what this + // watcher is for. + const path = new URL(url).pathname; + if (path.endsWith('/auth/me') && response.status() === 401) return; + + trouble.push(`${response.request().method()} ${path} → ${response.status()}`); }); return trouble; } @@ -577,7 +584,7 @@ test('et invitationslink opretter kontoen og giver adgang til projektet', async await invitee.goto(link); await expect(invitee.getByText('Du er inviteret')).toBeVisible(); - await invitee.getByLabel('Navn').fill('Nabo'); + await invitee.getByLabel('Navn', { exact: true }).fill('Nabo'); await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test'); await invitee.getByRole('button', { name: 'Opret konto' }).click(); @@ -734,7 +741,7 @@ test('en administrator kan invitere en bruger til instansen', async ({ browser, const context = await browser.newContext({ storageState: { cookies: [], origins: [] } }); const invitee = await context.newPage(); await invitee.goto(link); - await invitee.getByLabel('Navn').fill('Anders'); + await invitee.getByLabel('Navn', { exact: true }).fill('Anders'); await invitee.getByLabel(/Adgangskode/).fill('et langt kodeord til test'); await invitee.getByRole('button', { name: 'Opret konto' }).click(); @@ -807,7 +814,7 @@ test('en rolle kan rettes uden at fjerne personen', async ({ browser, page }) => const context = await browser.newContext({ storageState: { cookies: [], origins: [] } }); const andreas = await context.newPage(); await andreas.goto(link); - await andreas.getByLabel('Navn').fill('andreas'); + await andreas.getByLabel('Navn', { exact: true }).fill('andreas'); await andreas.getByLabel(/Adgangskode/).fill('et langt kodeord til test'); await andreas.getByRole('button', { name: 'Opret konto' }).click(); await expect(andreas.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible(); @@ -964,7 +971,7 @@ test('historik-siden viser hændelser fra et projekt, administratoren ikke er me expect(link).toContain('/invite?token='); await theirs.goto(link); - await theirs.getByLabel('Navn').fill('Sigrid'); + await theirs.getByLabel('Navn', { exact: true }).fill('Sigrid'); await theirs.getByLabel(/Adgangskode/).fill('et langt kodeord til test'); await theirs.getByRole('button', { name: 'Opret konto' }).click(); await expect(theirs.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible(); @@ -1447,6 +1454,97 @@ test('en opgave lukket ved et uheld kan hentes tilbage', async ({ page }) => { expect(trouble).toEqual([]); }); +/** + * A passkey, registered and then signed in with, end to end. + * + * The Go tests cover who may reach these endpoints and that a challenge is spent + * when it is used. What they cannot do is produce a signature — that needs an + * authenticator. Chrome has a virtual one behind CDP, and it is the only way to + * find out whether the encoding is right: every boundary here is a base64url + * conversion, and one wrong byte produces a signature that will not verify, + * reported as "that key was not accepted" — which reads as a broken key rather + * than a broken encoder. + * + * Chromium only, and deliberately so: this is testing our ceremony, not WebKit's + * authenticator. + */ +test('en passkey kan registreres og logges ind med', async ({ browser, browserName }) => { + test.skip(browserName !== 'chromium', 'den virtuelle authenticator findes kun i Chromium'); + + // Its own context, at `localhost` rather than the loopback address the rest of + // the suite uses. WebAuthn refuses an IP as a relying party id — the browser + // throws `SecurityError: This is an invalid domain` — and localhost is the + // exception every browser makes. It is not the shared baseURL because + // resolving `localhost` is slow enough here to cost the whole suite minutes. + // + // Cookies are bound to an origin, so the signed-in state from the setup project + // does not carry across: this signs in with a password first, which is also the + // honest order — you register a key on an account you have already proved you + // own. + const origin = 'http://localhost:8097'; + const context = await browser.newContext({ storageState: { cookies: [], origins: [] } }); + const page = await context.newPage(); + const trouble = watchForTrouble(page); + + await page.goto(`${origin}/`); + await page.getByLabel(/E-mail/).fill(USER.email); + await page.getByLabel(/Adgangskode/).fill(USER.password); + await page.getByRole('button', { name: 'Log ind', exact: true }).click(); + await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible(); + + // A virtual authenticator that verifies its owner, so the key counts as both + // factors — which is what lets the login complete without a password. + const client = await page.context().newCDPSession(page); + await client.send('WebAuthn.enable'); + const { authenticatorId } = await client.send('WebAuthn.addVirtualAuthenticator', { + options: { + protocol: 'ctap2', + transport: 'internal', + hasResidentKey: true, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true + } + }); + + await page.goto(`${origin}/indstillinger`); + await page.getByLabel('Navn på nøglen').fill('Testnøglen'); + await page.getByRole('button', { name: 'Tilføj en nøgle' }).click(); + + // It is in the list, and it counts as both factors — the virtual authenticator + // verifies, so the login below needs no password. + const key = page.locator('li').filter({ hasText: 'Testnøglen' }); + await expect(key).toBeVisible(); + await expect(key.getByText('begge faktorer')).toBeVisible(); + + // It survives a reload, so this is the server's row and not the page's state. + await page.reload(); + await expect(page.locator('li').filter({ hasText: 'Testnøglen' })).toBeVisible(); + + // Now sign out and back in with it. No email typed: the device knows which + // account its key belongs to, which is also why this page cannot be used to + // find out who has an account here. + await page.getByRole('navigation', { name: 'Hovedmenu' }).getByText('Log ud').click(); + // `exact`, because "Log ind" is a substring of "Log ind med en passkey" and + // Playwright matches an accessible name loosely unless told otherwise. + await expect(page.getByRole('button', { name: 'Log ind', exact: true })).toBeVisible(); + + await page.getByRole('button', { name: 'Log ind med en passkey' }).click(); + await expect(page.getByRole('navigation', { name: 'Hovedmenu' })).toBeVisible(); + await expect(page.getByRole('heading', { name: 'I dag' })).toBeVisible(); + + // And the key records that it was used, which is what makes the list worth + // reading when somebody wonders whether a device is still in play. + await page.goto(`${origin}/indstillinger`); + await expect( + page.locator('li').filter({ hasText: 'Testnøglen' }).getByText(/sidst brugt/) + ).toBeVisible(); + + await client.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId }); + await context.close(); + expect(trouble).toEqual([]); +}); + /** * The sidebar never scrolls sideways. * @@ -1465,7 +1563,9 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => { await page.goto('/indstillinger'); // A name long enough to overflow the column at its default width. - await page.getByLabel('Navn').fill('Kristian Vinterberg-Skovgaard'); + // `exact`, or this also matches "Navn på nøglen" in the passkey panel — and an + // ambiguous locator waits for one of them to go away rather than failing. + await page.getByLabel('Navn', { exact: true }).fill('Kristian Vinterberg-Skovgaard'); await page.getByRole('button', { name: 'Gem' }).first().click(); await expect(page.getByText('Kristian Vinterberg-Skovgaard').first()).toBeVisible(); @@ -1480,7 +1580,7 @@ test('sidebjælken kan ikke scrolles vandret', async ({ page }) => { expect(page_sideways, 'siden kan scrolles vandret').toBeLessThanOrEqual(1); // Put the name back, so the tests after this one see the account they expect. - await page.getByLabel('Navn').fill(USER.name); + await page.getByLabel('Navn', { exact: true }).fill(USER.name); await page.getByRole('button', { name: 'Gem' }).first().click(); expect(trouble).toEqual([]); @@ -1535,6 +1635,16 @@ test('et projekt kan have flere sektioner @forms', async ({ page }) => { // inside a board was exactly true. await page.getByRole('button', { name: 'Board', exact: true }).click(); await expect(page.getByRole('heading', { name: 'Uden sektion' })).toBeVisible(); + + // A board gets the width a reading column withholds. This was `.view:has(.board)` + // for as long as the board has existed and never once matched: `.board` is inside + // a child component, and Svelte scopes a selector to the component that wrote it. + // The rule read correctly, compiled, and could not fire. + const wide = await page.locator('.view').evaluate((el) => el.getBoundingClientRect().width); + await page.getByRole('button', { name: 'Liste', exact: true }).click(); + const narrow = await page.locator('.view').evaluate((el) => el.getBoundingClientRect().width); + expect(wide, 'board-visningen er ikke bredere end listen').toBeGreaterThan(narrow); + await page.getByRole('button', { name: 'Board', exact: true }).click(); await page.getByRole('button', { name: '+ Tilføj sektion' }).click(); const field = page.getByLabel('Ny sektion'); await expect(field, 'boardet har ingen måde at lave en sektion på').toBeVisible(); diff --git a/web/playwright.config.js b/web/playwright.config.js index 9690638..810aa7e 100644 --- a/web/playwright.config.js +++ b/web/playwright.config.js @@ -34,6 +34,9 @@ if (process.env.TEST_WORKER_INDEX === undefined) { } const PORT = 8097; +// `localhost`, not `127.0.0.1`. WebAuthn refuses an IP address as a relying party +// id — `SecurityError: This is an invalid domain` — and localhost is the exception +// every browser makes. The passkey test cannot run against a loopback address. const baseURL = `http://127.0.0.1:${PORT}`; export default defineConfig({ @@ -118,7 +121,15 @@ export default defineConfig({ stderr: 'pipe', env: { VERDANDE_ADDR: `:${PORT}`, - VERDANDE_BASE_URL: baseURL, + // `localhost`, while the suite itself drives `127.0.0.1`. These are two + // different things: the suite's address is where the browser goes, and + // this one is what the server calls itself — which is now also the + // WebAuthn relying party id, and WebAuthn refuses an IP address. + // + // The passkey test navigates to localhost explicitly so its origin + // matches. Everything else stays on the loopback address, where it has + // always been. + VERDANDE_BASE_URL: `http://localhost:${PORT}`, VERDANDE_DATA_DIR: dataDir, // Off: an update check would reach out to GitHub from a test run. VERDANDE_UPDATE_CHECK: 'false' diff --git a/web/src/lib/api.js b/web/src/lib/api.js index 22fb8a4..fb2bf57 100644 --- a/web/src/lib/api.js +++ b/web/src/lib/api.js @@ -181,6 +181,15 @@ export const api = { totpConfirm: (code) => post('/auth/totp/confirm', { code }), totpDisable: (password) => post('/auth/totp/disable', { password }), listSessions: () => get('/auth/sessions'), + + // --- passkeys + listPasskeys: () => get('/auth/passkeys'), + beginPasskeyRegistration: () => post('/auth/passkeys/register/begin'), + finishPasskeyRegistration: (data) => post('/auth/passkeys/register/finish', data), + renamePasskey: (id, name) => patch(`/auth/passkeys/${id}`, { name }), + deletePasskey: (id) => del(`/auth/passkeys/${id}`), + beginPasskeyLogin: () => post('/auth/passkey/login/begin'), + finishPasskeyLogin: (data) => post('/auth/passkey/login/finish', data), endSession: (id) => del(`/auth/sessions/${id}`), // --- users, administrators only diff --git a/web/src/lib/components/SignIn.svelte b/web/src/lib/components/SignIn.svelte index 49cd30f..6062f9d 100644 --- a/web/src/lib/components/SignIn.svelte +++ b/web/src/lib/components/SignIn.svelte @@ -20,6 +20,7 @@ import { app } from '$lib/stores.svelte.js'; import { focusOnMount } from '$lib/focus.js'; import { t } from '$lib/i18n.svelte.js'; + import { available, signIn } from '$lib/passkey.js'; // loading | login | setup | totp | forgot | sent | invite | reset | done let mode = $state('loading'); @@ -31,6 +32,41 @@ let fields = $state({}); let busy = $state(false); + // --- passkey ----------------------------------------------------------------- + + let passkeyReady = $state(false); + + $effect(() => { + available().then((ok) => (passkeyReady = ok)); + }); + + /** + * No email is asked for and none is sent: the device knows which account its + * key belongs to. That is not only convenience — it means this page cannot be + * used to find out who has an account here. + */ + async function signInWithPasskey() { + error = ''; + busy = true; + try { + const result = await signIn(); + if (result.totp_required) { + mode = 'totp'; + } else { + app.user = result.user; + await app.load(); + } + } catch (e) { + // A cancelled prompt is not a failure. Somebody changed their mind, and + // an error for that reads as though the key was rejected. + if (e?.name !== 'NotAllowedError' && e?.message !== 'cancelled') { + error = humanMessage(e); + } + } finally { + busy = false; + } + } + /** * The token from an emailed link, if this is one. * @@ -267,6 +303,15 @@ + + + {#if passkeyReady} + + {/if} + @@ -381,6 +426,22 @@ cursor: default; } + /* Set apart from the primary button but not made secondary: it is an equal way + in, not a fallback. */ + .passkey { + width: 100%; + padding: var(--s2) var(--s4); + border: 1px solid var(--line-strong); + border-radius: var(--radius); + font-size: var(--text-sm); + color: var(--ink); + transition: border-color var(--fast) var(--ease); + } + + .passkey:hover { + border-color: var(--accent); + } + .link { color: var(--ink-faint); font-size: var(--text-sm); diff --git a/web/src/lib/locales/da.js b/web/src/lib/locales/da.js index 06a2add..743aff0 100644 --- a/web/src/lib/locales/da.js +++ b/web/src/lib/locales/da.js @@ -613,5 +613,27 @@ export const da = { 'done.title': 'Færdige', 'done.hint': 'Alt du har lukket, nyeste først. Klik cirklen for at åbne en igen.', 'done.empty': 'Du har ikke lukket noget endnu.', - 'done.unknownDay': 'Uden dato' + 'done.unknownDay': 'Uden dato', + + // --- passkeys ---------------------------------------------------------------------- + 'passkey.title': 'Passkeys', + 'passkey.hint': + 'En nøgle på en enhed i stedet for en hemmelighed i et hoved. Den private halvdel forlader aldrig enheden, og signaturen er bundet til adressen — så en overbevisende kopi af login-siden et andet sted får ingenting.', + 'passkey.unsupported': 'Denne browser understøtter ikke passkeys.', + 'passkey.none': 'Ingen nøgler endnu.', + 'passkey.name': 'Navn på nøglen', + 'passkey.namePlaceholder': 'fx min bærbare', + 'passkey.add': 'Tilføj en nøgle', + 'passkey.registering': 'Venter på enheden…', + 'passkey.remove': 'Fjern', + 'passkey.removeQuestion': 'Fjern "{name}"? Enheden kan ikke logge ind med den bagefter.', + 'passkey.bothFactors': 'begge faktorer', + 'passkey.possessionOnly': 'kun besiddelse — koden bliver stadig krævet', + 'passkey.lastUsed': 'sidst brugt {when}', + 'passkey.neverUsed': 'aldrig brugt', + 'passkey.signIn': 'Log ind med en passkey', + 'passkey.signingIn': 'Venter på enheden…', + + 'passkey.unavailable': + 'Passkeys kan ikke bruges her — enten understøtter browseren dem ikke, eller også er serverens adresse en IP frem for et domænenavn, hvilket ingen authenticator accepterer.' }; diff --git a/web/src/lib/locales/en.js b/web/src/lib/locales/en.js index e5bbe5b..c1fa9b9 100644 --- a/web/src/lib/locales/en.js +++ b/web/src/lib/locales/en.js @@ -608,5 +608,27 @@ export const en = { 'done.title': 'Done', 'done.hint': 'Everything you have closed, newest first. Click the circle to reopen one.', 'done.empty': 'You have not closed anything yet.', - 'done.unknownDay': 'No date' + 'done.unknownDay': 'No date', + + // --- passkeys ---------------------------------------------------------------------- + 'passkey.title': 'Passkeys', + 'passkey.hint': + 'A key on a device instead of a secret in a head. The private half never leaves the device, and the signature is bound to the address that asked for it — so a convincing copy of the sign-in page somewhere else gets nothing.', + 'passkey.unsupported': 'This browser does not support passkeys.', + 'passkey.none': 'No keys yet.', + 'passkey.name': 'Name for the key', + 'passkey.namePlaceholder': 'e.g. my laptop', + 'passkey.add': 'Add a key', + 'passkey.registering': 'Waiting for the device…', + 'passkey.remove': 'Remove', + 'passkey.removeQuestion': 'Remove "{name}"? That device cannot sign in with it afterwards.', + 'passkey.bothFactors': 'both factors', + 'passkey.possessionOnly': 'possession only — the code is still required', + 'passkey.lastUsed': 'last used {when}', + 'passkey.neverUsed': 'never used', + 'passkey.signIn': 'Sign in with a passkey', + 'passkey.signingIn': 'Waiting for the device…', + + 'passkey.unavailable': + 'Passkeys cannot be used here — either this browser does not support them, or the server’s address is an IP rather than a domain name, which no authenticator will accept.' }; diff --git a/web/src/lib/passkey.js b/web/src/lib/passkey.js new file mode 100644 index 0000000..7c7a704 --- /dev/null +++ b/web/src/lib/passkey.js @@ -0,0 +1,115 @@ +/** + * The browser half of a passkey ceremony. + * + * WebAuthn passes binary as ArrayBuffers and JSON cannot carry those, so every + * boundary between this and the server is a base64url conversion. Getting one of + * them wrong produces a signature that will not verify, reported as "that key was + * not accepted" — which reads as a broken key rather than a broken encoder. That + * is the whole reason this is one file and not three call sites. + */ +import { api } from './api.js'; + +/** Whether this browser can do it at all. */ +export function supported() { + return typeof window !== 'undefined' && Boolean(window.PublicKeyCredential); +} + +/** + * Whether a key can be offered without the person typing anything first. + * + * Not the same question as `supported()`: a browser can do WebAuthn and still have + * nowhere to put a discoverable credential. Asking first is what stops the sign-in + * page offering a button that cannot work. + */ +export async function available() { + if (!supported()) return false; + try { + return await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable(); + } catch { + return false; + } +} + +const toBuffer = (value) => { + const normalised = value.replace(/-/g, '+').replace(/_/g, '/'); + const padded = normalised.padEnd(normalised.length + ((4 - (normalised.length % 4)) % 4), '='); + return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0)); +}; + +const toBase64URL = (buffer) => + btoa(String.fromCharCode(...new Uint8Array(buffer))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + +/** Registers a key on the signed-in account. Returns the row the server stored. */ +export async function register(name) { + const { challenge_id, options } = await api.beginPasskeyRegistration(); + const publicKey = { + ...options.publicKey, + challenge: toBuffer(options.publicKey.challenge), + user: { ...options.publicKey.user, id: toBuffer(options.publicKey.user.id) }, + excludeCredentials: (options.publicKey.excludeCredentials ?? []).map((c) => ({ + ...c, + id: toBuffer(c.id) + })) + }; + + const credential = await navigator.credentials.create({ publicKey }); + if (!credential) throw new Error('cancelled'); + + return api.finishPasskeyRegistration({ + challenge_id, + name, + credential: { + id: credential.id, + rawId: toBase64URL(credential.rawId), + type: credential.type, + response: { + clientDataJSON: toBase64URL(credential.response.clientDataJSON), + attestationObject: toBase64URL(credential.response.attestationObject) + }, + clientExtensionResults: credential.getClientExtensionResults() + } + }); +} + +/** + * Signs in with a key. + * + * No email is asked for and none is sent: the device knows which account its key + * belongs to. That is not only convenience — it means the sign-in page cannot be + * used to find out who has an account here. + */ +export async function signIn() { + const { challenge_id, options } = await api.beginPasskeyLogin(); + const publicKey = { + ...options.publicKey, + challenge: toBuffer(options.publicKey.challenge), + allowCredentials: (options.publicKey.allowCredentials ?? []).map((c) => ({ + ...c, + id: toBuffer(c.id) + })) + }; + + const credential = await navigator.credentials.get({ publicKey }); + if (!credential) throw new Error('cancelled'); + + return api.finishPasskeyLogin({ + challenge_id, + credential: { + id: credential.id, + rawId: toBase64URL(credential.rawId), + type: credential.type, + response: { + clientDataJSON: toBase64URL(credential.response.clientDataJSON), + authenticatorData: toBase64URL(credential.response.authenticatorData), + signature: toBase64URL(credential.response.signature), + userHandle: credential.response.userHandle + ? toBase64URL(credential.response.userHandle) + : null + }, + clientExtensionResults: credential.getClientExtensionResults() + } + }); +} diff --git a/web/src/routes/indstillinger/+page.svelte b/web/src/routes/indstillinger/+page.svelte index b5cf3ac..e8a3f7b 100644 --- a/web/src/routes/indstillinger/+page.svelte +++ b/web/src/routes/indstillinger/+page.svelte @@ -3,12 +3,67 @@ import { api, humanMessage } from '$lib/api.js'; import { app, theme, THEMES } from '$lib/stores.svelte.js'; import { t, plural, i18n } from '$lib/i18n.svelte.js'; + import { supported, register } from '$lib/passkey.js'; + import { ago } from '$lib/when.js'; // --- profile --------------------------------------------------------------- let name = $state(''); let timezone = $state(''); let locale = $state('da'); + + // --- passkeys ------------------------------------------------------------------- + + let passkeys = $state([]); + let passkeyName = $state(''); + let passkeyError = $state(''); + let registering = $state(false); + let passkeySupported = $state(true); + + $effect(() => { + passkeySupported = supported(); + if (!passkeySupported) return; + api + .listPasskeys() + .then((r) => { + passkeys = r.passkeys; + // The server has the other half of the answer: this browser can do + // passkeys, and this deployment may still be on an address no + // authenticator will accept. + passkeySupported = r.available; + }) + .catch(() => {}); + }); + + async function addPasskey(event) { + event.preventDefault(); + passkeyError = ''; + registering = true; + try { + passkeys = [...passkeys, await register(passkeyName.trim())]; + passkeyName = ''; + } catch (e) { + // A cancelled prompt is not a failure — the person changed their mind, + // and an error message for that reads as though something broke. + if (e?.name !== 'NotAllowedError' && e?.message !== 'cancelled') { + passkeyError = humanMessage(e); + } + } finally { + registering = false; + } + } + + async function removePasskey(key) { + if (!confirm(t('passkey.removeQuestion', { name: key.name }))) return; + const previous = passkeys; + passkeys = passkeys.filter((k) => k.id !== key.id); + try { + await api.deletePasskey(key.id); + } catch (e) { + passkeys = previous; + app.toast(humanMessage(e)); + } + } let profileErrors = $state({}); let profileSaved = $state(false); let savingProfile = $state(false); @@ -187,16 +242,6 @@ * older than a week gets its date instead: "for 23 dage siden" is a number * nobody can place. */ - function ago(iso) { - const then = new Date(iso); - const seconds = Math.round((Date.now() - then) / 1000); - - if (seconds < 60) return 'lige nu'; - if (seconds < 3600) return `for ${Math.floor(seconds / 60)} min. siden`; - if (seconds < 86400) return `for ${Math.floor(seconds / 3600)} timer siden`; - if (seconds < 604800) return `for ${Math.floor(seconds / 86400)} dage siden`; - return then.toLocaleDateString('da-DK', { day: 'numeric', month: 'short', year: 'numeric' }); - }
@@ -413,6 +458,47 @@ {/if}
+ +
+
+

{t('passkey.title')}

+

{t('passkey.hint')}

+
+ + {#if !passkeySupported} +

{t('passkey.unavailable')}

+ {:else} + {#if passkeys.length} +
    + {#each passkeys as key (key.id)} +
  • +
    + {key.name} + + {key.user_verified ? t('passkey.bothFactors') : t('passkey.possessionOnly')} + · + {key.last_used_at ? t('passkey.lastUsed', { when: ago(key.last_used_at) }) : t('passkey.neverUsed')} + +
    + +
  • + {/each} +
+ {:else} +

{t('passkey.none')}

+ {/if} + +
+ + +
+ {#if passkeyError}

{passkeyError}

{/if} + {/if} +
+

{t('account.devices')}

diff --git a/web/src/routes/projekt/[id]/+page.svelte b/web/src/routes/projekt/[id]/+page.svelte index ca35620..e5d7a9d 100644 --- a/web/src/routes/projekt/[id]/+page.svelte +++ b/web/src/routes/projekt/[id]/+page.svelte @@ -402,7 +402,12 @@ } -
+ +
{#if project}