detached 3246333

feat(webauthn): add the passkey verifier adapter over go-webauthn

commit
3246333677a3c852fd33f6f1bb08c7a7952dacba
author
brice · · 2026-10-12 14:52 UTC
parent
dbbae13
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +666 -2
M go.mod +8 -0
@@ -19,6 +19,8 @@ require (
1919 github.com/a-h/templ v0.3.1020
2020 github.com/alecthomas/chroma/v2 v2.27.0
2121 github.com/bnema/zerowrap v1.4.1
22+ github.com/descope/virtualwebauthn v1.0.5
23+ github.com/go-webauthn/webauthn v0.18.2
2224 github.com/golang-jwt/jwt/v5 v5.3.1
2325 github.com/spf13/cobra v1.10.2
2426 github.com/stretchr/testify v1.12.1
@@ -78,14 +80,17 @@ require (
7880 github.com/fatih/structs v1.1.0 // indirect
7981 github.com/felixge/httpsnoop v1.1.0 // indirect
8082 github.com/fsnotify/fsnotify v1.9.0 // indirect
83+ github.com/fxamacker/cbor/v2 v2.9.4 // indirect
8184 github.com/go-logr/logr v1.4.4 // indirect
8285 github.com/go-logr/stdr v1.2.2 // indirect
8386 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
87+ github.com/go-webauthn/x v0.3.1 // indirect
8488 github.com/gobwas/glob v0.2.3 // indirect
8589 github.com/gofrs/flock v0.13.1 // indirect
8690 github.com/gohugoio/hashstructure v0.6.0 // indirect
8791 github.com/gohugoio/hugo v0.164.0 // indirect
8892 github.com/google/go-containerregistry v0.22.1 // indirect
93+ github.com/google/go-tpm v0.9.8 // indirect
8994 github.com/google/uuid v1.6.0 // indirect
9095 github.com/huandu/xstrings v1.5.0 // indirect
9196 github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -115,6 +120,7 @@ require (
115120 github.com/pelletier/go-toml v1.9.5 // indirect
116121 github.com/pelletier/go-toml/v2 v2.4.3 // indirect
117122 github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect
123+ github.com/philhofer/fwd v1.2.0 // indirect
118124 github.com/quic-go/qpack v0.6.0 // indirect
119125 github.com/quic-go/quic-go v0.62.0 // indirect
120126 github.com/rivo/uniseg v0.4.7 // indirect
@@ -131,7 +137,9 @@ require (
131137 github.com/tdewolff/parse/v2 v2.8.12 // indirect
132138 github.com/tetratelabs/wazero v1.12.0 // indirect
133139 github.com/tidwall/btree v1.8.1 // indirect
140+ github.com/tinylib/msgp v1.6.4 // indirect
134141 github.com/vektra/mockery/v3 v3.8.0 // indirect
142+ github.com/x448/float16 v0.8.4 // indirect
135143 github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
136144 github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
137145 github.com/xeipuuv/gojsonschema v1.2.0 // indirect
@@ -19,6 +19,8 @@ require (
19 github.com/a-h/templ v0.3.102019 github.com/a-h/templ v0.3.1020
20 github.com/alecthomas/chroma/v2 v2.27.020 github.com/alecthomas/chroma/v2 v2.27.0
21 github.com/bnema/zerowrap v1.4.121 github.com/bnema/zerowrap v1.4.1
22+ github.com/descope/virtualwebauthn v1.0.5
23+ github.com/go-webauthn/webauthn v0.18.2
22 github.com/golang-jwt/jwt/v5 v5.3.124 github.com/golang-jwt/jwt/v5 v5.3.1
23 github.com/spf13/cobra v1.10.225 github.com/spf13/cobra v1.10.2
24 github.com/stretchr/testify v1.12.126 github.com/stretchr/testify v1.12.1
@@ -78,14 +80,17 @@ require (
78 github.com/fatih/structs v1.1.0 // indirect80 github.com/fatih/structs v1.1.0 // indirect
79 github.com/felixge/httpsnoop v1.1.0 // indirect81 github.com/felixge/httpsnoop v1.1.0 // indirect
80 github.com/fsnotify/fsnotify v1.9.0 // indirect82 github.com/fsnotify/fsnotify v1.9.0 // indirect
83+ github.com/fxamacker/cbor/v2 v2.9.4 // indirect
81 github.com/go-logr/logr v1.4.4 // indirect84 github.com/go-logr/logr v1.4.4 // indirect
82 github.com/go-logr/stdr v1.2.2 // indirect85 github.com/go-logr/stdr v1.2.2 // indirect
83 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect86 github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
87+ github.com/go-webauthn/x v0.3.1 // indirect
84 github.com/gobwas/glob v0.2.3 // indirect88 github.com/gobwas/glob v0.2.3 // indirect
85 github.com/gofrs/flock v0.13.1 // indirect89 github.com/gofrs/flock v0.13.1 // indirect
86 github.com/gohugoio/hashstructure v0.6.0 // indirect90 github.com/gohugoio/hashstructure v0.6.0 // indirect
87 github.com/gohugoio/hugo v0.164.0 // indirect91 github.com/gohugoio/hugo v0.164.0 // indirect
88 github.com/google/go-containerregistry v0.22.1 // indirect92 github.com/google/go-containerregistry v0.22.1 // indirect
93+ github.com/google/go-tpm v0.9.8 // indirect
89 github.com/google/uuid v1.6.0 // indirect94 github.com/google/uuid v1.6.0 // indirect
90 github.com/huandu/xstrings v1.5.0 // indirect95 github.com/huandu/xstrings v1.5.0 // indirect
91 github.com/inconshreveable/mousetrap v1.1.0 // indirect96 github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -115,6 +120,7 @@ require (
115 github.com/pelletier/go-toml v1.9.5 // indirect120 github.com/pelletier/go-toml v1.9.5 // indirect
116 github.com/pelletier/go-toml/v2 v2.4.3 // indirect121 github.com/pelletier/go-toml/v2 v2.4.3 // indirect
117 github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect122 github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect
123+ github.com/philhofer/fwd v1.2.0 // indirect
118 github.com/quic-go/qpack v0.6.0 // indirect124 github.com/quic-go/qpack v0.6.0 // indirect
119 github.com/quic-go/quic-go v0.62.0 // indirect125 github.com/quic-go/quic-go v0.62.0 // indirect
120 github.com/rivo/uniseg v0.4.7 // indirect126 github.com/rivo/uniseg v0.4.7 // indirect
@@ -131,7 +137,9 @@ require (
131 github.com/tdewolff/parse/v2 v2.8.12 // indirect137 github.com/tdewolff/parse/v2 v2.8.12 // indirect
132 github.com/tetratelabs/wazero v1.12.0 // indirect138 github.com/tetratelabs/wazero v1.12.0 // indirect
133 github.com/tidwall/btree v1.8.1 // indirect139 github.com/tidwall/btree v1.8.1 // indirect
140+ github.com/tinylib/msgp v1.6.4 // indirect
134 github.com/vektra/mockery/v3 v3.8.0 // indirect141 github.com/vektra/mockery/v3 v3.8.0 // indirect
142+ github.com/x448/float16 v0.8.4 // indirect
135 github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect143 github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
136 github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect144 github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
137 github.com/xeipuuv/gojsonschema v1.2.0 // indirect145 github.com/xeipuuv/gojsonschema v1.2.0 // indirect
M go.sum +20 -2
@@ -138,6 +138,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
138138 github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
139139 github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
140140 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
141+github.com/descope/virtualwebauthn v1.0.5 h1:fMXji5UMepJC51Ge6d4v5IAjiJQRKmXE9hlo/B9SczQ=
142+github.com/descope/virtualwebauthn v1.0.5/go.mod h1:lLCfN+DpCM3iisM4bCILZlFEWkC1Zo7ZgsxC45CUapI=
141143 github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
142144 github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
143145 github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
@@ -163,6 +165,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
163165 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
164166 github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
165167 github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
168+github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
169+github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
166170 github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k0EZ2g=
167171 github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g=
168172 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -176,6 +180,10 @@ github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3U
176180 github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
177181 github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
178182 github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
183+github.com/go-webauthn/webauthn v0.18.2 h1:0BeftmEHU7i3Dv0VFwBtidy/ba37Vcdjvqst9EYu8Sk=
184+github.com/go-webauthn/webauthn v0.18.2/go.mod h1:hEXaOuLxvZ3zG9miZe3ehlyeVso9AtklXG+kTn36k+A=
185+github.com/go-webauthn/x v0.3.1 h1:1ff37z3XfmTTomkhlURgGizLIDyOvPgTt2t9nlzKLRo=
186+github.com/go-webauthn/x v0.3.1/go.mod h1:ZInxAynYXfBPvvm5gzKZ7geBlL23K71xASMgohHl/Rg=
179187 github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
180188 github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
181189 github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
@@ -208,6 +216,10 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
208216 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
209217 github.com/google/go-containerregistry v0.22.1 h1:RZuuSYhTvlDvtsK+NkutoCZ//C0X2ebLK8X8l3ULs84=
210218 github.com/google/go-containerregistry v0.22.1/go.mod h1:bJR35SK8XgisYmhg/FMQ/5RK0S/XrOAqLBV5/LR2XE0=
219+github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
220+github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
221+github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
222+github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
211223 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
212224 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
213225 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
@@ -313,6 +325,8 @@ github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdD
313325 github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
314326 github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 h1:lcWAnrqr2nNfDiArwFNHCE4787Mw2tCdVSOXCru0/0E=
315327 github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
328+github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
329+github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
316330 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
317331 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
318332 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -372,8 +386,12 @@ github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZ
372386 github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
373387 github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
374388 github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
389+github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
390+github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
375391 github.com/vektra/mockery/v3 v3.8.0 h1:Tklqcx844iV8GtYziqJXam1a8RdcKip4UErMa66Nxps=
376392 github.com/vektra/mockery/v3 v3.8.0/go.mod h1:K+L72OoFVizA9eWtO4L+kU65oylgreiJEJ57Gu8g9Ew=
393+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
394+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
377395 github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
378396 github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
379397 github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
@@ -411,8 +429,8 @@ go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu6
411429 go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
412430 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
413431 go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
414-go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=
415-go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=
432+go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
433+go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
416434 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
417435 go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
418436 go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
@@ -138,6 +138,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
138 github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=138 github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
139 github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=139 github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
140 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=140 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
141+github.com/descope/virtualwebauthn v1.0.5 h1:fMXji5UMepJC51Ge6d4v5IAjiJQRKmXE9hlo/B9SczQ=
142+github.com/descope/virtualwebauthn v1.0.5/go.mod h1:lLCfN+DpCM3iisM4bCILZlFEWkC1Zo7ZgsxC45CUapI=
141 github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=143 github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
142 github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=144 github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
143 github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=145 github.com/dlclark/regexp2/v2 v2.2.1 h1:mf4KkFUj0gJuarK8P+LgiS+Lit7m9N1yAwEfPbee7R0=
@@ -163,6 +165,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
163 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=165 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
164 github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=166 github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
165 github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=167 github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
168+github.com/fxamacker/cbor/v2 v2.9.4 h1:xwjVlxEMR3S605oUlgBjKLTTeGFciYPGYCtF/35LKGo=
169+github.com/fxamacker/cbor/v2 v2.9.4/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
166 github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k0EZ2g=170 github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k0EZ2g=
167 github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g=171 github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g=
168 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=172 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -176,6 +180,10 @@ github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3U
176 github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=180 github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU=
177 github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=181 github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
178 github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=182 github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
183+github.com/go-webauthn/webauthn v0.18.2 h1:0BeftmEHU7i3Dv0VFwBtidy/ba37Vcdjvqst9EYu8Sk=
184+github.com/go-webauthn/webauthn v0.18.2/go.mod h1:hEXaOuLxvZ3zG9miZe3ehlyeVso9AtklXG+kTn36k+A=
185+github.com/go-webauthn/x v0.3.1 h1:1ff37z3XfmTTomkhlURgGizLIDyOvPgTt2t9nlzKLRo=
186+github.com/go-webauthn/x v0.3.1/go.mod h1:ZInxAynYXfBPvvm5gzKZ7geBlL23K71xASMgohHl/Rg=
179 github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=187 github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
180 github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=188 github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
181 github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=189 github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
@@ -208,6 +216,10 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
208 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=216 github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
209 github.com/google/go-containerregistry v0.22.1 h1:RZuuSYhTvlDvtsK+NkutoCZ//C0X2ebLK8X8l3ULs84=217 github.com/google/go-containerregistry v0.22.1 h1:RZuuSYhTvlDvtsK+NkutoCZ//C0X2ebLK8X8l3ULs84=
210 github.com/google/go-containerregistry v0.22.1/go.mod h1:bJR35SK8XgisYmhg/FMQ/5RK0S/XrOAqLBV5/LR2XE0=218 github.com/google/go-containerregistry v0.22.1/go.mod h1:bJR35SK8XgisYmhg/FMQ/5RK0S/XrOAqLBV5/LR2XE0=
219+github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
220+github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
221+github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
222+github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
211 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=223 github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
212 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=224 github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
213 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=225 github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
@@ -313,6 +325,8 @@ github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdD
313 github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=325 github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
314 github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 h1:lcWAnrqr2nNfDiArwFNHCE4787Mw2tCdVSOXCru0/0E=326 github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 h1:lcWAnrqr2nNfDiArwFNHCE4787Mw2tCdVSOXCru0/0E=
315 github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=327 github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
328+github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
329+github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
316 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=330 github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
317 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=331 github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
318 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=332 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -372,8 +386,12 @@ github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZ
372 github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=386 github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0=
373 github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=387 github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
374 github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=388 github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
389+github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
390+github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
375 github.com/vektra/mockery/v3 v3.8.0 h1:Tklqcx844iV8GtYziqJXam1a8RdcKip4UErMa66Nxps=391 github.com/vektra/mockery/v3 v3.8.0 h1:Tklqcx844iV8GtYziqJXam1a8RdcKip4UErMa66Nxps=
376 github.com/vektra/mockery/v3 v3.8.0/go.mod h1:K+L72OoFVizA9eWtO4L+kU65oylgreiJEJ57Gu8g9Ew=392 github.com/vektra/mockery/v3 v3.8.0/go.mod h1:K+L72OoFVizA9eWtO4L+kU65oylgreiJEJ57Gu8g9Ew=
393+github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
394+github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
377 github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=395 github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
378 github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=396 github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo=
379 github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=397 github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU=
@@ -411,8 +429,8 @@ go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu6
411 go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=429 go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
412 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=430 go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
413 go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=431 go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
414-go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko=432+go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
415-go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o=433+go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
416 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=434 go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
417 go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=435 go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
418 go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=436 go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo=
A internal/adapters/webauthn/webauthn.go +216 -0
@@ -0,0 +1,216 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package webauthn implements ports.PasskeyVerifier over go-webauthn (ADR
4+// 0026). It is the only importer of the library: the use case sees plain
5+// domain values and opaque ceremony bytes.
6+package webauthn
7+
8+import (
9+ "encoding/json"
10+ "errors"
11+ "fmt"
12+ "net/url"
13+ "strings"
14+ "time"
15+
16+ "github.com/go-webauthn/webauthn/protocol"
17+ gowebauthn "github.com/go-webauthn/webauthn/webauthn"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+)
22+
23+// ceremonyTimeout is how long the browser may take, enforced here too. The
24+// use case keeps its own, equal TTL for the ceremony data.
25+const ceremonyTimeout = 2 * time.Minute
26+
27+// ownerName names the one user in the options sent to the browser.
28+const ownerName = "owner"
29+
30+// Verifier runs the WebAuthn ceremonies for the owner of one public URL.
31+type Verifier struct {
32+ web *gowebauthn.WebAuthn
33+}
34+
35+var _ ports.PasskeyVerifier = (*Verifier)(nil)
36+
37+// New builds a verifier for publicURL: the relying party ID is its host name,
38+// the only accepted origin is its origin. It fails when the library refuses the
39+// configuration, for example for a host that is an IP address.
40+func New(publicURL string) (*Verifier, error) {
41+ u, err := url.Parse(publicURL)
42+ if err != nil {
43+ return nil, fmt.Errorf("passkeys: parse public URL: %w", err)
44+ }
45+ host := strings.ToLower(u.Hostname())
46+ if (u.Scheme != "http" && u.Scheme != "https") || host == "" {
47+ return nil, fmt.Errorf("passkeys: public URL %q needs an http(s) scheme and a host", publicURL)
48+ }
49+ timeout := gowebauthn.TimeoutConfig{Enforce: true, Timeout: ceremonyTimeout, TimeoutUVD: ceremonyTimeout}
50+ web, err := gowebauthn.New(&gowebauthn.Config{
51+ RPID: host,
52+ RPDisplayName: host,
53+ RPOrigins: []string{u.Scheme + "://" + strings.ToLower(u.Host)},
54+ AuthenticatorSelection: protocol.AuthenticatorSelection{
55+ ResidentKey: protocol.ResidentKeyRequirementRequired,
56+ RequireResidentKey: protocol.ResidentKeyRequired(),
57+ UserVerification: protocol.VerificationRequired,
58+ },
59+ AttestationPreference: protocol.PreferNoAttestation,
60+ Timeouts: gowebauthn.TimeoutsConfig{Login: timeout, Registration: timeout},
61+ })
62+ if err != nil {
63+ return nil, fmt.Errorf("passkeys: %w", err)
64+ }
65+ return &Verifier{web: web}, nil
66+}
67+
68+// owner is the single WebAuthn user.
69+type owner struct {
70+ handle []byte
71+ credentials []gowebauthn.Credential
72+}
73+
74+func (o owner) WebAuthnID() []byte { return o.handle }
75+func (o owner) WebAuthnName() string { return ownerName }
76+func (o owner) WebAuthnDisplayName() string { return ownerName }
77+func (o owner) WebAuthnCredentials() []gowebauthn.Credential { return o.credentials }
78+
79+// refuse builds the error of a failed verification: an ErrUnauthenticated whose
80+// text carries the reason, for logs. The library error is flattened to text so
81+// that its types never leave this package.
82+func refuse(what string, err error) error {
83+ return fmt.Errorf("%w: passkey %s: %v", domain.ErrUnauthenticated, what, err)
84+}
85+
86+// BeginRegistration implements ports.PasskeyVerifier.
87+func (v *Verifier) BeginRegistration(userHandle []byte, existing []domain.Passkey) ([]byte, []byte, error) {
88+ exclude := make([]protocol.CredentialDescriptor, 0, len(existing))
89+ for _, p := range existing {
90+ exclude = append(exclude, descriptor(p))
91+ }
92+ creation, session, err := v.web.BeginRegistration(owner{handle: userHandle},
93+ gowebauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
94+ gowebauthn.WithExclusions(exclude),
95+ gowebauthn.WithConveyancePreference(protocol.PreferNoAttestation),
96+ )
97+ if err != nil {
98+ return nil, nil, fmt.Errorf("begin passkey registration: %w", err)
99+ }
100+ return encode(creation, session)
101+}
102+
103+// FinishRegistration implements ports.PasskeyVerifier.
104+func (v *Verifier) FinishRegistration(userHandle []byte, ceremony, response []byte) (domain.Passkey, error) {
105+ var session gowebauthn.SessionData
106+ if err := json.Unmarshal(ceremony, &session); err != nil {
107+ return domain.Passkey{}, refuse("registration", fmt.Errorf("decode ceremony: %w", err))
108+ }
109+ parsed, err := protocol.ParseCredentialCreationResponseBytes(response)
110+ if err != nil {
111+ return domain.Passkey{}, refuse("registration", err)
112+ }
113+ cred, err := v.web.CreateCredential(owner{handle: userHandle}, session, parsed)
114+ if err != nil {
115+ return domain.Passkey{}, refuse("registration", err)
116+ }
117+ if n := len(cred.ID); n == 0 || n > domain.MaxPasskeyIDLen {
118+ return domain.Passkey{}, refuse("registration", fmt.Errorf("credential id has %d bytes", n))
119+ }
120+ transports := make([]string, 0, len(cred.Transport))
121+ for _, t := range cred.Transport {
122+ transports = append(transports, string(t))
123+ }
124+ return domain.Passkey{
125+ ID: cred.ID,
126+ PublicKey: cred.PublicKey,
127+ AttestationType: cred.AttestationType,
128+ Transports: transports,
129+ SignCount: cred.Authenticator.SignCount,
130+ BackupEligible: cred.Flags.BackupEligible,
131+ BackupState: cred.Flags.BackupState,
132+ }, nil
133+}
134+
135+// BeginLogin implements ports.PasskeyVerifier.
136+func (v *Verifier) BeginLogin() ([]byte, []byte, error) {
137+ assertion, session, err := v.web.BeginDiscoverableLogin()
138+ if err != nil {
139+ return nil, nil, fmt.Errorf("begin passkey login: %w", err)
140+ }
141+ return encode(assertion, session)
142+}
143+
144+// FinishLogin implements ports.PasskeyVerifier. The library compares the user
145+// handle of the response with userHandle.
146+//
147+//hexcheck:ignore suspicious-business-logic-in-adapter copies the counter and backup state the library verified into the stored passkey; the clone rule is the library's, the refusal is the port contract
148+func (v *Verifier) FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error) {
149+ var session gowebauthn.SessionData
150+ if err := json.Unmarshal(ceremony, &session); err != nil {
151+ return domain.Passkey{}, refuse("login", fmt.Errorf("decode ceremony: %w", err))
152+ }
153+ parsed, err := protocol.ParseCredentialRequestResponseBytes(response)
154+ if err != nil {
155+ return domain.Passkey{}, refuse("login", err)
156+ }
157+
158+ var stored domain.Passkey
159+ var lookupErr error // a failure of the store, as opposed to an unknown credential
160+ handler := func(rawID, _ []byte) (gowebauthn.User, error) {
161+ p, err := lookup(rawID)
162+ if err != nil {
163+ if !errors.Is(err, domain.ErrNotFound) {
164+ lookupErr = err
165+ }
166+ return nil, err
167+ }
168+ stored = p
169+ return owner{handle: userHandle, credentials: []gowebauthn.Credential{credential(p)}}, nil
170+ }
171+ _, cred, err := v.web.ValidatePasskeyLogin(handler, session, parsed)
172+ if lookupErr != nil {
173+ return domain.Passkey{}, lookupErr // fail closed, as it is
174+ }
175+ if err != nil {
176+ return domain.Passkey{}, refuse("login", err)
177+ }
178+ if cred.Authenticator.CloneWarning {
179+ return domain.Passkey{}, domain.ErrPasskeyCloned
180+ }
181+ stored.SignCount = cred.Authenticator.SignCount
182+ stored.BackupState = cred.Flags.BackupState
183+ return stored, nil
184+}
185+
186+// encode returns the JSON of the options for the browser and of the session.
187+func encode(options any, session *gowebauthn.SessionData) (opts, ceremony []byte, err error) {
188+ if opts, err = json.Marshal(options); err != nil {
189+ return nil, nil, fmt.Errorf("encode passkey options: %w", err)
190+ }
191+ if ceremony, err = json.Marshal(session); err != nil {
192+ return nil, nil, fmt.Errorf("encode passkey ceremony: %w", err)
193+ }
194+ return opts, ceremony, nil
195+}
196+
197+// credential converts a stored passkey into the library's form.
198+func credential(p domain.Passkey) gowebauthn.Credential {
199+ c := gowebauthn.Credential{
200+ ID: p.ID,
201+ PublicKey: p.PublicKey,
202+ AttestationType: p.AttestationType,
203+ Flags: gowebauthn.CredentialFlags{BackupEligible: p.BackupEligible, BackupState: p.BackupState},
204+ Authenticator: gowebauthn.Authenticator{SignCount: p.SignCount},
205+ }
206+ for _, t := range p.Transports {
207+ c.Transport = append(c.Transport, protocol.AuthenticatorTransport(t))
208+ }
209+ return c
210+}
211+
212+// descriptor names a stored passkey for the exclusion list.
213+func descriptor(p domain.Passkey) protocol.CredentialDescriptor {
214+ c := credential(p)
215+ return c.Descriptor()
216+}
@@ -0,0 +1,216 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+// Package webauthn implements ports.PasskeyVerifier over go-webauthn (ADR
4+// 0026). It is the only importer of the library: the use case sees plain
5+// domain values and opaque ceremony bytes.
6+package webauthn
7+
8+import (
9+ "encoding/json"
10+ "errors"
11+ "fmt"
12+ "net/url"
13+ "strings"
14+ "time"
15+
16+ "github.com/go-webauthn/webauthn/protocol"
17+ gowebauthn "github.com/go-webauthn/webauthn/webauthn"
18+
19+ "bnema.dev/ermite/internal/domain"
20+ "bnema.dev/ermite/internal/ports"
21+)
22+
23+// ceremonyTimeout is how long the browser may take, enforced here too. The
24+// use case keeps its own, equal TTL for the ceremony data.
25+const ceremonyTimeout = 2 * time.Minute
26+
27+// ownerName names the one user in the options sent to the browser.
28+const ownerName = "owner"
29+
30+// Verifier runs the WebAuthn ceremonies for the owner of one public URL.
31+type Verifier struct {
32+ web *gowebauthn.WebAuthn
33+}
34+
35+var _ ports.PasskeyVerifier = (*Verifier)(nil)
36+
37+// New builds a verifier for publicURL: the relying party ID is its host name,
38+// the only accepted origin is its origin. It fails when the library refuses the
39+// configuration, for example for a host that is an IP address.
40+func New(publicURL string) (*Verifier, error) {
41+ u, err := url.Parse(publicURL)
42+ if err != nil {
43+ return nil, fmt.Errorf("passkeys: parse public URL: %w", err)
44+ }
45+ host := strings.ToLower(u.Hostname())
46+ if (u.Scheme != "http" && u.Scheme != "https") || host == "" {
47+ return nil, fmt.Errorf("passkeys: public URL %q needs an http(s) scheme and a host", publicURL)
48+ }
49+ timeout := gowebauthn.TimeoutConfig{Enforce: true, Timeout: ceremonyTimeout, TimeoutUVD: ceremonyTimeout}
50+ web, err := gowebauthn.New(&gowebauthn.Config{
51+ RPID: host,
52+ RPDisplayName: host,
53+ RPOrigins: []string{u.Scheme + "://" + strings.ToLower(u.Host)},
54+ AuthenticatorSelection: protocol.AuthenticatorSelection{
55+ ResidentKey: protocol.ResidentKeyRequirementRequired,
56+ RequireResidentKey: protocol.ResidentKeyRequired(),
57+ UserVerification: protocol.VerificationRequired,
58+ },
59+ AttestationPreference: protocol.PreferNoAttestation,
60+ Timeouts: gowebauthn.TimeoutsConfig{Login: timeout, Registration: timeout},
61+ })
62+ if err != nil {
63+ return nil, fmt.Errorf("passkeys: %w", err)
64+ }
65+ return &Verifier{web: web}, nil
66+}
67+
68+// owner is the single WebAuthn user.
69+type owner struct {
70+ handle []byte
71+ credentials []gowebauthn.Credential
72+}
73+
74+func (o owner) WebAuthnID() []byte { return o.handle }
75+func (o owner) WebAuthnName() string { return ownerName }
76+func (o owner) WebAuthnDisplayName() string { return ownerName }
77+func (o owner) WebAuthnCredentials() []gowebauthn.Credential { return o.credentials }
78+
79+// refuse builds the error of a failed verification: an ErrUnauthenticated whose
80+// text carries the reason, for logs. The library error is flattened to text so
81+// that its types never leave this package.
82+func refuse(what string, err error) error {
83+ return fmt.Errorf("%w: passkey %s: %v", domain.ErrUnauthenticated, what, err)
84+}
85+
86+// BeginRegistration implements ports.PasskeyVerifier.
87+func (v *Verifier) BeginRegistration(userHandle []byte, existing []domain.Passkey) ([]byte, []byte, error) {
88+ exclude := make([]protocol.CredentialDescriptor, 0, len(existing))
89+ for _, p := range existing {
90+ exclude = append(exclude, descriptor(p))
91+ }
92+ creation, session, err := v.web.BeginRegistration(owner{handle: userHandle},
93+ gowebauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
94+ gowebauthn.WithExclusions(exclude),
95+ gowebauthn.WithConveyancePreference(protocol.PreferNoAttestation),
96+ )
97+ if err != nil {
98+ return nil, nil, fmt.Errorf("begin passkey registration: %w", err)
99+ }
100+ return encode(creation, session)
101+}
102+
103+// FinishRegistration implements ports.PasskeyVerifier.
104+func (v *Verifier) FinishRegistration(userHandle []byte, ceremony, response []byte) (domain.Passkey, error) {
105+ var session gowebauthn.SessionData
106+ if err := json.Unmarshal(ceremony, &session); err != nil {
107+ return domain.Passkey{}, refuse("registration", fmt.Errorf("decode ceremony: %w", err))
108+ }
109+ parsed, err := protocol.ParseCredentialCreationResponseBytes(response)
110+ if err != nil {
111+ return domain.Passkey{}, refuse("registration", err)
112+ }
113+ cred, err := v.web.CreateCredential(owner{handle: userHandle}, session, parsed)
114+ if err != nil {
115+ return domain.Passkey{}, refuse("registration", err)
116+ }
117+ if n := len(cred.ID); n == 0 || n > domain.MaxPasskeyIDLen {
118+ return domain.Passkey{}, refuse("registration", fmt.Errorf("credential id has %d bytes", n))
119+ }
120+ transports := make([]string, 0, len(cred.Transport))
121+ for _, t := range cred.Transport {
122+ transports = append(transports, string(t))
123+ }
124+ return domain.Passkey{
125+ ID: cred.ID,
126+ PublicKey: cred.PublicKey,
127+ AttestationType: cred.AttestationType,
128+ Transports: transports,
129+ SignCount: cred.Authenticator.SignCount,
130+ BackupEligible: cred.Flags.BackupEligible,
131+ BackupState: cred.Flags.BackupState,
132+ }, nil
133+}
134+
135+// BeginLogin implements ports.PasskeyVerifier.
136+func (v *Verifier) BeginLogin() ([]byte, []byte, error) {
137+ assertion, session, err := v.web.BeginDiscoverableLogin()
138+ if err != nil {
139+ return nil, nil, fmt.Errorf("begin passkey login: %w", err)
140+ }
141+ return encode(assertion, session)
142+}
143+
144+// FinishLogin implements ports.PasskeyVerifier. The library compares the user
145+// handle of the response with userHandle.
146+//
147+//hexcheck:ignore suspicious-business-logic-in-adapter copies the counter and backup state the library verified into the stored passkey; the clone rule is the library's, the refusal is the port contract
148+func (v *Verifier) FinishLogin(userHandle []byte, ceremony, response []byte, lookup func(id []byte) (domain.Passkey, error)) (domain.Passkey, error) {
149+ var session gowebauthn.SessionData
150+ if err := json.Unmarshal(ceremony, &session); err != nil {
151+ return domain.Passkey{}, refuse("login", fmt.Errorf("decode ceremony: %w", err))
152+ }
153+ parsed, err := protocol.ParseCredentialRequestResponseBytes(response)
154+ if err != nil {
155+ return domain.Passkey{}, refuse("login", err)
156+ }
157+
158+ var stored domain.Passkey
159+ var lookupErr error // a failure of the store, as opposed to an unknown credential
160+ handler := func(rawID, _ []byte) (gowebauthn.User, error) {
161+ p, err := lookup(rawID)
162+ if err != nil {
163+ if !errors.Is(err, domain.ErrNotFound) {
164+ lookupErr = err
165+ }
166+ return nil, err
167+ }
168+ stored = p
169+ return owner{handle: userHandle, credentials: []gowebauthn.Credential{credential(p)}}, nil
170+ }
171+ _, cred, err := v.web.ValidatePasskeyLogin(handler, session, parsed)
172+ if lookupErr != nil {
173+ return domain.Passkey{}, lookupErr // fail closed, as it is
174+ }
175+ if err != nil {
176+ return domain.Passkey{}, refuse("login", err)
177+ }
178+ if cred.Authenticator.CloneWarning {
179+ return domain.Passkey{}, domain.ErrPasskeyCloned
180+ }
181+ stored.SignCount = cred.Authenticator.SignCount
182+ stored.BackupState = cred.Flags.BackupState
183+ return stored, nil
184+}
185+
186+// encode returns the JSON of the options for the browser and of the session.
187+func encode(options any, session *gowebauthn.SessionData) (opts, ceremony []byte, err error) {
188+ if opts, err = json.Marshal(options); err != nil {
189+ return nil, nil, fmt.Errorf("encode passkey options: %w", err)
190+ }
191+ if ceremony, err = json.Marshal(session); err != nil {
192+ return nil, nil, fmt.Errorf("encode passkey ceremony: %w", err)
193+ }
194+ return opts, ceremony, nil
195+}
196+
197+// credential converts a stored passkey into the library's form.
198+func credential(p domain.Passkey) gowebauthn.Credential {
199+ c := gowebauthn.Credential{
200+ ID: p.ID,
201+ PublicKey: p.PublicKey,
202+ AttestationType: p.AttestationType,
203+ Flags: gowebauthn.CredentialFlags{BackupEligible: p.BackupEligible, BackupState: p.BackupState},
204+ Authenticator: gowebauthn.Authenticator{SignCount: p.SignCount},
205+ }
206+ for _, t := range p.Transports {
207+ c.Transport = append(c.Transport, protocol.AuthenticatorTransport(t))
208+ }
209+ return c
210+}
211+
212+// descriptor names a stored passkey for the exclusion list.
213+func descriptor(p domain.Passkey) protocol.CredentialDescriptor {
214+ c := credential(p)
215+ return c.Descriptor()
216+}
A internal/adapters/webauthn/webauthn_test.go +422 -0
@@ -0,0 +1,422 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package webauthn_test
4+
5+import (
6+ "encoding/json"
7+ "errors"
8+ "testing"
9+ "time"
10+
11+ "github.com/descope/virtualwebauthn"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/adapters/webauthn"
16+ "bnema.dev/ermite/internal/domain"
17+)
18+
19+var userHandle = []byte("0123456789abcdef0123456789abcdef")
20+
21+// site is one public URL and the relying party a browser would present for it.
22+type site struct {
23+ url string
24+ rp virtualwebauthn.RelyingParty
25+}
26+
27+var sites = map[string]site{
28+ "localhost": {"http://localhost:7341", virtualwebauthn.RelyingParty{ID: "localhost", Name: "localhost", Origin: "http://localhost:7341"}},
29+ "https": {"https://git.example.test", virtualwebauthn.RelyingParty{ID: "git.example.test", Name: "git.example.test", Origin: "https://git.example.test"}},
30+}
31+
32+// device is a virtual authenticator with one credential and the passkey the
33+// server stored for it.
34+type device struct {
35+ auth virtualwebauthn.Authenticator
36+ cred virtualwebauthn.Credential
37+ store map[string]domain.Passkey // by credential id
38+}
39+
40+func (d *device) lookup(id []byte) (domain.Passkey, error) {
41+ p, ok := d.store[string(id)]
42+ if !ok {
43+ return domain.Passkey{}, domain.ErrNotFound
44+ }
45+ return p, nil
46+}
47+
48+// register runs a registration ceremony against v with a new credential.
49+func register(t *testing.T, v *webauthn.Verifier, s site, opts virtualwebauthn.AuthenticatorOptions) *device {
50+ t.Helper()
51+ opts.UserHandle = userHandle
52+ d := &device{
53+ auth: virtualwebauthn.NewAuthenticatorWithOptions(opts),
54+ cred: virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2),
55+ store: map[string]domain.Passkey{},
56+ }
57+ options, ceremony, err := v.BeginRegistration(userHandle, nil)
58+ require.NoError(t, err)
59+ parsed, err := virtualwebauthn.ParseAttestationOptions(string(options))
60+ require.NoError(t, err)
61+ response := virtualwebauthn.CreateAttestationResponse(s.rp, d.auth, d.cred, *parsed)
62+
63+ p, err := v.FinishRegistration(userHandle, ceremony, []byte(response))
64+ require.NoError(t, err)
65+ assert.Equal(t, d.cred.ID, p.ID)
66+ assert.NotEmpty(t, p.PublicKey)
67+ p.Name = "test"
68+ d.store[string(p.ID)] = p
69+ d.auth.AddCredential(d.cred)
70+ return d
71+}
72+
73+// assertion starts a login and returns the ceremony and the signed response.
74+func (d *device) assertion(t *testing.T, v *webauthn.Verifier, s site) (ceremony []byte, options *virtualwebauthn.AssertionOptions) {
75+ t.Helper()
76+ raw, ceremony, err := v.BeginLogin()
77+ require.NoError(t, err)
78+ options, err = virtualwebauthn.ParseAssertionOptions(string(raw))
79+ require.NoError(t, err)
80+ return ceremony, options
81+}
82+
83+func (d *device) respond(s site, options *virtualwebauthn.AssertionOptions) []byte {
84+ return []byte(virtualwebauthn.CreateAssertionResponse(s.rp, d.auth, d.cred, *options))
85+}
86+
87+func TestRegisterThenLogin(t *testing.T) {
88+ t.Parallel()
89+ for name, s := range sites {
90+ t.Run(name, func(t *testing.T) {
91+ t.Parallel()
92+ v, err := webauthn.New(s.url)
93+ require.NoError(t, err)
94+ d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{BackupEligible: true})
95+ d.cred.Counter = 5
96+
97+ ceremony, options := d.assertion(t, v, s)
98+ got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
99+ require.NoError(t, err)
100+ assert.Equal(t, d.cred.ID, got.ID)
101+ assert.Equal(t, "test", got.Name, "the stored fields are kept")
102+ assert.Equal(t, uint32(5), got.SignCount, "the new sign count")
103+ assert.True(t, got.BackupEligible)
104+ assert.False(t, got.BackupState)
105+ assert.Equal(t, d.store[string(got.ID)].PublicKey, got.PublicKey)
106+
107+ // The next login advances the counter again.
108+ d.store[string(got.ID)] = got
109+ d.cred.Counter = 6
110+ ceremony, options = d.assertion(t, v, s)
111+ got, err = v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
112+ require.NoError(t, err)
113+ assert.Equal(t, uint32(6), got.SignCount)
114+ })
115+ }
116+}
117+
118+func TestRegistrationReportsBackupFlags(t *testing.T) {
119+ t.Parallel()
120+ s := sites["https"]
121+ v, err := webauthn.New(s.url)
122+ require.NoError(t, err)
123+ d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{BackupEligible: true, BackupState: true,
124+ Transports: []virtualwebauthn.Transport{virtualwebauthn.TransportInternal, virtualwebauthn.TransportHybrid}})
125+ p := d.store[string(d.cred.ID)]
126+ assert.True(t, p.BackupEligible)
127+ assert.True(t, p.BackupState)
128+ assert.Equal(t, []string{"internal", "hybrid"}, p.Transports)
129+
130+ // A login reports the backup state the authenticator has now.
131+ d.auth.Options.BackupState = false
132+ ceremony, options := d.assertion(t, v, s)
133+ got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
134+ require.NoError(t, err)
135+ assert.False(t, got.BackupState)
136+}
137+
138+func TestOptions(t *testing.T) {
139+ t.Parallel()
140+ s := sites["https"]
141+ v, err := webauthn.New(s.url)
142+ require.NoError(t, err)
143+
144+ existing := domain.Passkey{ID: []byte("already-registered"), PublicKey: []byte("k"), Transports: []string{"usb"}}
145+ raw, _, err := v.BeginRegistration(userHandle, []domain.Passkey{existing})
146+ require.NoError(t, err)
147+ var creation struct {
148+ PublicKey struct {
149+ RP struct{ ID, Name string }
150+ User struct{ ID, Name string }
151+ Challenge string
152+ AuthenticatorSelection struct {
153+ ResidentKey string
154+ RequireResidentKey bool
155+ UserVerification string
156+ }
157+ Attestation string
158+ Timeout int
159+ ExcludeCredentials []struct{ ID string }
160+ }
161+ }
162+ require.NoError(t, json.Unmarshal(raw, &creation))
163+ pk := creation.PublicKey
164+ assert.Equal(t, "git.example.test", pk.RP.ID)
165+ assert.Equal(t, "owner", pk.User.Name)
166+ assert.NotEmpty(t, pk.Challenge)
167+ assert.Equal(t, "required", pk.AuthenticatorSelection.ResidentKey)
168+ assert.True(t, pk.AuthenticatorSelection.RequireResidentKey)
169+ assert.Equal(t, "required", pk.AuthenticatorSelection.UserVerification)
170+ assert.Equal(t, "none", pk.Attestation)
171+ assert.Equal(t, 120000, pk.Timeout)
172+ require.Len(t, pk.ExcludeCredentials, 1)
173+ parsed, err := virtualwebauthn.ParseAttestationOptions(string(raw))
174+ require.NoError(t, err)
175+ require.Len(t, parsed.ExcludeCredentials, 1)
176+
177+ raw, _, err = v.BeginLogin()
178+ require.NoError(t, err)
179+ var assertion struct {
180+ PublicKey struct {
181+ RPID string
182+ Challenge string
183+ UserVerification string
184+ Timeout int
185+ AllowCredentials []struct{ ID string }
186+ }
187+ }
188+ require.NoError(t, json.Unmarshal(raw, &assertion))
189+ assert.Equal(t, "git.example.test", assertion.PublicKey.RPID)
190+ assert.NotEmpty(t, assertion.PublicKey.Challenge)
191+ assert.Equal(t, "required", assertion.PublicKey.UserVerification, "login inherits user verification")
192+ assert.Equal(t, 120000, assertion.PublicKey.Timeout)
193+ assert.Empty(t, assertion.PublicKey.AllowCredentials, "a discoverable login lists no credential")
194+}
195+
196+func TestLoginRefused(t *testing.T) {
197+ t.Parallel()
198+ s := sites["localhost"]
199+ v, err := webauthn.New(s.url)
200+ require.NoError(t, err)
201+
202+ tests := []struct {
203+ name string
204+ // run returns the error of FinishLogin.
205+ run func(t *testing.T, d *device) error
206+ }{
207+ {"wrong origin", func(t *testing.T, d *device) error {
208+ ceremony, options := d.assertion(t, v, s)
209+ other := s
210+ other.rp.Origin = "http://localhost:9999"
211+ return finish(v, ceremony, d.respond(other, options), d)
212+ }},
213+ {"wrong RP ID", func(t *testing.T, d *device) error {
214+ ceremony, options := d.assertion(t, v, s)
215+ other := s
216+ other.rp.ID = "evil.example.test"
217+ return finish(v, ceremony, d.respond(other, options), d)
218+ }},
219+ {"tampered challenge", func(t *testing.T, d *device) error {
220+ ceremony, options := d.assertion(t, v, s)
221+ options.Challenge[0] ^= 0xff
222+ return finish(v, ceremony, d.respond(s, options), d)
223+ }},
224+ {"challenge of another ceremony", func(t *testing.T, d *device) error {
225+ ceremony, _ := d.assertion(t, v, s)
226+ _, other := d.assertion(t, v, s)
227+ return finish(v, ceremony, d.respond(s, other), d)
228+ }},
229+ {"expired ceremony", func(t *testing.T, d *device) error {
230+ ceremony, options := d.assertion(t, v, s)
231+ return finish(v, setExpires(t, ceremony, time.Now().Add(-time.Second)), d.respond(s, options), d)
232+ }},
233+ {"garbage ceremony", func(t *testing.T, d *device) error {
234+ _, options := d.assertion(t, v, s)
235+ return finish(v, []byte("not json"), d.respond(s, options), d)
236+ }},
237+ {"garbage response", func(t *testing.T, d *device) error {
238+ ceremony, _ := d.assertion(t, v, s)
239+ return finish(v, ceremony, []byte("{}"), d)
240+ }},
241+ {"user not verified", func(t *testing.T, d *device) error {
242+ d.auth.Options.UserNotVerified = true
243+ ceremony, options := d.assertion(t, v, s)
244+ return finish(v, ceremony, d.respond(s, options), d)
245+ }},
246+ {"user not present", func(t *testing.T, d *device) error {
247+ d.auth.Options.UserNotPresent = true
248+ ceremony, options := d.assertion(t, v, s)
249+ return finish(v, ceremony, d.respond(s, options), d)
250+ }},
251+ {"unknown credential", func(t *testing.T, d *device) error {
252+ ceremony, options := d.assertion(t, v, s)
253+ delete(d.store, string(d.cred.ID))
254+ return finish(v, ceremony, d.respond(s, options), d)
255+ }},
256+ {"other user handle", func(t *testing.T, d *device) error {
257+ d.auth.Options.UserHandle = []byte("a-handle-of-somebody-else-------")
258+ ceremony, options := d.assertion(t, v, s)
259+ return finish(v, ceremony, d.respond(s, options), d)
260+ }},
261+ {"no user handle", func(t *testing.T, d *device) error {
262+ d.auth.Options.UserHandle = nil
263+ ceremony, options := d.assertion(t, v, s)
264+ return finish(v, ceremony, d.respond(s, options), d)
265+ }},
266+ {"registration ceremony used for a login", func(t *testing.T, d *device) error {
267+ _, ceremony, err := v.BeginRegistration(userHandle, nil)
268+ require.NoError(t, err)
269+ _, options := d.assertion(t, v, s)
270+ return finish(v, ceremony, d.respond(s, options), d)
271+ }},
272+ }
273+ for _, tc := range tests {
274+ t.Run(tc.name, func(t *testing.T) {
275+ t.Parallel()
276+ d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{})
277+ err := tc.run(t, d)
278+ require.Error(t, err)
279+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
280+ assert.NotErrorIs(t, err, domain.ErrPasskeyCloned)
281+ })
282+ }
283+}
284+
285+func finish(v *webauthn.Verifier, ceremony, response []byte, d *device) error {
286+ _, err := v.FinishLogin(userHandle, ceremony, response, d.lookup)
287+ return err
288+}
289+
290+// setExpires rewrites the expiry of a ceremony the way a late finish would see
291+// it, since the library checks it against the wall clock.
292+func setExpires(t *testing.T, ceremony []byte, at time.Time) []byte {
293+ t.Helper()
294+ var m map[string]any
295+ require.NoError(t, json.Unmarshal(ceremony, &m))
296+ require.Contains(t, m, "expires")
297+ m["expires"] = at
298+ out, err := json.Marshal(m)
299+ require.NoError(t, err)
300+ return out
301+}
302+
303+func TestLoginStoreFailureIsNotAnUnauthenticated(t *testing.T) {
304+ t.Parallel()
305+ s := sites["localhost"]
306+ v, err := webauthn.New(s.url)
307+ require.NoError(t, err)
308+ d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{})
309+ ceremony, options := d.assertion(t, v, s)
310+ boom := errors.New("disk on fire")
311+ _, err = v.FinishLogin(userHandle, ceremony, d.respond(s, options), func([]byte) (domain.Passkey, error) {
312+ return domain.Passkey{}, boom
313+ })
314+ require.ErrorIs(t, err, boom)
315+ assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
316+}
317+
318+func TestClonedAuthenticator(t *testing.T) {
319+ t.Parallel()
320+ s := sites["localhost"]
321+ v, err := webauthn.New(s.url)
322+ require.NoError(t, err)
323+ d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{})
324+
325+ d.cred.Counter = 1
326+ ceremony, options := d.assertion(t, v, s)
327+ got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
328+ require.NoError(t, err)
329+ assert.Equal(t, uint32(1), got.SignCount)
330+ d.store[string(got.ID)] = got
331+
332+ // The counter did not advance: a copy of the key is in use.
333+ ceremony, options = d.assertion(t, v, s)
334+ _, err = v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
335+ require.ErrorIs(t, err, domain.ErrPasskeyCloned)
336+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
337+}
338+
339+// Synced passkeys always report 0: that is not a clone.
340+func TestZeroCounterIsNotAClone(t *testing.T) {
341+ t.Parallel()
342+ s := sites["localhost"]
343+ v, err := webauthn.New(s.url)
344+ require.NoError(t, err)
345+ d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{BackupEligible: true, BackupState: true})
346+ for range 2 {
347+ ceremony, options := d.assertion(t, v, s)
348+ got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
349+ require.NoError(t, err)
350+ assert.Zero(t, got.SignCount)
351+ }
352+}
353+
354+func TestRegistrationRefused(t *testing.T) {
355+ t.Parallel()
356+ s := sites["https"]
357+ v, err := webauthn.New(s.url)
358+ require.NoError(t, err)
359+
360+ tests := []struct {
361+ name string
362+ auth virtualwebauthn.AuthenticatorOptions
363+ rp func(rp *virtualwebauthn.RelyingParty)
364+ // ceremony replaces the ceremony data when set.
365+ ceremony func(t *testing.T, ceremony []byte) []byte
366+ handle []byte
367+ }{
368+ {name: "wrong origin", rp: func(rp *virtualwebauthn.RelyingParty) { rp.Origin = "https://evil.example.test" }},
369+ {name: "wrong RP ID", rp: func(rp *virtualwebauthn.RelyingParty) { rp.ID = "evil.example.test" }},
370+ {name: "user not verified", auth: virtualwebauthn.AuthenticatorOptions{UserNotVerified: true}},
371+ {name: "expired ceremony", ceremony: func(t *testing.T, c []byte) []byte { return setExpires(t, c, time.Now().Add(-time.Second)) }},
372+ {name: "other user", handle: []byte("somebody-else-------------------")},
373+ }
374+ for _, tc := range tests {
375+ t.Run(tc.name, func(t *testing.T) {
376+ t.Parallel()
377+ rp := s.rp
378+ if tc.rp != nil {
379+ tc.rp(&rp)
380+ }
381+ auth := virtualwebauthn.NewAuthenticatorWithOptions(tc.auth)
382+ cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
383+ options, ceremony, err := v.BeginRegistration(userHandle, nil)
384+ require.NoError(t, err)
385+ parsed, err := virtualwebauthn.ParseAttestationOptions(string(options))
386+ require.NoError(t, err)
387+ response := virtualwebauthn.CreateAttestationResponse(rp, auth, cred, *parsed)
388+ if tc.ceremony != nil {
389+ ceremony = tc.ceremony(t, ceremony)
390+ }
391+ handle := userHandle
392+ if tc.handle != nil {
393+ handle = tc.handle
394+ }
395+ _, err = v.FinishRegistration(handle, ceremony, []byte(response))
396+ require.Error(t, err)
397+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
398+ })
399+ }
400+
401+ t.Run("garbage", func(t *testing.T) {
402+ t.Parallel()
403+ _, ceremony, err := v.BeginRegistration(userHandle, nil)
404+ require.NoError(t, err)
405+ _, err = v.FinishRegistration(userHandle, ceremony, []byte("{"))
406+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
407+ _, err = v.FinishRegistration(userHandle, []byte("{"), []byte("{}"))
408+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
409+ })
410+}
411+
412+func TestNew(t *testing.T) {
413+ t.Parallel()
414+ for _, ok := range []string{"https://git.example.com", "http://localhost:7341", "https://Git.Example.com:8443/"} {
415+ _, err := webauthn.New(ok)
416+ assert.NoError(t, err, ok)
417+ }
418+ for _, bad := range []string{"http://127.0.0.1:7341", "http://[::1]:7341", "git.example.com", "ftp://git.example.com", "", "https://"} {
419+ _, err := webauthn.New(bad)
420+ assert.Error(t, err, bad)
421+ }
422+}
@@ -0,0 +1,422 @@
1+// SPDX-License-Identifier: AGPL-3.0-or-later
2+
3+package webauthn_test
4+
5+import (
6+ "encoding/json"
7+ "errors"
8+ "testing"
9+ "time"
10+
11+ "github.com/descope/virtualwebauthn"
12+ "github.com/stretchr/testify/assert"
13+ "github.com/stretchr/testify/require"
14+
15+ "bnema.dev/ermite/internal/adapters/webauthn"
16+ "bnema.dev/ermite/internal/domain"
17+)
18+
19+var userHandle = []byte("0123456789abcdef0123456789abcdef")
20+
21+// site is one public URL and the relying party a browser would present for it.
22+type site struct {
23+ url string
24+ rp virtualwebauthn.RelyingParty
25+}
26+
27+var sites = map[string]site{
28+ "localhost": {"http://localhost:7341", virtualwebauthn.RelyingParty{ID: "localhost", Name: "localhost", Origin: "http://localhost:7341"}},
29+ "https": {"https://git.example.test", virtualwebauthn.RelyingParty{ID: "git.example.test", Name: "git.example.test", Origin: "https://git.example.test"}},
30+}
31+
32+// device is a virtual authenticator with one credential and the passkey the
33+// server stored for it.
34+type device struct {
35+ auth virtualwebauthn.Authenticator
36+ cred virtualwebauthn.Credential
37+ store map[string]domain.Passkey // by credential id
38+}
39+
40+func (d *device) lookup(id []byte) (domain.Passkey, error) {
41+ p, ok := d.store[string(id)]
42+ if !ok {
43+ return domain.Passkey{}, domain.ErrNotFound
44+ }
45+ return p, nil
46+}
47+
48+// register runs a registration ceremony against v with a new credential.
49+func register(t *testing.T, v *webauthn.Verifier, s site, opts virtualwebauthn.AuthenticatorOptions) *device {
50+ t.Helper()
51+ opts.UserHandle = userHandle
52+ d := &device{
53+ auth: virtualwebauthn.NewAuthenticatorWithOptions(opts),
54+ cred: virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2),
55+ store: map[string]domain.Passkey{},
56+ }
57+ options, ceremony, err := v.BeginRegistration(userHandle, nil)
58+ require.NoError(t, err)
59+ parsed, err := virtualwebauthn.ParseAttestationOptions(string(options))
60+ require.NoError(t, err)
61+ response := virtualwebauthn.CreateAttestationResponse(s.rp, d.auth, d.cred, *parsed)
62+
63+ p, err := v.FinishRegistration(userHandle, ceremony, []byte(response))
64+ require.NoError(t, err)
65+ assert.Equal(t, d.cred.ID, p.ID)
66+ assert.NotEmpty(t, p.PublicKey)
67+ p.Name = "test"
68+ d.store[string(p.ID)] = p
69+ d.auth.AddCredential(d.cred)
70+ return d
71+}
72+
73+// assertion starts a login and returns the ceremony and the signed response.
74+func (d *device) assertion(t *testing.T, v *webauthn.Verifier, s site) (ceremony []byte, options *virtualwebauthn.AssertionOptions) {
75+ t.Helper()
76+ raw, ceremony, err := v.BeginLogin()
77+ require.NoError(t, err)
78+ options, err = virtualwebauthn.ParseAssertionOptions(string(raw))
79+ require.NoError(t, err)
80+ return ceremony, options
81+}
82+
83+func (d *device) respond(s site, options *virtualwebauthn.AssertionOptions) []byte {
84+ return []byte(virtualwebauthn.CreateAssertionResponse(s.rp, d.auth, d.cred, *options))
85+}
86+
87+func TestRegisterThenLogin(t *testing.T) {
88+ t.Parallel()
89+ for name, s := range sites {
90+ t.Run(name, func(t *testing.T) {
91+ t.Parallel()
92+ v, err := webauthn.New(s.url)
93+ require.NoError(t, err)
94+ d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{BackupEligible: true})
95+ d.cred.Counter = 5
96+
97+ ceremony, options := d.assertion(t, v, s)
98+ got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
99+ require.NoError(t, err)
100+ assert.Equal(t, d.cred.ID, got.ID)
101+ assert.Equal(t, "test", got.Name, "the stored fields are kept")
102+ assert.Equal(t, uint32(5), got.SignCount, "the new sign count")
103+ assert.True(t, got.BackupEligible)
104+ assert.False(t, got.BackupState)
105+ assert.Equal(t, d.store[string(got.ID)].PublicKey, got.PublicKey)
106+
107+ // The next login advances the counter again.
108+ d.store[string(got.ID)] = got
109+ d.cred.Counter = 6
110+ ceremony, options = d.assertion(t, v, s)
111+ got, err = v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
112+ require.NoError(t, err)
113+ assert.Equal(t, uint32(6), got.SignCount)
114+ })
115+ }
116+}
117+
118+func TestRegistrationReportsBackupFlags(t *testing.T) {
119+ t.Parallel()
120+ s := sites["https"]
121+ v, err := webauthn.New(s.url)
122+ require.NoError(t, err)
123+ d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{BackupEligible: true, BackupState: true,
124+ Transports: []virtualwebauthn.Transport{virtualwebauthn.TransportInternal, virtualwebauthn.TransportHybrid}})
125+ p := d.store[string(d.cred.ID)]
126+ assert.True(t, p.BackupEligible)
127+ assert.True(t, p.BackupState)
128+ assert.Equal(t, []string{"internal", "hybrid"}, p.Transports)
129+
130+ // A login reports the backup state the authenticator has now.
131+ d.auth.Options.BackupState = false
132+ ceremony, options := d.assertion(t, v, s)
133+ got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
134+ require.NoError(t, err)
135+ assert.False(t, got.BackupState)
136+}
137+
138+func TestOptions(t *testing.T) {
139+ t.Parallel()
140+ s := sites["https"]
141+ v, err := webauthn.New(s.url)
142+ require.NoError(t, err)
143+
144+ existing := domain.Passkey{ID: []byte("already-registered"), PublicKey: []byte("k"), Transports: []string{"usb"}}
145+ raw, _, err := v.BeginRegistration(userHandle, []domain.Passkey{existing})
146+ require.NoError(t, err)
147+ var creation struct {
148+ PublicKey struct {
149+ RP struct{ ID, Name string }
150+ User struct{ ID, Name string }
151+ Challenge string
152+ AuthenticatorSelection struct {
153+ ResidentKey string
154+ RequireResidentKey bool
155+ UserVerification string
156+ }
157+ Attestation string
158+ Timeout int
159+ ExcludeCredentials []struct{ ID string }
160+ }
161+ }
162+ require.NoError(t, json.Unmarshal(raw, &creation))
163+ pk := creation.PublicKey
164+ assert.Equal(t, "git.example.test", pk.RP.ID)
165+ assert.Equal(t, "owner", pk.User.Name)
166+ assert.NotEmpty(t, pk.Challenge)
167+ assert.Equal(t, "required", pk.AuthenticatorSelection.ResidentKey)
168+ assert.True(t, pk.AuthenticatorSelection.RequireResidentKey)
169+ assert.Equal(t, "required", pk.AuthenticatorSelection.UserVerification)
170+ assert.Equal(t, "none", pk.Attestation)
171+ assert.Equal(t, 120000, pk.Timeout)
172+ require.Len(t, pk.ExcludeCredentials, 1)
173+ parsed, err := virtualwebauthn.ParseAttestationOptions(string(raw))
174+ require.NoError(t, err)
175+ require.Len(t, parsed.ExcludeCredentials, 1)
176+
177+ raw, _, err = v.BeginLogin()
178+ require.NoError(t, err)
179+ var assertion struct {
180+ PublicKey struct {
181+ RPID string
182+ Challenge string
183+ UserVerification string
184+ Timeout int
185+ AllowCredentials []struct{ ID string }
186+ }
187+ }
188+ require.NoError(t, json.Unmarshal(raw, &assertion))
189+ assert.Equal(t, "git.example.test", assertion.PublicKey.RPID)
190+ assert.NotEmpty(t, assertion.PublicKey.Challenge)
191+ assert.Equal(t, "required", assertion.PublicKey.UserVerification, "login inherits user verification")
192+ assert.Equal(t, 120000, assertion.PublicKey.Timeout)
193+ assert.Empty(t, assertion.PublicKey.AllowCredentials, "a discoverable login lists no credential")
194+}
195+
196+func TestLoginRefused(t *testing.T) {
197+ t.Parallel()
198+ s := sites["localhost"]
199+ v, err := webauthn.New(s.url)
200+ require.NoError(t, err)
201+
202+ tests := []struct {
203+ name string
204+ // run returns the error of FinishLogin.
205+ run func(t *testing.T, d *device) error
206+ }{
207+ {"wrong origin", func(t *testing.T, d *device) error {
208+ ceremony, options := d.assertion(t, v, s)
209+ other := s
210+ other.rp.Origin = "http://localhost:9999"
211+ return finish(v, ceremony, d.respond(other, options), d)
212+ }},
213+ {"wrong RP ID", func(t *testing.T, d *device) error {
214+ ceremony, options := d.assertion(t, v, s)
215+ other := s
216+ other.rp.ID = "evil.example.test"
217+ return finish(v, ceremony, d.respond(other, options), d)
218+ }},
219+ {"tampered challenge", func(t *testing.T, d *device) error {
220+ ceremony, options := d.assertion(t, v, s)
221+ options.Challenge[0] ^= 0xff
222+ return finish(v, ceremony, d.respond(s, options), d)
223+ }},
224+ {"challenge of another ceremony", func(t *testing.T, d *device) error {
225+ ceremony, _ := d.assertion(t, v, s)
226+ _, other := d.assertion(t, v, s)
227+ return finish(v, ceremony, d.respond(s, other), d)
228+ }},
229+ {"expired ceremony", func(t *testing.T, d *device) error {
230+ ceremony, options := d.assertion(t, v, s)
231+ return finish(v, setExpires(t, ceremony, time.Now().Add(-time.Second)), d.respond(s, options), d)
232+ }},
233+ {"garbage ceremony", func(t *testing.T, d *device) error {
234+ _, options := d.assertion(t, v, s)
235+ return finish(v, []byte("not json"), d.respond(s, options), d)
236+ }},
237+ {"garbage response", func(t *testing.T, d *device) error {
238+ ceremony, _ := d.assertion(t, v, s)
239+ return finish(v, ceremony, []byte("{}"), d)
240+ }},
241+ {"user not verified", func(t *testing.T, d *device) error {
242+ d.auth.Options.UserNotVerified = true
243+ ceremony, options := d.assertion(t, v, s)
244+ return finish(v, ceremony, d.respond(s, options), d)
245+ }},
246+ {"user not present", func(t *testing.T, d *device) error {
247+ d.auth.Options.UserNotPresent = true
248+ ceremony, options := d.assertion(t, v, s)
249+ return finish(v, ceremony, d.respond(s, options), d)
250+ }},
251+ {"unknown credential", func(t *testing.T, d *device) error {
252+ ceremony, options := d.assertion(t, v, s)
253+ delete(d.store, string(d.cred.ID))
254+ return finish(v, ceremony, d.respond(s, options), d)
255+ }},
256+ {"other user handle", func(t *testing.T, d *device) error {
257+ d.auth.Options.UserHandle = []byte("a-handle-of-somebody-else-------")
258+ ceremony, options := d.assertion(t, v, s)
259+ return finish(v, ceremony, d.respond(s, options), d)
260+ }},
261+ {"no user handle", func(t *testing.T, d *device) error {
262+ d.auth.Options.UserHandle = nil
263+ ceremony, options := d.assertion(t, v, s)
264+ return finish(v, ceremony, d.respond(s, options), d)
265+ }},
266+ {"registration ceremony used for a login", func(t *testing.T, d *device) error {
267+ _, ceremony, err := v.BeginRegistration(userHandle, nil)
268+ require.NoError(t, err)
269+ _, options := d.assertion(t, v, s)
270+ return finish(v, ceremony, d.respond(s, options), d)
271+ }},
272+ }
273+ for _, tc := range tests {
274+ t.Run(tc.name, func(t *testing.T) {
275+ t.Parallel()
276+ d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{})
277+ err := tc.run(t, d)
278+ require.Error(t, err)
279+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
280+ assert.NotErrorIs(t, err, domain.ErrPasskeyCloned)
281+ })
282+ }
283+}
284+
285+func finish(v *webauthn.Verifier, ceremony, response []byte, d *device) error {
286+ _, err := v.FinishLogin(userHandle, ceremony, response, d.lookup)
287+ return err
288+}
289+
290+// setExpires rewrites the expiry of a ceremony the way a late finish would see
291+// it, since the library checks it against the wall clock.
292+func setExpires(t *testing.T, ceremony []byte, at time.Time) []byte {
293+ t.Helper()
294+ var m map[string]any
295+ require.NoError(t, json.Unmarshal(ceremony, &m))
296+ require.Contains(t, m, "expires")
297+ m["expires"] = at
298+ out, err := json.Marshal(m)
299+ require.NoError(t, err)
300+ return out
301+}
302+
303+func TestLoginStoreFailureIsNotAnUnauthenticated(t *testing.T) {
304+ t.Parallel()
305+ s := sites["localhost"]
306+ v, err := webauthn.New(s.url)
307+ require.NoError(t, err)
308+ d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{})
309+ ceremony, options := d.assertion(t, v, s)
310+ boom := errors.New("disk on fire")
311+ _, err = v.FinishLogin(userHandle, ceremony, d.respond(s, options), func([]byte) (domain.Passkey, error) {
312+ return domain.Passkey{}, boom
313+ })
314+ require.ErrorIs(t, err, boom)
315+ assert.NotErrorIs(t, err, domain.ErrUnauthenticated)
316+}
317+
318+func TestClonedAuthenticator(t *testing.T) {
319+ t.Parallel()
320+ s := sites["localhost"]
321+ v, err := webauthn.New(s.url)
322+ require.NoError(t, err)
323+ d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{})
324+
325+ d.cred.Counter = 1
326+ ceremony, options := d.assertion(t, v, s)
327+ got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
328+ require.NoError(t, err)
329+ assert.Equal(t, uint32(1), got.SignCount)
330+ d.store[string(got.ID)] = got
331+
332+ // The counter did not advance: a copy of the key is in use.
333+ ceremony, options = d.assertion(t, v, s)
334+ _, err = v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
335+ require.ErrorIs(t, err, domain.ErrPasskeyCloned)
336+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
337+}
338+
339+// Synced passkeys always report 0: that is not a clone.
340+func TestZeroCounterIsNotAClone(t *testing.T) {
341+ t.Parallel()
342+ s := sites["localhost"]
343+ v, err := webauthn.New(s.url)
344+ require.NoError(t, err)
345+ d := register(t, v, s, virtualwebauthn.AuthenticatorOptions{BackupEligible: true, BackupState: true})
346+ for range 2 {
347+ ceremony, options := d.assertion(t, v, s)
348+ got, err := v.FinishLogin(userHandle, ceremony, d.respond(s, options), d.lookup)
349+ require.NoError(t, err)
350+ assert.Zero(t, got.SignCount)
351+ }
352+}
353+
354+func TestRegistrationRefused(t *testing.T) {
355+ t.Parallel()
356+ s := sites["https"]
357+ v, err := webauthn.New(s.url)
358+ require.NoError(t, err)
359+
360+ tests := []struct {
361+ name string
362+ auth virtualwebauthn.AuthenticatorOptions
363+ rp func(rp *virtualwebauthn.RelyingParty)
364+ // ceremony replaces the ceremony data when set.
365+ ceremony func(t *testing.T, ceremony []byte) []byte
366+ handle []byte
367+ }{
368+ {name: "wrong origin", rp: func(rp *virtualwebauthn.RelyingParty) { rp.Origin = "https://evil.example.test" }},
369+ {name: "wrong RP ID", rp: func(rp *virtualwebauthn.RelyingParty) { rp.ID = "evil.example.test" }},
370+ {name: "user not verified", auth: virtualwebauthn.AuthenticatorOptions{UserNotVerified: true}},
371+ {name: "expired ceremony", ceremony: func(t *testing.T, c []byte) []byte { return setExpires(t, c, time.Now().Add(-time.Second)) }},
372+ {name: "other user", handle: []byte("somebody-else-------------------")},
373+ }
374+ for _, tc := range tests {
375+ t.Run(tc.name, func(t *testing.T) {
376+ t.Parallel()
377+ rp := s.rp
378+ if tc.rp != nil {
379+ tc.rp(&rp)
380+ }
381+ auth := virtualwebauthn.NewAuthenticatorWithOptions(tc.auth)
382+ cred := virtualwebauthn.NewCredential(virtualwebauthn.KeyTypeEC2)
383+ options, ceremony, err := v.BeginRegistration(userHandle, nil)
384+ require.NoError(t, err)
385+ parsed, err := virtualwebauthn.ParseAttestationOptions(string(options))
386+ require.NoError(t, err)
387+ response := virtualwebauthn.CreateAttestationResponse(rp, auth, cred, *parsed)
388+ if tc.ceremony != nil {
389+ ceremony = tc.ceremony(t, ceremony)
390+ }
391+ handle := userHandle
392+ if tc.handle != nil {
393+ handle = tc.handle
394+ }
395+ _, err = v.FinishRegistration(handle, ceremony, []byte(response))
396+ require.Error(t, err)
397+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
398+ })
399+ }
400+
401+ t.Run("garbage", func(t *testing.T) {
402+ t.Parallel()
403+ _, ceremony, err := v.BeginRegistration(userHandle, nil)
404+ require.NoError(t, err)
405+ _, err = v.FinishRegistration(userHandle, ceremony, []byte("{"))
406+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
407+ _, err = v.FinishRegistration(userHandle, []byte("{"), []byte("{}"))
408+ assert.ErrorIs(t, err, domain.ErrUnauthenticated)
409+ })
410+}
411+
412+func TestNew(t *testing.T) {
413+ t.Parallel()
414+ for _, ok := range []string{"https://git.example.com", "http://localhost:7341", "https://Git.Example.com:8443/"} {
415+ _, err := webauthn.New(ok)
416+ assert.NoError(t, err, ok)
417+ }
418+ for _, bad := range []string{"http://127.0.0.1:7341", "http://[::1]:7341", "git.example.com", "ftp://git.example.com", "", "https://"} {
419+ _, err := webauthn.New(bad)
420+ assert.Error(t, err, bad)
421+ }
422+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close