feat(webauthn): add the passkey verifier adapter over go-webauthn
- commit
- 3246333677a3c852fd33f6f1bb08c7a7952dacba
- author
- brice · · 2026-10-12 14:52 UTC
- parent
- dbbae13
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +666 -2
M go.mod +8 -0
| @@ -19,6 +19,8 @@ require ( | |||
| 19 | 19 | github.com/a-h/templ v0.3.1020 | |
| 20 | 20 | github.com/alecthomas/chroma/v2 v2.27.0 | |
| 21 | 21 | 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 | 24 | github.com/golang-jwt/jwt/v5 v5.3.1 | |
| 23 | 25 | github.com/spf13/cobra v1.10.2 | |
| 24 | 26 | github.com/stretchr/testify v1.12.1 | |
| @@ -78,14 +80,17 @@ require ( | |||
| 78 | 80 | github.com/fatih/structs v1.1.0 // indirect | |
| 79 | 81 | github.com/felixge/httpsnoop v1.1.0 // indirect | |
| 80 | 82 | github.com/fsnotify/fsnotify v1.9.0 // indirect | |
| 83 | + | github.com/fxamacker/cbor/v2 v2.9.4 // indirect | |
| 81 | 84 | github.com/go-logr/logr v1.4.4 // indirect | |
| 82 | 85 | github.com/go-logr/stdr v1.2.2 // indirect | |
| 83 | 86 | github.com/go-viper/mapstructure/v2 v2.5.0 // indirect | |
| 87 | + | github.com/go-webauthn/x v0.3.1 // indirect | |
| 84 | 88 | github.com/gobwas/glob v0.2.3 // indirect | |
| 85 | 89 | github.com/gofrs/flock v0.13.1 // indirect | |
| 86 | 90 | github.com/gohugoio/hashstructure v0.6.0 // indirect | |
| 87 | 91 | github.com/gohugoio/hugo v0.164.0 // indirect | |
| 88 | 92 | github.com/google/go-containerregistry v0.22.1 // indirect | |
| 93 | + | github.com/google/go-tpm v0.9.8 // indirect | |
| 89 | 94 | github.com/google/uuid v1.6.0 // indirect | |
| 90 | 95 | github.com/huandu/xstrings v1.5.0 // indirect | |
| 91 | 96 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| @@ -115,6 +120,7 @@ require ( | |||
| 115 | 120 | github.com/pelletier/go-toml v1.9.5 // indirect | |
| 116 | 121 | github.com/pelletier/go-toml/v2 v2.4.3 // indirect | |
| 117 | 122 | github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect | |
| 123 | + | github.com/philhofer/fwd v1.2.0 // indirect | |
| 118 | 124 | github.com/quic-go/qpack v0.6.0 // indirect | |
| 119 | 125 | github.com/quic-go/quic-go v0.62.0 // indirect | |
| 120 | 126 | github.com/rivo/uniseg v0.4.7 // indirect | |
| @@ -131,7 +137,9 @@ require ( | |||
| 131 | 137 | github.com/tdewolff/parse/v2 v2.8.12 // indirect | |
| 132 | 138 | github.com/tetratelabs/wazero v1.12.0 // indirect | |
| 133 | 139 | github.com/tidwall/btree v1.8.1 // indirect | |
| 140 | + | github.com/tinylib/msgp v1.6.4 // indirect | |
| 134 | 141 | github.com/vektra/mockery/v3 v3.8.0 // indirect | |
| 142 | + | github.com/x448/float16 v0.8.4 // indirect | |
| 135 | 143 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect | |
| 136 | 144 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect | |
| 137 | 145 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect | |
| @@ -19,6 +19,8 @@ require ( | |||||
| 19 | github.com/a-h/templ v0.3.1020 | 19 | github.com/a-h/templ v0.3.1020 | ||
| 20 | github.com/alecthomas/chroma/v2 v2.27.0 | 20 | github.com/alecthomas/chroma/v2 v2.27.0 | ||
| 21 | github.com/bnema/zerowrap v1.4.1 | 21 | 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.1 | 24 | github.com/golang-jwt/jwt/v5 v5.3.1 | ||
| 23 | github.com/spf13/cobra v1.10.2 | 25 | github.com/spf13/cobra v1.10.2 | ||
| 24 | github.com/stretchr/testify v1.12.1 | 26 | github.com/stretchr/testify v1.12.1 | ||
| @@ -78,14 +80,17 @@ require ( | |||||
| 78 | github.com/fatih/structs v1.1.0 // indirect | 80 | github.com/fatih/structs v1.1.0 // indirect | ||
| 79 | github.com/felixge/httpsnoop v1.1.0 // indirect | 81 | github.com/felixge/httpsnoop v1.1.0 // indirect | ||
| 80 | github.com/fsnotify/fsnotify v1.9.0 // indirect | 82 | 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 // indirect | 84 | github.com/go-logr/logr v1.4.4 // indirect | ||
| 82 | github.com/go-logr/stdr v1.2.2 // indirect | 85 | github.com/go-logr/stdr v1.2.2 // indirect | ||
| 83 | github.com/go-viper/mapstructure/v2 v2.5.0 // indirect | 86 | 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 // indirect | 88 | github.com/gobwas/glob v0.2.3 // indirect | ||
| 85 | github.com/gofrs/flock v0.13.1 // indirect | 89 | github.com/gofrs/flock v0.13.1 // indirect | ||
| 86 | github.com/gohugoio/hashstructure v0.6.0 // indirect | 90 | github.com/gohugoio/hashstructure v0.6.0 // indirect | ||
| 87 | github.com/gohugoio/hugo v0.164.0 // indirect | 91 | github.com/gohugoio/hugo v0.164.0 // indirect | ||
| 88 | github.com/google/go-containerregistry v0.22.1 // indirect | 92 | 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 // indirect | 94 | github.com/google/uuid v1.6.0 // indirect | ||
| 90 | github.com/huandu/xstrings v1.5.0 // indirect | 95 | github.com/huandu/xstrings v1.5.0 // indirect | ||
| 91 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | 96 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||
| @@ -115,6 +120,7 @@ require ( | |||||
| 115 | github.com/pelletier/go-toml v1.9.5 // indirect | 120 | github.com/pelletier/go-toml v1.9.5 // indirect | ||
| 116 | github.com/pelletier/go-toml/v2 v2.4.3 // indirect | 121 | github.com/pelletier/go-toml/v2 v2.4.3 // indirect | ||
| 117 | github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 // indirect | 122 | 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 // indirect | 124 | github.com/quic-go/qpack v0.6.0 // indirect | ||
| 119 | github.com/quic-go/quic-go v0.62.0 // indirect | 125 | github.com/quic-go/quic-go v0.62.0 // indirect | ||
| 120 | github.com/rivo/uniseg v0.4.7 // indirect | 126 | github.com/rivo/uniseg v0.4.7 // indirect | ||
| @@ -131,7 +137,9 @@ require ( | |||||
| 131 | github.com/tdewolff/parse/v2 v2.8.12 // indirect | 137 | github.com/tdewolff/parse/v2 v2.8.12 // indirect | ||
| 132 | github.com/tetratelabs/wazero v1.12.0 // indirect | 138 | github.com/tetratelabs/wazero v1.12.0 // indirect | ||
| 133 | github.com/tidwall/btree v1.8.1 // indirect | 139 | 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 // indirect | 141 | 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 // indirect | 143 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect | ||
| 136 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect | 144 | github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect | ||
| 137 | github.com/xeipuuv/gojsonschema v1.2.0 // indirect | 145 | 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 | |||
| 138 | 138 | github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= | |
| 139 | 139 | github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | |
| 140 | 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 | 143 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= | |
| 142 | 144 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= | |
| 143 | 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 | 165 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= | |
| 164 | 166 | github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= | |
| 165 | 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 | 170 | github.com/getkin/kin-openapi v0.140.0 h1:JFn675aXRFjyiZKa/BFWploGldQlI0gobp4J5k0EZ2g= | |
| 167 | 171 | github.com/getkin/kin-openapi v0.140.0/go.mod h1:lISrB64F0CPcuDJ3LdtPTMJBY8VENjR9wJBdrcT6J3g= | |
| 168 | 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 | 180 | github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= | |
| 177 | 181 | github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= | |
| 178 | 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 | 187 | github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= | |
| 180 | 188 | github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= | |
| 181 | 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 | 216 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= | |
| 209 | 217 | github.com/google/go-containerregistry v0.22.1 h1:RZuuSYhTvlDvtsK+NkutoCZ//C0X2ebLK8X8l3ULs84= | |
| 210 | 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 | 223 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= | |
| 212 | 224 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= | |
| 213 | 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 | 325 | github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= | |
| 314 | 326 | github.com/petermattis/goid v0.0.0-20260820044319-269ab09b5261 h1:lcWAnrqr2nNfDiArwFNHCE4787Mw2tCdVSOXCru0/0E= | |
| 315 | 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 | 330 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= | |
| 317 | 331 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= | |
| 318 | 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 | 386 | github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= | |
| 373 | 387 | github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA= | |
| 374 | 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 | 391 | github.com/vektra/mockery/v3 v3.8.0 h1:Tklqcx844iV8GtYziqJXam1a8RdcKip4UErMa66Nxps= | |
| 376 | 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 | 395 | github.com/xeipuuv/gojsonpointer v0.0.0-20180127040702-4e3ac2762d5f/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= | |
| 378 | 396 | github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb h1:zGWFAtiMcyryUHoUjUJX0/lt1H2+i2Ka2n+D3DImSNo= | |
| 379 | 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 | 429 | go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= | |
| 412 | 430 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= | |
| 413 | 431 | 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= | |
| 416 | 434 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= | |
| 417 | 435 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= | |
| 418 | 436 | 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 | + | } | |||