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} + + {: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}