Vow, uncensorable PDS written in Go

feat: support passkeys

+1151 -810
+4 -3
go.mod
··· 6 github.com/bluesky-social/indigo v0.0.0-20260203235305-a86f3ae1f8ec 7 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 8 github.com/domodwyer/mailyak/v3 v3.6.2 9 - github.com/ethereum/go-ethereum v1.17.1 10 github.com/glebarez/sqlite v1.11.0 11 github.com/go-chi/chi/v5 v5.2.5 12 github.com/go-pkgz/expirable-cache/v3 v3.0.0 ··· 35 ) 36 37 require ( 38 - github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect 39 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect 40 github.com/beorn7/perks v1.0.1 // indirect 41 github.com/cespare/xxhash/v2 v2.3.0 // indirect ··· 54 github.com/gocql/gocql v1.7.0 // indirect 55 github.com/gogo/protobuf v1.3.2 // indirect 56 github.com/golang/snappy v1.0.0 // indirect 57 github.com/gorilla/securecookie v1.1.2 // indirect 58 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect 59 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 60 github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 61 github.com/hashicorp/golang-lru v1.0.2 // indirect 62 - github.com/holiman/uint256 v1.3.2 // indirect 63 github.com/inconshreveable/mousetrap v1.1.0 // indirect 64 github.com/ipfs/bbloom v0.0.4 // indirect 65 github.com/ipfs/go-blockservice v0.5.2 // indirect ··· 116 github.com/spf13/cast v1.10.0 // indirect 117 github.com/spf13/pflag v1.0.10 // indirect 118 github.com/subosito/gotenv v1.6.0 // indirect 119 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 120 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 121 go.opentelemetry.io/auto/sdk v1.2.1 // indirect
··· 6 github.com/bluesky-social/indigo v0.0.0-20260203235305-a86f3ae1f8ec 7 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 8 github.com/domodwyer/mailyak/v3 v3.6.2 9 + github.com/fxamacker/cbor/v2 v2.9.0 10 github.com/glebarez/sqlite v1.11.0 11 github.com/go-chi/chi/v5 v5.2.5 12 github.com/go-pkgz/expirable-cache/v3 v3.0.0 ··· 35 ) 36 37 require ( 38 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect 39 github.com/beorn7/perks v1.0.1 // indirect 40 github.com/cespare/xxhash/v2 v2.3.0 // indirect ··· 53 github.com/gocql/gocql v1.7.0 // indirect 54 github.com/gogo/protobuf v1.3.2 // indirect 55 github.com/golang/snappy v1.0.0 // indirect 56 + github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect 57 github.com/gorilla/securecookie v1.1.2 // indirect 58 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect 59 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 60 github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 61 github.com/hashicorp/golang-lru v1.0.2 // indirect 62 + github.com/huin/goupnp v1.3.0 // indirect 63 github.com/inconshreveable/mousetrap v1.1.0 // indirect 64 github.com/ipfs/bbloom v0.0.4 // indirect 65 github.com/ipfs/go-blockservice v0.5.2 // indirect ··· 116 github.com/spf13/cast v1.10.0 // indirect 117 github.com/spf13/pflag v1.0.10 // indirect 118 github.com/subosito/gotenv v1.6.0 // indirect 119 + github.com/x448/float16 v0.8.4 // indirect 120 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 121 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 122 go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+4 -8
go.sum
··· 1 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 - github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 h1:1zYrtlhrZ6/b6SAjLSfKzWtdgqK0U+HtH/VcBWh1BaU= 3 - github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= 4 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b h1:5/++qT1/z812ZqBvqQt6ToRswSuPZ/B33m6xVHRzADU= 5 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4= 6 github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5 h1:iW0a5ljuFxkLGPNem5Ui+KBjFJzKg4Fv2fnxe4dvzpM= ··· 27 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 28 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 29 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 30 - github.com/decred/dcrd/crypto/blake256 v1.0.1 h1:7PltbUIQB7u/FfZ39+DGa/ShuMyJ5ilcvdfma9wOH6Y= 31 - github.com/decred/dcrd/crypto/blake256 v1.0.1/go.mod h1:2OfgNZ5wDpcsFmHmCK5gZTPcCXqlm2ArzUIkw9czNJo= 32 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 33 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 34 github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8= ··· 37 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 38 github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= 39 github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= 40 - github.com/ethereum/go-ethereum v1.17.1 h1:IjlQDjgxg2uL+GzPRkygGULPMLzcYWncEI7wbaizvho= 41 - github.com/ethereum/go-ethereum v1.17.1/go.mod h1:7UWOVHL7K3b8RfVRea022btnzLCaanwHtBuH1jUCH/I= 42 github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 43 github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 44 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= ··· 47 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 48 github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 49 github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 50 github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= 51 github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= 52 github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= ··· 116 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 117 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 118 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 119 - github.com/holiman/uint256 v1.3.2 h1:a9EgMPSC1AAaj1SZL5zIQD3WbwTuHrMGOerLjGmM/TA= 120 - github.com/holiman/uint256 v1.3.2/go.mod h1:EOMSn4q6Nyt9P6efbI3bueV4e1b3dGlUCXeiRV4ng7E= 121 github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= 122 github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= 123 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= ··· 362 github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11/go.mod h1:Wlo/SzPmxVp6vXpGt/zaXhHH0fn4IxgqZc82aKg6bpQ= 363 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 364 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 365 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 366 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 367 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
··· 1 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 2 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b h1:5/++qT1/z812ZqBvqQt6ToRswSuPZ/B33m6xVHRzADU= 3 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4= 4 github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5 h1:iW0a5ljuFxkLGPNem5Ui+KBjFJzKg4Fv2fnxe4dvzpM= ··· 25 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 26 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 27 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 28 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 29 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 30 github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8= ··· 33 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 34 github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= 35 github.com/earthboundkid/versioninfo/v2 v2.24.1/go.mod h1:VcWEooDEuyUJnMfbdTh0uFN4cfEIg+kHMuWB2CDCLjw= 36 github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 37 github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 38 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= ··· 41 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 42 github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 43 github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 44 + github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= 45 + github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= 46 github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= 47 github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= 48 github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= ··· 112 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 113 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 114 github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= 115 github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= 116 github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= 117 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= ··· 356 github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11/go.mod h1:Wlo/SzPmxVp6vXpGt/zaXhHH0fn4IxgqZc82aKg6bpQ= 357 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 358 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e/go.mod h1:pM99HXyEbSQHcosHc0iW7YFmwnscr+t9Te4ibko05so= 359 + github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 360 + github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 361 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 362 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 363 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+10 -10
internal/helpers/helpers.go
··· 13 ) 14 15 var ( 16 - // ErrSignerNotConnected is the sentinel returned when no wallet is connected. 17 ErrSignerNotConnected = errors.New("signer not connected") 18 19 - // ErrSignerRejected is the sentinel returned when the wallet rejected the 20 - // signing request. 21 ErrSignerRejected = errors.New("signer rejected") 22 23 - // ErrSignerTimeout is the sentinel returned when the wallet did not respond 24 - // within the deadline. 25 ErrSignerTimeout = errors.New("signer timeout") 26 ) 27 ··· 48 // guard). 49 // 50 // The error codes follow the ATProto convention used by the official PDS: 51 - // - "AccountNotFound" — no wallet tab is open / connected. 52 - // - "UserTookDownRepo" — the user explicitly rejected the signing prompt. 53 // - "RepoDeactivated" — the signing deadline elapsed with no response. 54 // 55 // These are the closest standard codes to what happened; they tell AppViews ··· 63 case errors.Is(err, ErrSignerNotConnected): 64 writeJSON(w, http.StatusBadRequest, map[string]string{ 65 "error": "AccountNotFound", 66 - "message": "No wallet signer is connected for this account. Open the account page and keep it in a browser tab.", 67 }) 68 case errors.Is(err, ErrSignerRejected): 69 writeJSON(w, http.StatusBadRequest, map[string]string{ 70 "error": "UserTookDownRepo", 71 - "message": "The wallet rejected the signing request.", 72 }) 73 case errors.Is(err, ErrSignerTimeout): 74 writeJSON(w, http.StatusBadRequest, map[string]string{ 75 "error": "RepoDeactivated", 76 - "message": "The wallet did not respond within the signing deadline.", 77 }) 78 default: 79 ServerError(w, nil)
··· 13 ) 14 15 var ( 16 + // ErrSignerNotConnected is the sentinel returned when no signer tab is open. 17 ErrSignerNotConnected = errors.New("signer not connected") 18 19 + // ErrSignerRejected is the sentinel returned when the passkey prompt was 20 + // dismissed or the signing request was explicitly rejected. 21 ErrSignerRejected = errors.New("signer rejected") 22 23 + // ErrSignerTimeout is the sentinel returned when the passkey did not 24 + // respond within the deadline. 25 ErrSignerTimeout = errors.New("signer timeout") 26 ) 27 ··· 48 // guard). 49 // 50 // The error codes follow the ATProto convention used by the official PDS: 51 + // - "AccountNotFound" — no signer tab is open / connected. 52 + // - "UserTookDownRepo" — the user dismissed the passkey prompt or rejected it. 53 // - "RepoDeactivated" — the signing deadline elapsed with no response. 54 // 55 // These are the closest standard codes to what happened; they tell AppViews ··· 63 case errors.Is(err, ErrSignerNotConnected): 64 writeJSON(w, http.StatusBadRequest, map[string]string{ 65 "error": "AccountNotFound", 66 + "message": "No signer is connected for this account. Open the account page and keep it in a browser tab.", 67 }) 68 case errors.Is(err, ErrSignerRejected): 69 writeJSON(w, http.StatusBadRequest, map[string]string{ 70 "error": "UserTookDownRepo", 71 + "message": "The passkey prompt was dismissed or the signing request was rejected.", 72 }) 73 case errors.Is(err, ErrSignerTimeout): 74 writeJSON(w, http.StatusBadRequest, map[string]string{ 75 "error": "RepoDeactivated", 76 + "message": "The passkey did not respond within the signing deadline.", 77 }) 78 default: 79 ServerError(w, nil)
+11 -21
models/models.go
··· 2 3 import ( 4 "time" 5 - 6 - gethcrypto "github.com/ethereum/go-ethereum/crypto" 7 ) 8 9 type Repo struct { ··· 22 AccountDeleteCode *string 23 AccountDeleteCodeExpiresAt *time.Time 24 Password string 25 - // PublicKey holds the compressed secp256k1 public key bytes for the 26 - // account. This is the only key material the PDS retains. 27 - PublicKey []byte 28 - Rev string 29 - Root []byte 30 - Preferences []byte 31 - Deactivated bool 32 - } 33 - 34 - // EthereumAddress returns the Ethereum address for PublicKey. 35 - func (r *Repo) EthereumAddress() string { 36 - if len(r.PublicKey) == 0 { 37 - return "" 38 - } 39 - ecPub, err := gethcrypto.DecompressPubkey(r.PublicKey) 40 - if err != nil { 41 - return "" 42 - } 43 - return gethcrypto.PubkeyToAddress(*ecPub).Hex() 44 } 45 46 func (r *Repo) Status() *string {
··· 2 3 import ( 4 "time" 5 ) 6 7 type Repo struct { ··· 20 AccountDeleteCode *string 21 AccountDeleteCodeExpiresAt *time.Time 22 Password string 23 + // PublicKey holds the compressed P-256 (secp256r1) public key bytes for 24 + // the account. This is the only key material the PDS retains. 25 + PublicKey []byte 26 + // CredentialID is the WebAuthn credential ID returned by the authenticator 27 + // during registration. It is stored so the server can build the 28 + // allowCredentials list when requesting an assertion from the passkey. 29 + CredentialID []byte 30 + Rev string 31 + Root []byte 32 + Preferences []byte 33 + Deactivated bool 34 } 35 36 func (r *Repo) Status() *string {
+5 -1
readme.md
··· 222 { 223 "type": "sign_response", 224 "requestId": "uuid", 225 - "signature": "<base64url-encoded signature bytes>" 226 } 227 ``` 228 229 **Rejection message (browser → PDS):** 230
··· 222 { 223 "type": "sign_response", 224 "requestId": "uuid", 225 + "authenticatorData": "<base64url authenticatorData bytes>", 226 + "clientDataJSON": "<base64url clientDataJSON bytes>", 227 + "signature": "<base64url DER-encoded ECDSA signature>" 228 } 229 ``` 230 + 231 + The server decodes all three fields, reconstructs the signed message as `authenticatorData ‖ SHA-256(clientDataJSON)`, verifies the P-256 signature, and converts the DER-encoded signature to the raw 64-byte (r‖s) format expected by ATProto before delivering it to the waiting write handler. 232 233 **Rejection message (browser → PDS):** 234
+14 -6
server/handle_account.go
··· 1 package server 2 3 import ( 4 "net/http" 5 "time" 6 ··· 65 }) 66 } 67 68 if err := s.renderTemplate(w, "account.html", map[string]any{ 69 - "Handle": repo.Handle, 70 - "Did": repo.Repo.Did, 71 - "HasSigningKey": len(repo.PublicKey) > 0, 72 - "EthereumAddress": repo.EthereumAddress(), 73 - "Tokens": tokenInfo, 74 - "flashes": s.getFlashesFromSession(w, r, sess), 75 }); err != nil { 76 logger.Error("failed to render template", "error", err) 77 }
··· 1 package server 2 3 import ( 4 + "encoding/base64" 5 "net/http" 6 "time" 7 ··· 66 }) 67 } 68 69 + // Encode the credential ID as base64url so the template can pass it to 70 + // navigator.credentials.get() as the allowCredentials entry. 71 + credentialID := "" 72 + if len(repo.CredentialID) > 0 { 73 + credentialID = base64.RawURLEncoding.EncodeToString(repo.CredentialID) 74 + } 75 + 76 if err := s.renderTemplate(w, "account.html", map[string]any{ 77 + "Handle": repo.Handle, 78 + "Did": repo.Repo.Did, 79 + "HasSigningKey": len(repo.PublicKey) > 0, 80 + "CredentialID": credentialID, 81 + "Tokens": tokenInfo, 82 + "flashes": s.getFlashesFromSession(w, r, sess), 83 }); err != nil { 84 logger.Error("failed to render template", "error", err) 85 }
+21 -6
server/handle_account_signer.go
··· 1 package server 2 3 import ( 4 - "encoding/base64" 5 "encoding/json" 6 "net/http" 7 "time" ··· 62 inbound := make(chan wsIncoming, 4) 63 nextReq := make(chan signerRequest, 1) 64 65 ctx := r.Context() 66 go func() { 67 for { ··· 110 case in := <-inbound: 111 switch in.Type { 112 case "sign_response": 113 - if in.Signature == "" { 114 - logger.Warn("signer: sign_response missing signature", "did", did) 115 continue 116 } 117 - sigBytes, err := base64.RawURLEncoding.DecodeString(in.Signature) 118 if err != nil { 119 - logger.Warn("signer: sign_response bad base64url", "did", did, "error", err) 120 continue 121 } 122 - if !s.signerHub.DeliverSignature(did, in.RequestID, sigBytes) { 123 logger.Warn("signer: sign_response for unknown requestId", "did", did, "requestId", in.RequestID) 124 } 125 126 case "sign_reject": 127 if !s.signerHub.DeliverRejection(did, in.RequestID) { 128 logger.Warn("signer: sign_reject for unknown requestId", "did", did, "requestId", in.RequestID) 129 } ··· 137 logger.Error("signer: failed to write request", "did", did, "error", err) 138 req.reply <- signerReply{err: helpers.ErrSignerNotConnected} 139 return 140 } 141 142 logger.Info("signer: request sent", "did", did, "requestId", req.requestID)
··· 1 package server 2 3 import ( 4 "encoding/json" 5 "net/http" 6 "time" ··· 61 inbound := make(chan wsIncoming, 4) 62 nextReq := make(chan signerRequest, 1) 63 64 + // pendingPayloads maps requestID → base64url payload so we can reconstruct 65 + // the expected WebAuthn challenge when a sign_response arrives. 66 + pendingPayloads := make(map[string]string) 67 + 68 ctx := r.Context() 69 go func() { 70 for { ··· 113 case in := <-inbound: 114 switch in.Type { 115 case "sign_response": 116 + payload, ok := pendingPayloads[in.RequestID] 117 + if !ok { 118 + logger.Warn("signer: sign_response for unknown requestId (no payload)", "did", did, "requestId", in.RequestID) 119 continue 120 } 121 + delete(pendingPayloads, in.RequestID) 122 + 123 + rawSig, err := verifyWebAuthnSignResponse(repo.PublicKey, payload, in, s.config.Hostname, logger) 124 if err != nil { 125 + logger.Warn("signer: sign_response verification failed", "did", did, "requestId", in.RequestID, "error", err) 126 continue 127 } 128 + if !s.signerHub.DeliverSignature(did, in.RequestID, rawSig) { 129 logger.Warn("signer: sign_response for unknown requestId", "did", did, "requestId", in.RequestID) 130 } 131 132 case "sign_reject": 133 + delete(pendingPayloads, in.RequestID) 134 if !s.signerHub.DeliverRejection(did, in.RequestID) { 135 logger.Warn("signer: sign_reject for unknown requestId", "did", did, "requestId", in.RequestID) 136 } ··· 144 logger.Error("signer: failed to write request", "did", did, "error", err) 145 req.reply <- signerReply{err: helpers.ErrSignerNotConnected} 146 return 147 + } 148 + 149 + // Record the payload so we can verify the WebAuthn challenge when 150 + // the sign_response arrives. 151 + if payload, err := extractPayloadFromMsg(req.msg); err == nil { 152 + pendingPayloads[req.requestID] = payload 153 + } else { 154 + logger.Warn("signer: could not extract payload from sign_request", "did", did, "error", err) 155 } 156 157 logger.Info("signer: request sent", "did", did, "requestId", req.requestID)
+3 -3
server/handle_identity_sign_plc_operation.go
··· 34 // 35 // Unlike the previous implementation this handler never touches a private key. 36 // The rotation key (held by the PDS) signs the PLC operation envelope as 37 - // required by the PLC protocol; the user's signing key (held in their Ethereum 38 - // wallet) signs only the inner payload bytes delivered over the WebSocket. 39 func (s *Server) handleSignPlcOperation(w http.ResponseWriter, r *http.Request) { 40 logger := s.logger.With("name", "handleSignPlcOperation") 41 ··· 100 op.Services = *req.Services 101 } 102 103 - // Serialise the operation to CBOR — this is the payload the user's wallet 104 // must sign. We send it to the signer and wait for the signature. 105 opCBOR, err := op.MarshalCBOR() 106 if err != nil {
··· 34 // 35 // Unlike the previous implementation this handler never touches a private key. 36 // The rotation key (held by the PDS) signs the PLC operation envelope as 37 + // required by the PLC protocol; the user's signing key (held in their passkey) 38 + // signs only the inner payload bytes delivered over the WebSocket. 39 func (s *Server) handleSignPlcOperation(w http.ResponseWriter, r *http.Request) { 40 logger := s.logger.With("name", "handleSignPlcOperation") 41 ··· 100 op.Services = *req.Services 101 } 102 103 + // Serialise the operation to CBOR — this is the payload the user's passkey 104 // must sign. We send it to the signer and wait for the signature. 105 opCBOR, err := op.MarshalCBOR() 106 if err != nil {
+2 -2
server/handle_identity_submit_plc_operation.go
··· 52 // 1. The signing key (verificationMethods.atproto) matches the registered key. 53 // 2. The service endpoint still points to this PDS. 54 // 3. The rotation keys include at least one key that was already authorised 55 - // (either the user's wallet key or the PDS key, depending on whether 56 // sovereignty has been transferred). 57 // 4. The operation was signed by one of the current rotation keys (enforced 58 // by plc.directory on submission, not re-checked here). ··· 62 return 63 } 64 65 - pubKey, err := atcrypto.ParsePublicBytesK256(repo.PublicKey) 66 if err != nil { 67 logger.Error("error parsing stored public key", "error", err) 68 helpers.ServerError(w, nil)
··· 52 // 1. The signing key (verificationMethods.atproto) matches the registered key. 53 // 2. The service endpoint still points to this PDS. 54 // 3. The rotation keys include at least one key that was already authorised 55 + // (either the user's passkey or the PDS key, depending on whether 56 // sovereignty has been transferred). 57 // 4. The operation was signed by one of the current rotation keys (enforced 58 // by plc.directory on submission, not re-checked here). ··· 62 return 63 } 64 65 + pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey) 66 if err != nil { 67 logger.Error("error parsing stored public key", "error", err) 68 helpers.ServerError(w, nil)
+2 -2
server/handle_identity_update_handle.go
··· 75 76 // Determine whether the PDS rotation key still has authority over 77 // this DID. After supplySigningKey transfers the rotation key to the 78 - // user's wallet, the PDS key is no longer in the rotation key list 79 // and cannot sign PLC operations. 80 pdsRotationDIDKey := s.plcClient.RotationDIDKey() 81 pdsCanSign := slices.Contains(latest.Operation.RotationKeys, pdsRotationDIDKey) ··· 88 return 89 } 90 } else { 91 - // Rotation key belongs to the user's wallet. Delegate the 92 // signing to the signer over WebSocket, same as 93 // handleSignPlcOperation does for other PLC operations. 94 opCBOR, err := op.MarshalCBOR()
··· 75 76 // Determine whether the PDS rotation key still has authority over 77 // this DID. After supplySigningKey transfers the rotation key to the 78 + // user's passkey, the PDS key is no longer in the rotation key list 79 // and cannot sign PLC operations. 80 pdsRotationDIDKey := s.plcClient.RotationDIDKey() 81 pdsCanSign := slices.Contains(latest.Operation.RotationKeys, pdsRotationDIDKey) ··· 88 return 89 } 90 } else { 91 + // Rotation key belongs to the user's passkey. Delegate the 92 // signing to the signer over WebSocket, same as 93 // handleSignPlcOperation does for other PLC operations. 94 opCBOR, err := op.MarshalCBOR()
+78
server/handle_passkey_assertion_challenge.go
···
··· 1 + package server 2 + 3 + import ( 4 + "crypto/sha256" 5 + "encoding/base64" 6 + "fmt" 7 + "net/http" 8 + 9 + "pkg.rbrt.fr/vow/internal/helpers" 10 + "pkg.rbrt.fr/vow/models" 11 + ) 12 + 13 + // passkeyAssertionOptions is the JSON structure returned to the browser so it 14 + // can call navigator.credentials.get(). It mirrors the 15 + // PublicKeyCredentialRequestOptions WebAuthn type. 16 + type passkeyAssertionOptions struct { 17 + Challenge string `json:"challenge"` 18 + AllowCredentials []allowedCredential `json:"allowCredentials"` 19 + Timeout int `json:"timeout"` 20 + UserVerification string `json:"userVerification"` 21 + RpID string `json:"rpId"` 22 + } 23 + 24 + type allowedCredential struct { 25 + ID string `json:"id"` // base64url-encoded credential ID 26 + Type string `json:"type"` // always "public-key" 27 + } 28 + 29 + // handlePasskeyAssertionChallenge returns WebAuthn PublicKeyCredentialRequestOptions 30 + // for operations that need a fresh passkey assertion — currently only account 31 + // deletion. The challenge is SHA-256("Delete account: <did>") so the server 32 + // can reconstruct and verify it without storing session state. 33 + // 34 + // POST /account/passkey-assertion-challenge 35 + func (s *Server) handlePasskeyAssertionChallenge(w http.ResponseWriter, r *http.Request) { 36 + repo, ok := getContextValue[*models.RepoActor](r, contextKeyRepo) 37 + if !ok { 38 + helpers.UnauthorizedError(w, nil) 39 + return 40 + } 41 + 42 + if len(repo.PublicKey) == 0 { 43 + s.writeJSON(w, http.StatusBadRequest, map[string]string{ 44 + "error": "NoSigningKey", 45 + "message": "No passkey is registered for this account.", 46 + }) 47 + return 48 + } 49 + 50 + if len(repo.CredentialID) == 0 { 51 + s.writeJSON(w, http.StatusBadRequest, map[string]string{ 52 + "error": "NoCredentialID", 53 + "message": "No credential ID found for this account. Please re-register your passkey.", 54 + }) 55 + return 56 + } 57 + 58 + // Derive a deterministic challenge so we can verify it server-side without 59 + // storing per-request state: SHA-256("Delete account: <did>"). 60 + msg := fmt.Sprintf("Delete account: %s", repo.Repo.Did) 61 + sum := sha256.Sum256([]byte(msg)) 62 + challenge := base64.RawURLEncoding.EncodeToString(sum[:]) 63 + 64 + opts := passkeyAssertionOptions{ 65 + Challenge: challenge, 66 + AllowCredentials: []allowedCredential{ 67 + { 68 + ID: base64.RawURLEncoding.EncodeToString(repo.CredentialID), 69 + Type: "public-key", 70 + }, 71 + }, 72 + Timeout: 30000, 73 + UserVerification: "preferred", 74 + RpID: s.config.Hostname, 75 + } 76 + 77 + s.writeJSON(w, http.StatusOK, opts) 78 + }
+97
server/handle_passkey_challenge.go
···
··· 1 + package server 2 + 3 + import ( 4 + "crypto/rand" 5 + "encoding/base64" 6 + "net/http" 7 + 8 + "pkg.rbrt.fr/vow/internal/helpers" 9 + "pkg.rbrt.fr/vow/models" 10 + ) 11 + 12 + // passkeyCreationOptions is the JSON structure returned to the browser so it 13 + // can call navigator.credentials.create(). It mirrors the 14 + // PublicKeyCredentialCreationOptions WebAuthn type. 15 + type passkeyCreationOptions struct { 16 + Rp passkeyRp `json:"rp"` 17 + User passkeyUser `json:"user"` 18 + // Challenge is a base64url-encoded random byte string. The browser passes 19 + // it through to the authenticator unchanged; the server doesn't need to 20 + // verify it later because the attestation is verified via the 21 + // clientDataJSON embedded in the attestationObject. 22 + Challenge string `json:"challenge"` 23 + PubKeyCredParams []pubKeyCredParam `json:"pubKeyCredParams"` 24 + AuthenticatorSel authenticatorSelection `json:"authenticatorSelection"` 25 + Attestation string `json:"attestation"` 26 + Timeout int `json:"timeout"` 27 + } 28 + 29 + type passkeyRp struct { 30 + ID string `json:"id"` 31 + Name string `json:"name"` 32 + } 33 + 34 + type passkeyUser struct { 35 + // ID is the base64url-encoded DID bytes. The WebAuthn spec requires it to 36 + // be opaque user-handle bytes, not a human-readable string. 37 + ID string `json:"id"` 38 + Name string `json:"name"` 39 + DisplayName string `json:"displayName"` 40 + } 41 + 42 + type pubKeyCredParam struct { 43 + Type string `json:"type"` 44 + Alg int `json:"alg"` // -7 = ES256 (P-256) 45 + } 46 + 47 + type authenticatorSelection struct { 48 + UserVerification string `json:"userVerification"` 49 + ResidentKey string `json:"residentKey"` 50 + } 51 + 52 + // handlePasskeyChallenge returns WebAuthn PublicKeyCredentialCreationOptions 53 + // so the browser can register a new passkey for the authenticated account. 54 + // 55 + // POST /account/passkey-challenge 56 + func (s *Server) handlePasskeyChallenge(w http.ResponseWriter, r *http.Request) { 57 + repo, ok := getContextValue[*models.RepoActor](r, contextKeyRepo) 58 + if !ok { 59 + helpers.UnauthorizedError(w, nil) 60 + return 61 + } 62 + 63 + // Generate a fresh 32-byte random challenge. 64 + challengeBytes := make([]byte, 32) 65 + if _, err := rand.Read(challengeBytes); err != nil { 66 + helpers.ServerError(w, nil) 67 + return 68 + } 69 + challenge := base64.RawURLEncoding.EncodeToString(challengeBytes) 70 + 71 + // Use the DID as the opaque user ID (base64url-encoded UTF-8 bytes). 72 + userID := base64.RawURLEncoding.EncodeToString([]byte(repo.Repo.Did)) 73 + 74 + opts := passkeyCreationOptions{ 75 + Rp: passkeyRp{ 76 + ID: s.config.Hostname, 77 + Name: "Vow PDS", 78 + }, 79 + User: passkeyUser{ 80 + ID: userID, 81 + Name: repo.Handle, 82 + DisplayName: repo.Handle, 83 + }, 84 + Challenge: challenge, 85 + PubKeyCredParams: []pubKeyCredParam{ 86 + {Type: "public-key", Alg: -7}, // ES256 / P-256 87 + }, 88 + AuthenticatorSel: authenticatorSelection{ 89 + UserVerification: "preferred", 90 + ResidentKey: "preferred", 91 + }, 92 + Attestation: "none", 93 + Timeout: 60000, 94 + } 95 + 96 + s.writeJSON(w, http.StatusOK, opts) 97 + }
+1 -1
server/handle_proxy.go
··· 89 90 // exp=0 tells signServiceAuthJWT to use the default lifetime and 91 // cache the resulting token so repeated proxy calls for the same 92 - // (aud, lxm) pair reuse it instead of prompting the wallet each time. 93 token, err := s.signServiceAuthJWT(r.Context(), repo, aud, lxm, 0) 94 if helpers.HandleSignerError(w, err) { 95 logger.Error("error signing proxy JWT", "error", err)
··· 89 90 // exp=0 tells signServiceAuthJWT to use the default lifetime and 91 // cache the resulting token so repeated proxy calls for the same 92 + // (aud, lxm) pair reuse it instead of prompting the passkey each time. 93 token, err := s.signServiceAuthJWT(r.Context(), repo, aud, lxm, 0) 94 if helpers.HandleSignerError(w, err) { 95 logger.Error("error signing proxy JWT", "error", err)
+50 -49
server/handle_server_delete_account.go
··· 2 3 import ( 4 "context" 5 - "encoding/hex" 6 "encoding/json" 7 "fmt" 8 "net/http" 9 - "strings" 10 "time" 11 12 "github.com/bluesky-social/indigo/api/atproto" 13 "github.com/bluesky-social/indigo/events" 14 "github.com/bluesky-social/indigo/util" 15 - gethcrypto "github.com/ethereum/go-ethereum/crypto" 16 "golang.org/x/crypto/bcrypt" 17 "pkg.rbrt.fr/vow/internal/helpers" 18 "pkg.rbrt.fr/vow/models" ··· 149 } 150 151 // --------------------------------------------------------------------------- 152 - // /account/delete — browser endpoint (web session + wallet signature) 153 // --------------------------------------------------------------------------- 154 155 type AccountDeleteRequest struct { 156 - WalletAddress string `json:"walletAddress" validate:"required"` 157 - Signature string `json:"signature" validate:"required"` 158 } 159 160 - // handleAccountDelete deletes the authenticated account after verifying that 161 - // the request is signed by the wallet whose public key is registered with the 162 - // account. Authentication is done via the web session cookie; the wallet 163 - // signature proves the user still controls the key, with no email or password 164 - // needed. 165 func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request) { 166 ctx := r.Context() 167 logger := s.logger.With("name", "handleAccountDelete") ··· 172 return 173 } 174 175 - // The account must have a registered signing key; without it we have no 176 - // wallet to verify against. 177 if len(repo.PublicKey) == 0 { 178 s.writeJSON(w, http.StatusBadRequest, map[string]any{ 179 "error": "NoSigningKey", 180 - "message": "No signing key is registered for this account. Please register your wallet first.", 181 }) 182 return 183 } ··· 189 return 190 } 191 192 - if err := s.validator.Struct(&req); err != nil { 193 - logger.Error("validation failed", "error", err) 194 - s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "walletAddress and signature are required"}) 195 return 196 } 197 198 - // Decode the 65-byte personal_sign signature. 199 - sigHex := strings.TrimPrefix(req.Signature, "0x") 200 - sig, err := hex.DecodeString(sigHex) 201 - if err != nil || len(sig) != 65 { 202 - s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "signature must be a 65-byte hex string"}) 203 return 204 } 205 206 - // personal_sign uses v=27/28; go-ethereum SigToPub expects v=0/1. 207 - if sig[64] >= 27 { 208 - sig[64] -= 27 209 - } 210 - 211 - // Hash the message with the Ethereum personal_sign envelope. 212 - msg := fmt.Sprintf("Delete account: %s", repo.Repo.Did) 213 - msgHash := gethcrypto.Keccak256( 214 - fmt.Appendf(nil, "\x19Ethereum Signed Message:\n%d%s", len(msg), msg), 215 - ) 216 - 217 - // Recover the public key from the signature. 218 - ecPub, err := gethcrypto.SigToPub(msgHash, sig) 219 if err != nil { 220 - logger.Warn("public key recovery failed", "error", err) 221 - s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "could not recover public key from signature"}) 222 return 223 } 224 225 - // Verify the recovered address matches the claimed wallet address. 226 - recoveredAddr := gethcrypto.PubkeyToAddress(*ecPub).Hex() 227 - if !strings.EqualFold(recoveredAddr, req.WalletAddress) { 228 - logger.Warn("address mismatch", "claimed", req.WalletAddress, "recovered", recoveredAddr) 229 - s.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "signature does not match the provided wallet address"}) 230 return 231 } 232 233 - // Verify the recovered address matches the wallet registered on the account. 234 - registeredAddr := repo.EthereumAddress() 235 - if !strings.EqualFold(recoveredAddr, registeredAddr) { 236 - logger.Warn("wallet not registered for account", 237 - "recovered", recoveredAddr, 238 - "registered", registeredAddr, 239 "did", repo.Repo.Did, 240 ) 241 - s.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "signature wallet does not match the key registered for this account"}) 242 return 243 } 244
··· 2 3 import ( 4 "context" 5 + "crypto/sha256" 6 + "encoding/base64" 7 "encoding/json" 8 "fmt" 9 "net/http" 10 "time" 11 12 "github.com/bluesky-social/indigo/api/atproto" 13 "github.com/bluesky-social/indigo/events" 14 "github.com/bluesky-social/indigo/util" 15 "golang.org/x/crypto/bcrypt" 16 "pkg.rbrt.fr/vow/internal/helpers" 17 "pkg.rbrt.fr/vow/models" ··· 148 } 149 150 // --------------------------------------------------------------------------- 151 + // /account/delete — browser endpoint (web session + WebAuthn assertion) 152 // --------------------------------------------------------------------------- 153 154 + // AccountDeleteRequest carries the WebAuthn assertion response fields sent by 155 + // the browser after the user confirms account deletion with their passkey. 156 type AccountDeleteRequest struct { 157 + CredentialID string `json:"credentialId"` // base64url 158 + ClientDataJSON string `json:"clientDataJSON"` // base64url 159 + AuthenticatorData string `json:"authenticatorData"` // base64url 160 + Signature string `json:"signature"` // base64url DER-encoded ECDSA 161 } 162 163 + // handleAccountDelete deletes the authenticated account after verifying a 164 + // WebAuthn assertion signed by the passkey registered for the account. 165 + // Authentication is via the web session cookie; the passkey assertion proves 166 + // the user still controls the device, with no password or email needed. 167 func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request) { 168 ctx := r.Context() 169 logger := s.logger.With("name", "handleAccountDelete") ··· 174 return 175 } 176 177 + // The account must have a registered passkey; without it there is nothing 178 + // to verify against. 179 if len(repo.PublicKey) == 0 { 180 s.writeJSON(w, http.StatusBadRequest, map[string]any{ 181 "error": "NoSigningKey", 182 + "message": "No passkey is registered for this account. Please register a passkey first.", 183 }) 184 return 185 } ··· 191 return 192 } 193 194 + if req.ClientDataJSON == "" || req.AuthenticatorData == "" || req.Signature == "" { 195 + s.writeJSON(w, http.StatusBadRequest, map[string]string{ 196 + "error": "clientDataJSON, authenticatorData, and signature are required", 197 + }) 198 return 199 } 200 201 + // Decode base64url fields. 202 + clientDataJSONBytes, err := base64.RawURLEncoding.DecodeString(req.ClientDataJSON) 203 + if err != nil { 204 + s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid clientDataJSON encoding"}) 205 return 206 } 207 208 + authenticatorDataBytes, err := base64.RawURLEncoding.DecodeString(req.AuthenticatorData) 209 if err != nil { 210 + s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid authenticatorData encoding"}) 211 return 212 } 213 214 + signatureDER, err := base64.RawURLEncoding.DecodeString(req.Signature) 215 + if err != nil { 216 + s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid signature encoding"}) 217 return 218 } 219 220 + // Reconstruct the expected challenge: SHA-256("Delete account: <did>"). 221 + // This is the same derivation used by handlePasskeyAssertionChallenge, so 222 + // no server-side session state is needed. 223 + msg := fmt.Sprintf("Delete account: %s", repo.Repo.Did) 224 + sum := sha256.Sum256([]byte(msg)) 225 + 226 + // verifyAssertion checks the challenge, rpIdHash, UP flag, and P-256 227 + // signature. It also returns the raw (r‖s) bytes, which we discard here. 228 + if _, err := verifyAssertion( 229 + repo.PublicKey, 230 + sum[:], 231 + clientDataJSONBytes, 232 + authenticatorDataBytes, 233 + signatureDER, 234 + s.config.Hostname, 235 + ); err != nil { 236 + logger.Warn("WebAuthn assertion verification failed for account delete", 237 "did", repo.Repo.Did, 238 + "error", err, 239 ) 240 + s.writeJSON(w, http.StatusUnauthorized, map[string]string{ 241 + "error": "passkey verification failed: " + err.Error(), 242 + }) 243 return 244 } 245
+13 -17
server/handle_server_get_service_auth.go
··· 80 }) 81 } 82 83 - // signServiceAuthJWT returns a signed ES256K service-auth JWT for the given 84 - // (aud, lxm) pair, reusing a cached token when possible. Only when no cached 85 - // token is available does it send a signing request to the user's wallet via 86 - // the SignerHub WebSocket. 87 // 88 // The returned string is a fully formed "header.payload.signature" JWT ready to 89 // be placed in an Authorization: Bearer header. ··· 104 105 // ── Build header + payload ──────────────────────────────────────────── 106 header := map[string]string{ 107 - "alg": "ES256K", 108 - "crv": "secp256k1", 109 "typ": "JWT", 110 } 111 hj, err := json.Marshal(header) ··· 144 // base64url(header) + "." + base64url(payload). 145 signingInput := encHeader + "." + encPayload 146 147 - // The wallet signs the SHA-256 hash of the signing input, which is what 148 - // ES256K requires. We pass the raw signingInput bytes as the payload; 149 - // HashAndVerifyLenient on the verification side hashes them before 150 - // verifying, matching what personal_sign does after EIP-191 prefix 151 - // stripping (or eth_sign which skips the prefix). 152 - // 153 - // We send the SHA-256 pre-image (the signingInput string) rather than the 154 - // hash so the signer can display it meaningfully and so the wallet can 155 - // apply its own hashing. This matches the pattern used for commit signing. 156 hash := sha256.Sum256([]byte(signingInput)) 157 payloadB64 := base64.RawURLEncoding.EncodeToString(hash[:]) 158 ··· 180 return "", err 181 } 182 183 - // sigBytes is the raw compact (r||s) or EIP-191 signature returned by the 184 - // wallet. Trim to 64 bytes (r||s) if the wallet appended a recovery byte. 185 if len(sigBytes) == 65 { 186 sigBytes = sigBytes[:64] 187 }
··· 80 }) 81 } 82 83 + // signServiceAuthJWT returns a signed ES256 service-auth JWT for the given 84 + // (aud, lxm) pair. It sends a signing request to the user's passkey via the 85 + // SignerHub WebSocket and waits for the verified raw (r‖s) signature. 86 // 87 // The returned string is a fully formed "header.payload.signature" JWT ready to 88 // be placed in an Authorization: Bearer header. ··· 103 104 // ── Build header + payload ──────────────────────────────────────────── 105 header := map[string]string{ 106 + "alg": "ES256", 107 + "crv": "P-256", 108 "typ": "JWT", 109 } 110 hj, err := json.Marshal(header) ··· 143 // base64url(header) + "." + base64url(payload). 144 signingInput := encHeader + "." + encPayload 145 146 + // ES256 requires signing the SHA-256 hash of the signing input. We send 147 + // the hash as the WebAuthn challenge (the passkey will sign 148 + // authenticatorData ‖ SHA-256(clientDataJSON) where clientDataJSON.challenge 149 + // = base64url(hash)). The WS handler verifies the full assertion and 150 + // delivers the raw (r‖s) signature bytes back to this function. 151 hash := sha256.Sum256([]byte(signingInput)) 152 payloadB64 := base64.RawURLEncoding.EncodeToString(hash[:]) 153 ··· 175 return "", err 176 } 177 178 + // sigBytes is the raw 64-byte (r‖s) P-256 signature delivered by the WS 179 + // handler after WebAuthn assertion verification. Trim to 64 bytes just in 180 + // case an old client appended a recovery byte. 181 if len(sigBytes) == 65 { 182 sigBytes = sigBytes[:64] 183 }
+3 -3
server/handle_server_get_signing_key.go
··· 10 11 // PendingWriteOp is a human-readable summary of a single operation inside a 12 // signing request, sent to the signer so the user knows what they are 13 - // approving before the wallet prompt appears. 14 type PendingWriteOp struct { 15 Type string `json:"type"` 16 Collection string `json:"collection"` ··· 25 PublicKey string `json:"publicKey"` 26 } 27 28 - // handleGetSigningKey returns the compressed secp256k1 public key registered 29 // for the authenticated account, encoded as a did:key string. 30 // 31 // The private key is never held by the PDS; this endpoint only confirms that a ··· 44 return 45 } 46 47 - pubKey, err := atcrypto.ParsePublicBytesK256(repo.PublicKey) 48 if err != nil { 49 logger.Error("error parsing stored public key", "error", err) 50 helpers.ServerError(w, nil)
··· 10 11 // PendingWriteOp is a human-readable summary of a single operation inside a 12 // signing request, sent to the signer so the user knows what they are 13 + // approving before the passkey prompt appears. 14 type PendingWriteOp struct { 15 Type string `json:"type"` 16 Collection string `json:"collection"` ··· 25 PublicKey string `json:"publicKey"` 26 } 27 28 + // handleGetSigningKey returns the compressed P-256 public key registered 29 // for the authenticated account, encoded as a did:key string. 30 // 31 // The private key is never held by the PDS; this endpoint only confirms that a ··· 44 return 45 } 46 47 + pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey) 48 if err != nil { 49 logger.Error("error parsing stored public key", "error", err) 50 helpers.ServerError(w, nil)
+51 -89
server/handle_server_supply_signing_key.go
··· 1 package server 2 3 import ( 4 - "encoding/hex" 5 "encoding/json" 6 - "fmt" 7 "maps" 8 "net/http" 9 "strings" 10 11 "github.com/bluesky-social/indigo/atproto/atcrypto" 12 - gethcrypto "github.com/ethereum/go-ethereum/crypto" 13 "pkg.rbrt.fr/vow/identity" 14 "pkg.rbrt.fr/vow/internal/helpers" 15 "pkg.rbrt.fr/vow/models" 16 "pkg.rbrt.fr/vow/plc" 17 ) 18 19 - // ComAtprotoServerSupplySigningKeyRequest is sent by the account page to 20 - // register the user's secp256k1 public key with the PDS. The client sends the 21 - // wallet address and the signature over a fixed registration message; the PDS 22 - // recovers the public key server-side using go-ethereum and verifies it 23 - // matches the wallet address before storing it. 24 - type ComAtprotoServerSupplySigningKeyRequest struct { 25 - // WalletAddress is the EIP-55 checksummed Ethereum address of the wallet. 26 - WalletAddress string `json:"walletAddress" validate:"required"` 27 - // Signature is the hex-encoded 65-byte personal_sign signature (0x-prefixed). 28 - Signature string `json:"signature" validate:"required"` 29 } 30 31 - type ComAtprotoServerSupplySigningKeyResponse struct { 32 - Did string `json:"did"` 33 - PublicKey string `json:"publicKey"` // did:key representation 34 } 35 36 - // handleSupplySigningKey lets the account page register the user's 37 - // secp256k1 public key. The PDS stores only the compressed public key bytes 38 - // and updates the PLC DID document so the key becomes the active 39 - // verificationMethods.atproto entry. 40 // 41 - // The private key is never transmitted to or stored by the PDS. 42 - // registrationMessage is the fixed plaintext that the wallet must sign during 43 - // key registration. It is prefixed with the Ethereum personal_sign envelope 44 - // ("\x19Ethereum Signed Message:\n<len>") by the wallet before signing. 45 - const registrationMessage = "Vow key registration" 46 - 47 func (s *Server) handleSupplySigningKey(w http.ResponseWriter, r *http.Request) { 48 ctx := r.Context() 49 logger := s.logger.With("name", "handleSupplySigningKey") ··· 54 return 55 } 56 57 - var req ComAtprotoServerSupplySigningKeyRequest 58 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 59 logger.Error("error decoding request", "error", err) 60 helpers.InputError(w, new("could not decode request body")) ··· 63 64 if err := s.validator.Struct(req); err != nil { 65 logger.Error("validation failed", "error", err) 66 - helpers.InputError(w, new("walletAddress and signature are required")) 67 return 68 } 69 70 - // Decode the 65-byte personal_sign signature. 71 - sigHex := strings.TrimPrefix(req.Signature, "0x") 72 - sig, err := hex.DecodeString(sigHex) 73 - if err != nil || len(sig) != 65 { 74 - helpers.InputError(w, new("signature must be a 65-byte hex string")) 75 - return 76 - } 77 - 78 - // personal_sign uses v=27/28; go-ethereum SigToPub expects v=0/1. 79 - if sig[64] >= 27 { 80 - sig[64] -= 27 81 - } 82 - 83 - // Hash the message the same way personal_sign does: 84 - // keccak256("\x19Ethereum Signed Message:\n<len><message>") 85 - msgHash := gethcrypto.Keccak256( 86 - fmt.Appendf(nil, "\x19Ethereum Signed Message:\n%d%s", 87 - len(registrationMessage), registrationMessage), 88 - ) 89 - 90 - // Recover the uncompressed public key. 91 - ecPub, err := gethcrypto.SigToPub(msgHash, sig) 92 if err != nil { 93 - logger.Warn("public key recovery failed", "error", err) 94 - helpers.InputError(w, new("could not recover public key from signature")) 95 return 96 } 97 98 - // Verify the recovered key matches the claimed wallet address. 99 - recoveredAddr := gethcrypto.PubkeyToAddress(*ecPub).Hex() 100 - if !strings.EqualFold(recoveredAddr, req.WalletAddress) { 101 - logger.Warn("recovered address mismatch", 102 - "claimed", req.WalletAddress, 103 - "recovered", recoveredAddr, 104 - ) 105 - helpers.InputError(w, new("recovered address does not match walletAddress")) 106 - return 107 - } 108 - 109 - // Compress the public key (33 bytes). 110 - keyBytes := gethcrypto.CompressPubkey(ecPub) 111 - 112 - // Validate the compressed key is accepted by the atproto library. 113 - pubKey, err := atcrypto.ParsePublicBytesK256(keyBytes) 114 if err != nil { 115 - logger.Error("compressed key rejected by atcrypto", "error", err) 116 - helpers.ServerError(w, nil) 117 return 118 } 119 120 pubDIDKey := pubKey.DIDKey() 121 122 - // Update the PLC DID document if this is a did:plc identity so that the 123 - // new public key is the active atproto verification method. 124 if strings.HasPrefix(repo.Repo.Did, "did:plc:") { 125 log, err := identity.FetchDidAuditLog(ctx, nil, repo.Repo.Did) 126 if err != nil { ··· 135 maps.Copy(newVerificationMethods, latest.Operation.VerificationMethods) 136 newVerificationMethods["atproto"] = pubDIDKey 137 138 - // Replace the PDS rotation key with the user's wallet key. After 139 - // this operation the PDS can no longer unilaterally modify the DID 140 - // document — only the user's Ethereum wallet can authorise future 141 - // PLC operations. This is the moment the identity becomes 142 - // user-sovereign. 143 newRotationKeys := []string{pubDIDKey} 144 145 op := plc.Operation{ ··· 151 Prev: &latest.Cid, 152 } 153 154 - // The PLC operation is signed by the PDS rotation key, which still 155 - // has authority over the DID at this point. This is the last 156 - // operation the PDS will ever be able to sign — it is voluntarily 157 - // handing over control to the user's wallet key. 158 if err := s.plcClient.SignOp(&op); err != nil { 159 logger.Error("error signing PLC operation with rotation key", "error", err) 160 helpers.ServerError(w, nil) ··· 168 } 169 } 170 171 - // Persist the compressed public key. 172 if err := s.db.Exec(ctx, 173 - "UPDATE repos SET public_key = ? WHERE did = ?", 174 - nil, keyBytes, repo.Repo.Did, 175 ).Error; err != nil { 176 - logger.Error("error updating public key in db", "error", err) 177 helpers.ServerError(w, nil) 178 return 179 } ··· 183 logger.Warn("error busting DID doc cache", "error", err) 184 } 185 186 - logger.Info("public signing key registered via BYOK — rotation key transferred to user", 187 "did", repo.Repo.Did, 188 "publicKey", pubDIDKey, 189 ) 190 191 - s.writeJSON(w, 200, ComAtprotoServerSupplySigningKeyResponse{ 192 - Did: repo.Repo.Did, 193 - PublicKey: pubDIDKey, 194 }) 195 }
··· 1 package server 2 3 import ( 4 + "encoding/base64" 5 "encoding/json" 6 "maps" 7 "net/http" 8 "strings" 9 10 "github.com/bluesky-social/indigo/atproto/atcrypto" 11 "pkg.rbrt.fr/vow/identity" 12 "pkg.rbrt.fr/vow/internal/helpers" 13 "pkg.rbrt.fr/vow/models" 14 "pkg.rbrt.fr/vow/plc" 15 ) 16 17 + // SupplySigningKeyRequest is sent by the account page to register a WebAuthn 18 + // passkey as the account's signing key. The browser calls 19 + // navigator.credentials.create() and forwards the raw attestation response 20 + // fields here; the server parses the CBOR attestation object, extracts the 21 + // P-256 public key, and stores it alongside the credential ID. 22 + type SupplySigningKeyRequest struct { 23 + // ClientDataJSON is the base64url-encoded clientDataJSON bytes from the 24 + // AuthenticatorAttestationResponse. 25 + ClientDataJSON string `json:"clientDataJSON" validate:"required"` 26 + // AttestationObject is the base64url-encoded attestationObject CBOR from 27 + // the AuthenticatorAttestationResponse. 28 + AttestationObject string `json:"attestationObject" validate:"required"` 29 } 30 31 + type SupplySigningKeyResponse struct { 32 + Did string `json:"did"` 33 + PublicKey string `json:"publicKey"` // did:key representation 34 + CredentialID string `json:"credentialId"` // base64url 35 } 36 37 + // handleSupplySigningKey registers a WebAuthn passkey for the authenticated 38 + // account. The private key never leaves the authenticator; the PDS stores only 39 + // the compressed P-256 public key and the credential ID. 40 // 41 + // On success, the account's PLC DID document is updated so that the passkey's 42 + // did:key becomes the active atproto verification method and rotation key. 43 func (s *Server) handleSupplySigningKey(w http.ResponseWriter, r *http.Request) { 44 ctx := r.Context() 45 logger := s.logger.With("name", "handleSupplySigningKey") ··· 50 return 51 } 52 53 + var req SupplySigningKeyRequest 54 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 55 logger.Error("error decoding request", "error", err) 56 helpers.InputError(w, new("could not decode request body")) ··· 59 60 if err := s.validator.Struct(req); err != nil { 61 logger.Error("validation failed", "error", err) 62 + helpers.InputError(w, new("clientDataJSON and attestationObject are required")) 63 return 64 } 65 66 + // Parse the attestation object and extract the P-256 public key + 67 + // credential ID. We accept both "none" and self-attestation. 68 + keyBytes, credentialID, err := parseAttestationObject(req.AttestationObject) 69 if err != nil { 70 + logger.Warn("attestation parsing failed", "error", err) 71 + helpers.InputError(w, new("could not parse attestation object")) 72 return 73 } 74 75 + // Validate the compressed key is a well-formed P-256 point. 76 + pubKey, err := atcrypto.ParsePublicBytesP256(keyBytes) 77 if err != nil { 78 + logger.Error("compressed P-256 key rejected by atcrypto", "error", err) 79 + helpers.InputError(w, new("invalid P-256 public key in attestation")) 80 return 81 } 82 83 pubDIDKey := pubKey.DIDKey() 84 85 + // Update the PLC DID document so the passkey's did:key becomes the active 86 + // atproto verification method and the sole rotation key. 87 if strings.HasPrefix(repo.Repo.Did, "did:plc:") { 88 log, err := identity.FetchDidAuditLog(ctx, nil, repo.Repo.Did) 89 if err != nil { ··· 98 maps.Copy(newVerificationMethods, latest.Operation.VerificationMethods) 99 newVerificationMethods["atproto"] = pubDIDKey 100 101 + // Replace the PDS rotation key with the passkey's did:key. After this 102 + // operation the PDS can no longer unilaterally modify the DID document 103 + // — only the user's passkey can authorise future PLC operations. 104 newRotationKeys := []string{pubDIDKey} 105 106 op := plc.Operation{ ··· 112 Prev: &latest.Cid, 113 } 114 115 + // The PDS rotation key signs this PLC operation — this is the last 116 + // PLC operation the PDS will ever be able to sign on behalf of the 117 + // user. It is voluntarily handing over control to the passkey. 118 if err := s.plcClient.SignOp(&op); err != nil { 119 logger.Error("error signing PLC operation with rotation key", "error", err) 120 helpers.ServerError(w, nil) ··· 128 } 129 } 130 131 + // Persist the compressed P-256 public key and credential ID. 132 if err := s.db.Exec(ctx, 133 + "UPDATE repos SET public_key = ?, credential_id = ? WHERE did = ?", 134 + nil, keyBytes, credentialID, repo.Repo.Did, 135 ).Error; err != nil { 136 + logger.Error("error updating public key and credential ID in db", "error", err) 137 helpers.ServerError(w, nil) 138 return 139 } ··· 143 logger.Warn("error busting DID doc cache", "error", err) 144 } 145 146 + logger.Info("passkey registered — rotation key transferred to user", 147 "did", repo.Repo.Did, 148 "publicKey", pubDIDKey, 149 + "credentialIDLen", len(credentialID), 150 ) 151 152 + s.writeJSON(w, 200, SupplySigningKeyResponse{ 153 + Did: repo.Repo.Did, 154 + PublicKey: pubDIDKey, 155 + CredentialID: base64.RawURLEncoding.EncodeToString(credentialID), 156 }) 157 }
+108 -19
server/handle_signer_connect.go
··· 3 import ( 4 "encoding/base64" 5 "encoding/json" 6 "net/http" 7 "strings" 8 "time" ··· 35 Type string `json:"type"` // always "sign_request" 36 RequestID string `json:"requestId"` // UUID, echoed back in the response 37 Did string `json:"did"` 38 - Payload string `json:"payload"` // base64url-encoded unsigned commit CBOR 39 Ops []PendingWriteOp `json:"ops"` // human-readable summary shown to user 40 ExpiresAt string `json:"expiresAt"` // RFC3339 41 } 42 43 // wsIncoming is used for initial type-sniffing before full decode. 44 type wsIncoming struct { 45 - Type string `json:"type"` 46 - RequestID string `json:"requestId"` 47 - // sign_response: base64url-encoded signature bytes. 48 - Signature string `json:"signature,omitempty"` 49 } 50 51 // handleSignerConnect upgrades the connection to a WebSocket and registers it ··· 57 // 58 // 1. When a write handler needs a signature it calls SignerHub.RequestSignature 59 // which pushes a signerRequest onto the conn.requests channel. 60 - // 2. This goroutine picks it up, writes the sign_request (or pay_request) JSON 61 - // frame, and waits for a sign_response / pay_response or their reject 62 - // counterparts from the client. 63 - // 3. The reply is forwarded back to the waiting write handler via the reply 64 - // channel inside the signerRequest. 65 // 66 // The loop also handles WebSocket ping/pong: the server sends a ping every 20 s 67 // and expects a pong within 10 s (gorilla handles pong automatically). ··· 135 // inbound carries decoded messages from the reader goroutine. 136 inbound := make(chan wsIncoming, 4) 137 138 - // nextReq carries the next queued request to be sent to the wallet. 139 - // The NextRequest goroutine blocks until a request is ready and no other 140 - // request is in-flight (serialising wallet prompts automatically). 141 nextReq := make(chan signerRequest, 1) 142 143 ctx := r.Context() 144 145 // Read pump: conn.ReadMessage blocks so it runs in its own goroutine. ··· 164 }() 165 166 // Queue pump: feeds the main loop one request at a time, respecting the 167 - // wallet's one-at-a-time constraint enforced inside NextRequest. 168 go func() { 169 for { 170 req, ok := sc.NextRequest(ctx) ··· 206 case in := <-inbound: 207 switch in.Type { 208 case "sign_response": 209 - if in.Signature == "" { 210 - logger.Warn("signer: sign_response missing signature", "did", did) 211 continue 212 } 213 - sigBytes, err := base64.RawURLEncoding.DecodeString(in.Signature) 214 if err != nil { 215 - logger.Warn("signer: sign_response bad base64url", "did", did, "error", err) 216 continue 217 } 218 - if !s.signerHub.DeliverSignature(did, in.RequestID, sigBytes) { 219 logger.Warn("signer: sign_response for unknown requestId", "did", did, "requestId", in.RequestID) 220 } 221 222 case "sign_reject": 223 if !s.signerHub.DeliverRejection(did, in.RequestID) { 224 logger.Warn("signer: sign_reject for unknown requestId", "did", did, "requestId", in.RequestID) 225 } ··· 234 logger.Error("signer: failed to write request", "did", did, "error", err) 235 req.reply <- signerReply{err: helpers.ErrSignerNotConnected} 236 return 237 } 238 239 logger.Info("signer: request sent", "did", did, "requestId", req.requestID) ··· 269 Ops: ops, 270 ExpiresAt: expiresAt.UTC().Format(time.RFC3339), 271 }) 272 } 273 274 // isTokenExpired returns true if the JWT's exp claim is in the past.
··· 3 import ( 4 "encoding/base64" 5 "encoding/json" 6 + "log/slog" 7 "net/http" 8 "strings" 9 "time" ··· 36 Type string `json:"type"` // always "sign_request" 37 RequestID string `json:"requestId"` // UUID, echoed back in the response 38 Did string `json:"did"` 39 + Payload string `json:"payload"` // base64url-encoded unsigned commit CBOR (used as the WebAuthn challenge) 40 Ops []PendingWriteOp `json:"ops"` // human-readable summary shown to user 41 ExpiresAt string `json:"expiresAt"` // RFC3339 42 } 43 44 // wsIncoming is used for initial type-sniffing before full decode. 45 + // 46 + // sign_response carries the three fields from the WebAuthn AuthenticatorAssertionResponse: 47 + // - AuthenticatorData: base64url authenticatorData bytes 48 + // - ClientDataJSON: base64url clientDataJSON bytes 49 + // - Signature: base64url DER-encoded ECDSA signature 50 type wsIncoming struct { 51 + Type string `json:"type"` 52 + RequestID string `json:"requestId"` 53 + AuthenticatorData string `json:"authenticatorData,omitempty"` // base64url 54 + ClientDataJSON string `json:"clientDataJSON,omitempty"` // base64url 55 + Signature string `json:"signature,omitempty"` // base64url DER-encoded ECDSA 56 } 57 58 // handleSignerConnect upgrades the connection to a WebSocket and registers it ··· 64 // 65 // 1. When a write handler needs a signature it calls SignerHub.RequestSignature 66 // which pushes a signerRequest onto the conn.requests channel. 67 + // 2. This goroutine picks it up, writes the sign_request JSON frame, and waits 68 + // for a sign_response or sign_reject from the client. 69 + // 3. The WebAuthn assertion is verified here; the resulting raw (r‖s) signature 70 + // bytes are forwarded to the waiting write handler via DeliverSignature. 71 // 72 // The loop also handles WebSocket ping/pong: the server sends a ping every 20 s 73 // and expects a pong within 10 s (gorilla handles pong automatically). ··· 141 // inbound carries decoded messages from the reader goroutine. 142 inbound := make(chan wsIncoming, 4) 143 144 + // nextReq carries the next queued request to be sent to the signer. 145 nextReq := make(chan signerRequest, 1) 146 147 + // pendingPayloads maps requestID → base64url payload so that when a 148 + // sign_response arrives we can reconstruct the expected WebAuthn challenge 149 + // (the raw bytes that the payload string encodes). 150 + pendingPayloads := make(map[string]string) 151 + 152 ctx := r.Context() 153 154 // Read pump: conn.ReadMessage blocks so it runs in its own goroutine. ··· 173 }() 174 175 // Queue pump: feeds the main loop one request at a time, respecting the 176 + // passkey's one-at-a-time constraint enforced inside NextRequest. 177 go func() { 178 for { 179 req, ok := sc.NextRequest(ctx) ··· 215 case in := <-inbound: 216 switch in.Type { 217 case "sign_response": 218 + payload, ok := pendingPayloads[in.RequestID] 219 + if !ok { 220 + logger.Warn("signer: sign_response for unknown requestId (no payload)", "did", did, "requestId", in.RequestID) 221 continue 222 } 223 + delete(pendingPayloads, in.RequestID) 224 + 225 + rawSig, err := verifyWebAuthnSignResponse(repo.PublicKey, payload, in, s.config.Hostname, logger) 226 if err != nil { 227 + logger.Warn("signer: sign_response verification failed", "did", did, "requestId", in.RequestID, "error", err) 228 continue 229 } 230 + if !s.signerHub.DeliverSignature(did, in.RequestID, rawSig) { 231 logger.Warn("signer: sign_response for unknown requestId", "did", did, "requestId", in.RequestID) 232 } 233 234 case "sign_reject": 235 + delete(pendingPayloads, in.RequestID) 236 if !s.signerHub.DeliverRejection(did, in.RequestID) { 237 logger.Warn("signer: sign_reject for unknown requestId", "did", did, "requestId", in.RequestID) 238 } ··· 247 logger.Error("signer: failed to write request", "did", did, "error", err) 248 req.reply <- signerReply{err: helpers.ErrSignerNotConnected} 249 return 250 + } 251 + 252 + // Record the payload so we can verify the WebAuthn challenge when 253 + // the sign_response arrives. 254 + if payload, err := extractPayloadFromMsg(req.msg); err == nil { 255 + pendingPayloads[req.requestID] = payload 256 + } else { 257 + logger.Warn("signer: could not extract payload from sign_request", "did", did, "error", err) 258 } 259 260 logger.Info("signer: request sent", "did", did, "requestId", req.requestID) ··· 290 Ops: ops, 291 ExpiresAt: expiresAt.UTC().Format(time.RFC3339), 292 }) 293 + } 294 + 295 + // extractPayloadFromMsg extracts the "payload" field from a sign_request JSON 296 + // message without a full re-parse. 297 + func extractPayloadFromMsg(msg []byte) (string, error) { 298 + var req struct { 299 + Payload string `json:"payload"` 300 + } 301 + if err := json.Unmarshal(msg, &req); err != nil { 302 + return "", err 303 + } 304 + if req.Payload == "" { 305 + return "", nil 306 + } 307 + return req.Payload, nil 308 + } 309 + 310 + // verifyWebAuthnSignResponse decodes the three base64url fields from a 311 + // sign_response message, reconstructs the expected challenge from the payload, 312 + // verifies the WebAuthn P-256 assertion, and returns the raw 64-byte (r‖s) 313 + // signature for use in ATProto commits and JWTs. 314 + // 315 + // pubKey is the compressed P-256 public key stored in the database. 316 + // payloadB64 is the base64url-encoded challenge bytes that were sent in the 317 + // sign_request (the raw CBOR bytes of the unsigned commit, or the SHA-256 of 318 + // the JWT signing input for service-auth tokens). 319 + func verifyWebAuthnSignResponse( 320 + pubKey []byte, 321 + payloadB64 string, 322 + in wsIncoming, 323 + rpID string, 324 + logger *slog.Logger, 325 + ) ([]byte, error) { 326 + if in.AuthenticatorData == "" || in.ClientDataJSON == "" || in.Signature == "" { 327 + return nil, helpers.ErrSignerNotConnected // reuse a sentinel; caller logs 328 + } 329 + 330 + // The challenge passed to navigator.credentials.get() was the raw bytes 331 + // decoded from payloadB64. The browser re-encodes them as base64url in 332 + // clientDataJSON.challenge — so the expected challenge is exactly those 333 + // raw bytes. 334 + expectedChallenge, err := base64.RawURLEncoding.DecodeString(payloadB64) 335 + if err != nil { 336 + return nil, err 337 + } 338 + 339 + clientDataJSONBytes, err := base64.RawURLEncoding.DecodeString(in.ClientDataJSON) 340 + if err != nil { 341 + return nil, err 342 + } 343 + 344 + authenticatorDataBytes, err := base64.RawURLEncoding.DecodeString(in.AuthenticatorData) 345 + if err != nil { 346 + return nil, err 347 + } 348 + 349 + signatureDER, err := base64.RawURLEncoding.DecodeString(in.Signature) 350 + if err != nil { 351 + return nil, err 352 + } 353 + 354 + rawSig, err := verifyAssertion(pubKey, expectedChallenge, clientDataJSONBytes, authenticatorDataBytes, signatureDER, rpID) 355 + if err != nil { 356 + logger.With("rpID", rpID).Debug("verifyAssertion detail", "error", err) 357 + return nil, err 358 + } 359 + 360 + return rawSig, nil 361 } 362 363 // isTokenExpired returns true if the JWT's exp claim is in the past.
+13 -10
server/middleware.go
··· 155 repo = maybeRepo 156 } 157 158 - if token.Header["alg"] != "ES256K" { 159 token, err = new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) { 160 if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok { 161 return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"]) 162 } 163 - return s.privateKey.Public(), nil 164 }) 165 if err != nil { 166 logger.Error("error parsing jwt", "error", err) ··· 191 if repo == nil { 192 sub, ok := claims["sub"].(string) 193 if !ok { 194 - s.logger.Error("no sub claim in ES256K token and repo not set") 195 helpers.InvalidTokenError(w) 196 return 197 } 198 maybeRepo, err := s.getRepoActorByDid(ctx, sub) 199 if err != nil { 200 - s.logger.Error("error fetching repo for ES256K verification", "error", err) 201 helpers.ServerError(w, nil) 202 return 203 } ··· 205 did = sub 206 } 207 208 - // The PDS never holds a private key. Verify the ES256K JWT 209 - // signature using the compressed public key stored in PublicKey. 210 if len(repo.PublicKey) == 0 { 211 logger.Error("no public key registered for account", "did", repo.Repo.Did) 212 helpers.ServerError(w, nil) 213 return 214 } 215 216 - pubKey, err := atcrypto.ParsePublicBytesK256(repo.PublicKey) 217 if err != nil { 218 logger.Error("can't parse stored public key", "error", err) 219 helpers.ServerError(w, nil) 220 return 221 } 222 223 - // sigBytes is already the compact (r||s) 64-byte form. Verify 224 - // using HashAndVerifyLenient which hashes signingInput internally. 225 if err := pubKey.HashAndVerifyLenient([]byte(signingInput), sigBytes); err != nil { 226 - logger.Error("ES256K signature verification failed", "error", err) 227 helpers.ServerError(w, nil) 228 return 229 }
··· 155 repo = maybeRepo 156 } 157 158 + // isUserSignedToken is true for service-auth JWTs signed by the user's 159 + // passkey (ES256 with an lxm claim). Regular access tokens use ES256 160 + // too but are signed by the PDS private key and carry no lxm claim. 161 + isUserSignedToken := token.Header["alg"] == "ES256" && hasLxm 162 + 163 + if !isUserSignedToken { 164 token, err = new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) { 165 if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok { 166 return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"]) 167 } 168 + return &s.privateKey.PublicKey, nil 169 }) 170 if err != nil { 171 logger.Error("error parsing jwt", "error", err) ··· 196 if repo == nil { 197 sub, ok := claims["sub"].(string) 198 if !ok { 199 + s.logger.Error("no sub claim in user-signed token and repo not set") 200 helpers.InvalidTokenError(w) 201 return 202 } 203 maybeRepo, err := s.getRepoActorByDid(ctx, sub) 204 if err != nil { 205 + s.logger.Error("error fetching repo for user-signed token verification", "error", err) 206 helpers.ServerError(w, nil) 207 return 208 } ··· 210 did = sub 211 } 212 213 + // The PDS never holds the user's private key. Verify the JWT 214 + // signature using the compressed P-256 public key stored in the DB. 215 if len(repo.PublicKey) == 0 { 216 logger.Error("no public key registered for account", "did", repo.Repo.Did) 217 helpers.ServerError(w, nil) 218 return 219 } 220 221 + pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey) 222 if err != nil { 223 logger.Error("can't parse stored public key", "error", err) 224 helpers.ServerError(w, nil) 225 return 226 } 227 228 if err := pubKey.HashAndVerifyLenient([]byte(signingInput), sigBytes); err != nil { 229 + logger.Error("user-signed JWT verification failed", "error", err) 230 helpers.ServerError(w, nil) 231 return 232 }
+3 -3
server/repo.go
··· 219 // provided raw signature bytes, reserialises the commit, writes the commit 220 // block to the blockstore, and returns the commit CID. 221 // 222 - // sig must be the raw secp256k1 signature (compact or DER) over uc.cbor as 223 - // produced by an Ethereum wallet's personal_sign / eth_sign call. 224 func finaliseCommit(ctx context.Context, bs blockstore.Blockstore, uc *unsignedCommit, sig []byte) (cid.Cid, error) { 225 // Decode the unsigned commit so we can attach the signature field. 226 var commit atp.Commit ··· 599 return nil, fmt.Errorf("no public key registered for account %s", urepo.Did) 600 } 601 602 - pubKey, err := atcrypto.ParsePublicBytesK256(urepo.PublicKey) 603 if err != nil { 604 return nil, fmt.Errorf("parsing stored public key: %w", err) 605 }
··· 219 // provided raw signature bytes, reserialises the commit, writes the commit 220 // block to the blockstore, and returns the commit CID. 221 // 222 + // sig must be the raw 64-byte (r‖s) P-256 ECDSA signature over uc.cbor as 223 + // produced by the passkey WebAuthn assertion and verified by the WS handler. 224 func finaliseCommit(ctx context.Context, bs blockstore.Blockstore, uc *unsignedCommit, sig []byte) (cid.Cid, error) { 225 // Decode the unsigned commit so we can attach the signature field. 226 var commit atp.Commit ··· 599 return nil, fmt.Errorf("no public key registered for account %s", urepo.Did) 600 } 601 602 + pubKey, err := atcrypto.ParsePublicBytesP256(urepo.PublicKey) 603 if err != nil { 604 return nil, fmt.Errorf("parsing stored public key: %w", err) 605 }
+2
server/server.go
··· 516 r.Post("/account/signup", s.handleAccountSignupPost) 517 r.Get("/account/signout", s.handleAccountSignout) 518 r.With(s.handleWebSessionMiddleware).Post("/account/supply-signing-key", s.handleSupplySigningKey) 519 r.With(s.handleWebSessionMiddleware).Post("/account/delete", s.handleAccountDelete) 520 r.Get("/account/signer", s.handleAccountSigner) 521
··· 516 r.Post("/account/signup", s.handleAccountSignupPost) 517 r.Get("/account/signout", s.handleAccountSignout) 518 r.With(s.handleWebSessionMiddleware).Post("/account/supply-signing-key", s.handleSupplySigningKey) 519 + r.With(s.handleWebSessionMiddleware).Post("/account/passkey-challenge", s.handlePasskeyChallenge) 520 + r.With(s.handleWebSessionMiddleware).Post("/account/passkey-assertion-challenge", s.handlePasskeyAssertionChallenge) 521 r.With(s.handleWebSessionMiddleware).Post("/account/delete", s.handleAccountDelete) 522 r.Get("/account/signer", s.handleAccountSigner) 523
+12 -12
server/signer_hub.go
··· 31 32 // signerConn represents one active signer WebSocket connection for a DID. 33 // It owns an unbounded queue of pending requests and a map of in-flight 34 - // requests waiting for a reply from the wallet. 35 type signerConn struct { 36 // mu protects pending and inflight. 37 mu sync.Mutex 38 39 // pending is an ordered queue of requests that have not yet been sent to 40 - // the wallet. The WS goroutine drains it one at a time: it pops the head, 41 // sends the sign_request frame, moves the request into inflight, and only 42 - // pops the next one after a reply arrives. This serialises wallet prompts 43 // (the user must confirm each one before the next appears) while allowing 44 // any number of callers to enqueue work concurrently. 45 pending []signerRequest 46 47 - // inflight holds the single request that has been sent to the wallet and 48 // is awaiting a sign_response / sign_reject. Keyed by requestID. 49 inflight map[string]signerRequest 50 ··· 127 } 128 129 // RequestSignature enqueues a signing request for did and blocks until one of: 130 - // - The wallet sends sign_response → returns the signature bytes. 131 - // - The wallet sends sign_reject → returns helpers.ErrSignerRejected. 132 // - The WebSocket disconnects → returns helpers.ErrSignerNotConnected. 133 // - ctx is cancelled or times out → returns helpers.ErrSignerTimeout or ctx.Err(). 134 // 135 // Multiple callers for the same DID are all queued and processed sequentially 136 - // (the wallet sees one prompt at a time). Callers for different DIDs are 137 // independent. 138 func (h *SignerHub) RequestSignature(ctx context.Context, did string, requestID string, msg []byte) ([]byte, error) { 139 h.mu.Lock() ··· 243 return true 244 } 245 246 - // NextRequest blocks until a pending request is ready to be sent to the wallet 247 - // and no other request is currently in-flight (wallets handle one prompt at a 248 - // time). It moves the request from pending into inflight before returning, so 249 - // the caller just needs to write it to the WebSocket. 250 // 251 // Returns (request, true) on success, or (zero, false) if the connection is 252 // going away (done closed or ctx cancelled). 253 func (conn *signerConn) NextRequest(ctx context.Context) (signerRequest, bool) { 254 for { 255 conn.mu.Lock() 256 - // Only dequeue when nothing is in-flight (wallet is free). 257 if len(conn.pending) > 0 && len(conn.inflight) == 0 { 258 req := conn.pending[0] 259 conn.pending = conn.pending[1:]
··· 31 32 // signerConn represents one active signer WebSocket connection for a DID. 33 // It owns an unbounded queue of pending requests and a map of in-flight 34 + // requests waiting for a reply from the passkey. 35 type signerConn struct { 36 // mu protects pending and inflight. 37 mu sync.Mutex 38 39 // pending is an ordered queue of requests that have not yet been sent to 40 + // the passkey. The WS goroutine drains it one at a time: it pops the head, 41 // sends the sign_request frame, moves the request into inflight, and only 42 + // pops the next one after a reply arrives. This serialises passkey prompts 43 // (the user must confirm each one before the next appears) while allowing 44 // any number of callers to enqueue work concurrently. 45 pending []signerRequest 46 47 + // inflight holds the single request that has been sent to the passkey and 48 // is awaiting a sign_response / sign_reject. Keyed by requestID. 49 inflight map[string]signerRequest 50 ··· 127 } 128 129 // RequestSignature enqueues a signing request for did and blocks until one of: 130 + // - The signer sends sign_response → returns the signature bytes. 131 + // - The signer sends sign_reject → returns helpers.ErrSignerRejected. 132 // - The WebSocket disconnects → returns helpers.ErrSignerNotConnected. 133 // - ctx is cancelled or times out → returns helpers.ErrSignerTimeout or ctx.Err(). 134 // 135 // Multiple callers for the same DID are all queued and processed sequentially 136 + // (the passkey sees one prompt at a time). Callers for different DIDs are 137 // independent. 138 func (h *SignerHub) RequestSignature(ctx context.Context, did string, requestID string, msg []byte) ([]byte, error) { 139 h.mu.Lock() ··· 243 return true 244 } 245 246 + // NextRequest blocks until a pending request is ready to be sent to the signer 247 + // and no other request is currently in-flight (the passkey handles one prompt 248 + // at a time). It moves the request from pending into inflight before returning, 249 + // so the caller just needs to write it to the WebSocket. 250 // 251 // Returns (request, true) on success, or (zero, false) if the connection is 252 // going away (done closed or ctx cancelled). 253 func (conn *signerConn) NextRequest(ctx context.Context) (signerRequest, bool) { 254 for { 255 conn.mu.Lock() 256 + // Only dequeue when nothing is in-flight (passkey is free). 257 if len(conn.pending) > 0 && len(conn.inflight) == 0 { 258 req := conn.pending[0] 259 conn.pending = conn.pending[1:]
+306 -543
server/templates/account.html
··· 55 <h3>Signing Key</h3> 56 {{ if .HasSigningKey }} 57 <p> 58 - A signing key is registered for this account.<br /> 59 <small style="opacity: 0.7" 60 - >Ethereum address: 61 - <code>{{ .EthereumAddress }}</code></small 62 > 63 </p> 64 {{ else }} 65 <p> 66 - No signing key is registered yet. Connect your Ethereum 67 - wallet to register your public key with this PDS. 68 </p> 69 {{ end }} 70 ··· 75 76 <div class="button-row"> 77 <button id="btn-register-key" class="primary"> 78 - {{ if .HasSigningKey }}Update signing key{{ else 79 - }}Register signing key{{ end }} 80 </button> 81 </div> 82 </div> ··· 87 <h3>Signer</h3> 88 <p> 89 The signer connects to your PDS over a WebSocket and signs 90 - commits using your Ethereum wallet. Keep this page open (a 91 - pinned tab works great) to sign requests automatically. 92 </p> 93 94 <div ··· 188 {{ if .HasSigningKey }} 189 <p> 190 <small style="opacity: 0.7"> 191 - Your wallet (<code>{{ .EthereumAddress }}</code>) 192 - will be asked to sign a confirmation message. No 193 - transaction will be broadcast. 194 </small> 195 </p> 196 <div class="button-row"> ··· 201 {{ else }} 202 <p> 203 <small style="opacity: 0.7"> 204 - Account deletion requires a registered signing key 205 - so your wallet can confirm the request. Please 206 - register your wallet above first. 207 </small> 208 </p> 209 {{ end }} ··· 214 <p><strong>Are you absolutely sure?</strong></p> 215 <p> 216 <small style="opacity: 0.7"> 217 - Clicking the button below will prompt your wallet to 218 - sign 219 - <code style="word-break: break-all" 220 - >"Delete account: {{ .Did }}"</code 221 >. The server will verify the signature and 222 permanently erase all your data. This cannot be 223 undone. ··· 225 </p> 226 <div class="button-row"> 227 <button id="btn-delete-confirm" class="danger"> 228 - Yes, sign and delete my account 229 </button> 230 <button id="btn-delete-cancel">Cancel</button> 231 </div> ··· 235 236 <script> 237 // --------------------------------------------------------------------------- 238 - // Signing key registration via window.ethereum (EIP-1193) 239 // --------------------------------------------------------------------------- 240 241 const btn = document.getElementById("btn-register-key"); ··· 257 btn.addEventListener("click", async () => { 258 hideMsg(); 259 260 - if (!window.ethereum) { 261 showMsg( 262 - "No Ethereum wallet detected. Please install MetaMask, Rabby, or another EIP-1193 wallet and reload.", 263 "error", 264 ); 265 return; 266 } 267 268 btn.disabled = true; 269 - btn.textContent = "Connecting…"; 270 271 try { 272 - // 1. Request accounts 273 - const accounts = await window.ethereum.request({ 274 - method: "eth_requestAccounts", 275 }); 276 - if (!accounts || accounts.length === 0) { 277 - throw new Error("No accounts returned from wallet."); 278 } 279 - const account = accounts[0]; 280 281 - // 2. Sign the fixed registration message. 282 - btn.textContent = "Sign the message in your wallet…"; 283 - const signature = await window.ethereum.request({ 284 - method: "personal_sign", 285 - params: ["Vow key registration", account], 286 }); 287 288 - // 3. POST signature + address; the server recovers the key. 289 btn.textContent = "Registering…"; 290 const res = await fetch("/account/supply-signing-key", { 291 method: "POST", 292 headers: { "Content-Type": "application/json" }, 293 body: JSON.stringify({ 294 - walletAddress: account, 295 - signature, 296 }), 297 }); 298 ··· 300 const body = await res.json().catch(() => ({})); 301 throw new Error( 302 body.message || 303 - `Key registration failed (${res.status})`, 304 ); 305 } 306 307 - showMsg("Signing key registered! Reloading…", "success"); 308 setTimeout(() => window.location.reload(), 1000); 309 } catch (err) { 310 showMsg(err.message || String(err), "error"); 311 btn.textContent = 312 - "{{ if .HasSigningKey }}Update signing key{{ else }}Register signing key{{ end }}"; 313 } finally { 314 btn.disabled = false; 315 } 316 }); 317 318 // --------------------------------------------------------------------------- 319 - // Browser Signer — WebSocket + signing loop 320 // --------------------------------------------------------------------------- 321 322 {{ if .HasSigningKey }} 323 (function () { 324 const dot = document.getElementById("signer-dot"); 325 const statusEl = document.getElementById("signer-status"); 326 const signerMsg = document.getElementById("signer-msg"); ··· 336 let intentionalDisconnect = false; 337 let pendingRequestId = null; 338 339 - // Wallet address cached after first eth_requestAccounts call 340 - let walletAddress = null; 341 - 342 // --------------------------------------------------------------------------- 343 // Notification permission 344 // --------------------------------------------------------------------------- 345 346 function requestNotificationPermission() { 347 - if ("Notification" in window && Notification.permission === "default") { 348 Notification.requestPermission(); 349 } 350 } 351 352 function showNotification(title, body) { 353 - if ("Notification" in window && Notification.permission === "granted") { 354 try { 355 const n = new Notification(title, { 356 body: body, ··· 363 n.close(); 364 }; 365 } catch (e) { 366 - // Notifications not supported in this context 367 } 368 } 369 } ··· 381 function setState(state, detail) { 382 dot.style.background = STATE_COLORS[state] || "#ef4444"; 383 const labels = { 384 - connected: "Connected — listening for signing requests", 385 connecting: "Connecting…", 386 disconnected: "Disconnected", 387 }; 388 - statusEl.textContent = detail || labels[state] || state; 389 390 if (state === "connected") { 391 btnConnect.style.display = "none"; ··· 412 function showPending(ops) { 413 if (ops && ops.length > 0) { 414 pendingOpsEl.textContent = ops 415 - .map((op) => op.type + " " + op.collection + "/" + op.rkey) 416 .join(", "); 417 } else { 418 pendingOpsEl.textContent = "(details unavailable)"; ··· 425 } 426 427 // --------------------------------------------------------------------------- 428 - // Wallet access 429 - // --------------------------------------------------------------------------- 430 - 431 - async function getWalletAddress() { 432 - if (walletAddress) return walletAddress; 433 - if (!window.ethereum) { 434 - throw new Error( 435 - "No Ethereum wallet detected. Please install MetaMask, Rabby, or another EIP-1193 wallet.", 436 - ); 437 - } 438 - const accounts = await window.ethereum.request({ 439 - method: "eth_requestAccounts", 440 - }); 441 - if (!accounts || accounts.length === 0) { 442 - throw new Error("No accounts returned from wallet."); 443 - } 444 - walletAddress = accounts[0]; 445 - return walletAddress; 446 - } 447 - 448 - // --------------------------------------------------------------------------- 449 // WebSocket connection 450 // --------------------------------------------------------------------------- 451 452 function connect() { 453 - if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { 454 return; 455 } 456 ··· 459 setState("connecting"); 460 hideSignerMsg(); 461 462 - const wsScheme = location.protocol === "https:" ? "wss" : "ws"; 463 - const url = wsScheme + "://" + location.host + "/account/signer"; 464 465 try { 466 ws = new WebSocket(url); 467 } catch (err) { 468 - console.error("[vow/signer] WebSocket constructor error", err); 469 scheduleReconnect(); 470 return; 471 } ··· 483 484 ws.addEventListener("close", (event) => { 485 console.warn( 486 - "[vow/signer] closed: code=" + event.code + 487 - " reason=" + event.reason + 488 - " clean=" + event.wasClean, 489 ); 490 ws = null; 491 hidePending(); ··· 494 if (intentionalDisconnect) { 495 setState("disconnected"); 496 } else { 497 - setState("disconnected", "Disconnected — reconnecting…"); 498 scheduleReconnect(); 499 } 500 }); ··· 518 function scheduleReconnect() { 519 clearReconnectTimer(); 520 const delay = reconnectDelay; 521 - reconnectDelay = Math.min(reconnectDelay * 2, MAX_DELAY); 522 - console.log("[vow/signer] reconnecting in " + delay + "ms"); 523 reconnectTimer = setTimeout(connect, delay); 524 } 525 ··· 534 if (ws && ws.readyState === WebSocket.OPEN) { 535 ws.send(data); 536 } else { 537 - console.error("[vow/signer] cannot send — WebSocket not open"); 538 } 539 } 540 ··· 553 554 if (msg.type === "sign_request") { 555 handleSignRequest(msg); 556 - } else if (msg.type === "pay_request") { 557 - handlePayRequest(msg); 558 } else { 559 - console.log("[vow/signer] unknown message type", msg.type); 560 } 561 } 562 563 // --------------------------------------------------------------------------- 564 - // sign_request — EIP-191 personal_sign 565 // --------------------------------------------------------------------------- 566 567 async function handleSignRequest(msg) { 568 const { requestId, payload, ops, expiresAt } = msg; 569 570 if (!requestId || !payload) { 571 - console.warn("[vow/signer] malformed sign_request", msg); 572 return; 573 } 574 575 if (expiresAt && new Date(expiresAt) <= new Date()) { 576 - console.warn("[vow/signer] sign_request expired", requestId); 577 wsSend(buildSignReject(requestId)); 578 return; 579 } 580 581 if (pendingRequestId) { 582 - console.warn("[vow/signer] already pending — rejecting", requestId); 583 wsSend(buildSignReject(requestId)); 584 return; 585 } 586 587 pendingRequestId = requestId; 588 showPending(ops); 589 - showNotification("Vow — Signing Request", opsToSummary(ops)); 590 591 try { 592 - const addr = await getWalletAddress(); 593 - const payloadHex = "0x" + base64urlToHex(payload); 594 - const signature = await window.ethereum.request({ 595 - method: "personal_sign", 596 - params: [payloadHex, addr], 597 - }); 598 - wsSend(buildSignResponse(requestId, signature)); 599 - } catch (err) { 600 - console.warn("[vow/signer] signing failed", err); 601 - wsSend(buildSignReject(requestId)); 602 - } finally { 603 - pendingRequestId = null; 604 - hidePending(); 605 - } 606 - } 607 608 - // --------------------------------------------------------------------------- 609 - // pay_request — EIP-712 eth_signTypedData_v4 610 - // --------------------------------------------------------------------------- 611 612 - async function handlePayRequest(msg) { 613 - const { requestId, walletAddress: payerAddress, typedData, description, expiresAt } = msg; 614 - 615 - if (!requestId || !payerAddress || !typedData) { 616 - console.warn("[vow/signer] malformed pay_request", msg); 617 - return; 618 - } 619 - 620 - if (expiresAt && new Date(expiresAt) <= new Date()) { 621 - console.warn("[vow/signer] pay_request expired", requestId); 622 - wsSend(buildPayReject(requestId)); 623 - return; 624 - } 625 626 - if (pendingRequestId) { 627 - console.warn("[vow/signer] already pending — rejecting", requestId); 628 - wsSend(buildPayReject(requestId)); 629 - return; 630 - } 631 - 632 - pendingRequestId = requestId; 633 - showPending([{ type: "payment", collection: description || "x402", rkey: "" }]); 634 - showNotification("Vow — Payment Request", description || "x402 payment signing required"); 635 636 - try { 637 - const addr = await getWalletAddress(); 638 - const typedDataStr = 639 - typeof typedData === "string" 640 - ? typedData 641 - : JSON.stringify(typedData); 642 - const signature = await window.ethereum.request({ 643 - method: "eth_signTypedData_v4", 644 - params: [payerAddress, typedDataStr], 645 - }); 646 - wsSend(buildPayResponse(requestId, signature)); 647 } catch (err) { 648 - console.warn("[vow/signer] payment signing failed", err); 649 - wsSend(buildPayReject(requestId)); 650 } finally { 651 pendingRequestId = null; 652 hidePending(); ··· 657 // Protocol message builders 658 // --------------------------------------------------------------------------- 659 660 - function buildSignResponse(requestId, signatureHex) { 661 - const hex = signatureHex.startsWith("0x") 662 - ? signatureHex.slice(2) 663 - : signatureHex; 664 return JSON.stringify({ 665 type: "sign_response", 666 requestId: requestId, 667 - signature: hexToBase64url(hex), 668 }); 669 } 670 671 function buildSignReject(requestId) { 672 return JSON.stringify({ 673 type: "sign_reject", 674 - requestId: requestId, 675 - }); 676 - } 677 - 678 - function buildPayResponse(requestId, signatureHex) { 679 - return JSON.stringify({ 680 - type: "pay_response", 681 - requestId: requestId, 682 - signature: signatureHex, 683 - }); 684 - } 685 - 686 - function buildPayReject(requestId) { 687 - return JSON.stringify({ 688 - type: "pay_reject", 689 requestId: requestId, 690 }); 691 } 692 693 // --------------------------------------------------------------------------- 694 - // Encoding helpers 695 // --------------------------------------------------------------------------- 696 697 - function base64urlToHex(b64url) { 698 - let b64 = b64url.replace(/-/g, "+").replace(/_/g, "/"); 699 - while (b64.length % 4 !== 0) b64 += "="; 700 - const raw = atob(b64); 701 - let hex = ""; 702 - for (let i = 0; i < raw.length; i++) { 703 - hex += raw.charCodeAt(i).toString(16).padStart(2, "0"); 704 - } 705 - return hex; 706 - } 707 - 708 - function hexToBase64url(hex) { 709 - const bytes = new Uint8Array(hex.length / 2); 710 - for (let i = 0; i < bytes.length; i++) { 711 - bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); 712 - } 713 - let binary = ""; 714 - for (let i = 0; i < bytes.length; i++) { 715 - binary += String.fromCharCode(bytes[i]); 716 - } 717 - return btoa(binary) 718 - .replace(/\+/g, "-") 719 - .replace(/\//g, "_") 720 - .replace(/=+$/, ""); 721 - } 722 - 723 function opsToSummary(ops) { 724 - if (!ops || ops.length === 0) return "A signing request needs your approval."; 725 return ops 726 .map((op) => op.type + " " + op.collection) 727 .join(", "); ··· 732 // --------------------------------------------------------------------------- 733 734 btnConnect.addEventListener("click", () => { 735 - if (!window.ethereum) { 736 showSignerMsg( 737 - "No Ethereum wallet detected. Please install MetaMask, Rabby, or another EIP-1193 wallet.", 738 "error", 739 ); 740 return; ··· 750 // Auto-connect if previously connected (persisted in sessionStorage) 751 // --------------------------------------------------------------------------- 752 753 - if (sessionStorage.getItem("vow-signer-active") === "1" && window.ethereum) { 754 connect(); 755 } 756 ··· 770 {{ end }} 771 772 // --------------------------------------------------------------------------- 773 - // Account deletion — wallet signature confirmation 774 // --------------------------------------------------------------------------- 775 776 (function () { 777 - const step1 = document.getElementById("delete-step-1"); 778 - const step2 = document.getElementById("delete-step-2"); 779 - const msgEl = document.getElementById("delete-msg"); 780 - const btnStart = document.getElementById("btn-delete-start"); 781 - const btnConfirm = document.getElementById("btn-delete-confirm"); 782 - const btnCancel = document.getElementById("btn-delete-cancel"); 783 784 - if (!btnStart) return; // no signing key registered — nothing to wire up 785 786 function showDeleteMsg(text, type) { 787 msgEl.textContent = text; 788 msgEl.style.display = "block"; 789 msgEl.className = 790 - "alert " + (type === "error" ? "alert-danger" : "alert-success"); 791 } 792 793 function hideDeleteMsg() { ··· 810 btnConfirm.addEventListener("click", async () => { 811 hideDeleteMsg(); 812 813 - if (!window.ethereum) { 814 showDeleteMsg( 815 - "No Ethereum wallet detected. Please install MetaMask, Rabby, or another EIP-1193 wallet and reload.", 816 "error", 817 ); 818 return; 819 } 820 821 btnConfirm.disabled = true; 822 - btnCancel.disabled = true; 823 - btnConfirm.textContent = "Connecting to wallet…"; 824 825 try { 826 - const accounts = await window.ethereum.request({ 827 - method: "eth_requestAccounts", 828 - }); 829 - if (!accounts || accounts.length === 0) { 830 - throw new Error("No accounts returned from wallet."); 831 } 832 - const walletAddress = accounts[0]; 833 834 - const did = {{ .Did | js }}; 835 - const message = "Delete account: " + did; 836 837 - btnConfirm.textContent = "Sign the message in your wallet…"; 838 - const signature = await window.ethereum.request({ 839 - method: "personal_sign", 840 - params: [message, walletAddress], 841 }); 842 843 btnConfirm.textContent = "Deleting…"; 844 const res = await fetch("/account/delete", { 845 method: "POST", 846 headers: { "Content-Type": "application/json" }, 847 - body: JSON.stringify({ walletAddress, signature }), 848 }); 849 850 if (!res.ok) { 851 const body = await res.json().catch(() => ({})); 852 - throw new Error(body.message || body.error || `Deletion failed (${res.status})`); 853 } 854 855 - showDeleteMsg("Account deleted. Redirecting…", "success"); 856 - setTimeout(() => { window.location.href = "/"; }, 1500); 857 } catch (err) { 858 showDeleteMsg(err.message || String(err), "error"); 859 - btnConfirm.textContent = "Yes, sign and delete my account"; 860 btnConfirm.disabled = false; 861 - btnCancel.disabled = false; 862 } 863 }); 864 })(); 865 - 866 - // --------------------------------------------------------------------------- 867 - // Crypto helpers (used by key registration) 868 - // --------------------------------------------------------------------------- 869 - 870 - function stringToHex(str) { 871 - const bytes = new TextEncoder().encode(str); 872 - return ( 873 - "0x" + 874 - Array.from(bytes) 875 - .map((b) => b.toString(16).padStart(2, "0")) 876 - .join("") 877 - ); 878 - } 879 - 880 - function bytesToHex(bytes) { 881 - return Array.from(bytes) 882 - .map((b) => b.toString(16).padStart(2, "0")) 883 - .join(""); 884 - } 885 - 886 - function hexToBytes(hex) { 887 - hex = hex.replace(/^0x/, ""); 888 - const out = new Uint8Array(hex.length / 2); 889 - for (let i = 0; i < out.length; i++) { 890 - out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); 891 - } 892 - return out; 893 - } 894 - 895 - function ethereumSignedMessageHash(message) { 896 - const msgBytes = new TextEncoder().encode(message); 897 - const prefix = new TextEncoder().encode( 898 - "\x19Ethereum Signed Message:\n" + msgBytes.length, 899 - ); 900 - const combined = new Uint8Array( 901 - prefix.length + msgBytes.length, 902 - ); 903 - combined.set(prefix); 904 - combined.set(msgBytes, prefix.length); 905 - return keccak256(combined); 906 - } 907 - 908 - function parseSignature(hexSig) { 909 - const bytes = hexToBytes(hexSig); 910 - const r = bytes.slice(0, 32); 911 - const s = bytes.slice(32, 64); 912 - let v = bytes[64]; 913 - if (v === 0 || v === 1) v += 27; 914 - return { r, s, v }; 915 - } 916 - 917 - // secp256k1 curve parameters 918 - const P = 919 - 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn; 920 - const N = 921 - 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n; 922 - const B = 7n; 923 - const GX = 924 - 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n; 925 - const GY = 926 - 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n; 927 - 928 - function modp(n) { 929 - return ((n % P) + P) % P; 930 - } 931 - function modn(n) { 932 - return ((n % N) + N) % N; 933 - } 934 - 935 - function modpow(base, exp, mod) { 936 - let result = 1n; 937 - base = ((base % mod) + mod) % mod; 938 - while (exp > 0n) { 939 - if (exp & 1n) result = (result * base) % mod; 940 - exp >>= 1n; 941 - base = (base * base) % mod; 942 - } 943 - return result; 944 - } 945 - 946 - function pointAdd(P1, P2) { 947 - if (P1 === null) return P2; 948 - if (P2 === null) return P1; 949 - const [x1, y1] = P1; 950 - const [x2, y2] = P2; 951 - if (x1 === x2) { 952 - if (y1 !== y2) return null; 953 - const lam = modp(3n * x1 * x1 * modpow(2n * y1, P - 2n, P)); 954 - const x3 = modp(lam * lam - 2n * x1); 955 - return [x3, modp(lam * (x1 - x3) - y1)]; 956 - } 957 - const lam = modp((y2 - y1) * modpow(x2 - x1, P - 2n, P)); 958 - const x3 = modp(lam * lam - x1 - x2); 959 - return [x3, modp(lam * (x1 - x3) - y1)]; 960 - } 961 - 962 - function pointMul(k, point) { 963 - let R = null; 964 - let Q = point; 965 - k = modn(k); 966 - while (k > 0n) { 967 - if (k & 1n) R = pointAdd(R, Q); 968 - Q = pointAdd(Q, Q); 969 - k >>= 1n; 970 - } 971 - return R; 972 - } 973 - 974 - function recoverPublicKeyWithId(msgHash, sig, recId) { 975 - const { r, s } = sig; 976 - const rBig = BigInt("0x" + bytesToHex(r)); 977 - const sBig = BigInt("0x" + bytesToHex(s)); 978 - const hashBig = BigInt("0x" + bytesToHex(msgHash)); 979 - 980 - const x = rBig; 981 - const y2 = modp(modpow(x, 3n, P) + B); 982 - let y = modpow(y2, (P + 1n) / 4n, P); 983 - if ((y & 1n) !== BigInt(recId & 1)) y = P - y; 984 - const R = [x, y]; 985 - 986 - const rInv = modpow(rBig, N - 2n, N); 987 - const G = [GX, GY]; 988 - const u1 = modn((N - hashBig) * rInv); 989 - const u2 = modn(sBig * rInv); 990 - const Q = pointAdd(pointMul(u1, G), pointMul(u2, R)); 991 - 992 - const result = new Uint8Array(65); 993 - result[0] = 0x04; 994 - result.set(bigintToBytes32(Q[0]), 1); 995 - result.set(bigintToBytes32(Q[1]), 33); 996 - return result; 997 - } 998 - 999 - function compressPublicKey(uncompressed) { 1000 - const x = uncompressed.slice(1, 33); 1001 - const prefix = (uncompressed[64] & 1) === 0 ? 0x02 : 0x03; 1002 - const out = new Uint8Array(33); 1003 - out[0] = prefix; 1004 - out.set(x, 1); 1005 - return out; 1006 - } 1007 - 1008 - function bigintToBytes32(n) { 1009 - return hexToBytes(n.toString(16).padStart(64, "0")); 1010 - } 1011 - 1012 - // Derive the Ethereum address from an uncompressed public key 1013 - // (65 bytes, 0x04 prefix). Mirrors what go-ethereum does: 1014 - // keccak256(pubkey[1:]) -> last 20 bytes -> EIP-55 checksum. 1015 - function deriveEthAddress(uncompressed) { 1016 - // Hash the 64 uncompressed coordinate bytes (strip 0x04 prefix) 1017 - const hash = keccak256(uncompressed.slice(1)); 1018 - // Take the last 20 bytes 1019 - const addrBytes = hash.slice(12); 1020 - const hex = bytesToHex(addrBytes); 1021 - return eip55Checksum(hex); 1022 - } 1023 - 1024 - // EIP-55 mixed-case checksum encoding 1025 - function eip55Checksum(hex) { 1026 - const lower = hex.toLowerCase(); 1027 - const hash = keccak256(new TextEncoder().encode(lower)); 1028 - const hashHex = bytesToHex(hash); 1029 - let result = "0x"; 1030 - for (let i = 0; i < 40; i++) { 1031 - result += 1032 - parseInt(hashHex[i], 16) >= 8 1033 - ? lower[i].toUpperCase() 1034 - : lower[i]; 1035 - } 1036 - return result; 1037 - } 1038 - 1039 - // --------------------------------------------------------------------------- 1040 - // keccak256 (minimal, self-contained) 1041 - // --------------------------------------------------------------------------- 1042 - 1043 - const KECCAK_ROUNDS = 24; 1044 - const KECCAK_RC = [ 1045 - [0x00000001, 0x00000000], 1046 - [0x00008082, 0x00000000], 1047 - [0x0000808a, 0x80000000], 1048 - [0x80008000, 0x80000000], 1049 - [0x0000808b, 0x00000000], 1050 - [0x80000001, 0x00000000], 1051 - [0x80008081, 0x80000000], 1052 - [0x00008009, 0x80000000], 1053 - [0x0000008a, 0x00000000], 1054 - [0x00000088, 0x00000000], 1055 - [0x80008009, 0x00000000], 1056 - [0x8000000a, 0x00000000], 1057 - [0x8000808b, 0x00000000], 1058 - [0x0000008b, 0x80000000], 1059 - [0x00008089, 0x80000000], 1060 - [0x00008003, 0x80000000], 1061 - [0x00008002, 0x80000000], 1062 - [0x00000080, 0x80000000], 1063 - [0x0000800a, 0x00000000], 1064 - [0x8000000a, 0x80000000], 1065 - [0x80008081, 0x80000000], 1066 - [0x00008080, 0x80000000], 1067 - [0x80000001, 0x00000000], 1068 - [0x80008008, 0x80000000], 1069 - ]; 1070 - const KECCAK_ROTC = [ 1071 - 1, 3, 6, 10, 15, 21, 28, 36, 45, 55, 2, 14, 27, 41, 56, 8, 25, 1072 - 43, 62, 18, 39, 61, 20, 44, 1073 - ]; 1074 - const KECCAK_PILN = [ 1075 - 10, 7, 11, 17, 18, 3, 5, 16, 8, 21, 24, 4, 15, 23, 19, 13, 12, 1076 - 2, 20, 14, 22, 9, 6, 1, 1077 - ]; 1078 - 1079 - function keccak256(input) { 1080 - const rate = 136; 1081 - const padLen = rate - (input.length % rate); 1082 - const padded = new Uint8Array(input.length + padLen); 1083 - padded.set(input); 1084 - padded[input.length] = 0x01; 1085 - padded[padded.length - 1] |= 0x80; 1086 - 1087 - const state = new Uint32Array(50); 1088 - 1089 - for (let block = 0; block < padded.length; block += rate) { 1090 - for (let i = 0; i < rate / 4; i++) { 1091 - state[i] ^= 1092 - padded[block + i * 4] | 1093 - (padded[block + i * 4 + 1] << 8) | 1094 - (padded[block + i * 4 + 2] << 16) | 1095 - (padded[block + i * 4 + 3] << 24); 1096 - } 1097 - keccakF1600(state); 1098 - } 1099 - 1100 - const output = new Uint8Array(32); 1101 - for (let i = 0; i < 8; i++) { 1102 - const lo = state[i * 2]; 1103 - output[i * 4] = lo & 0xff; 1104 - output[i * 4 + 1] = (lo >> 8) & 0xff; 1105 - output[i * 4 + 2] = (lo >> 16) & 0xff; 1106 - output[i * 4 + 3] = (lo >> 24) & 0xff; 1107 - } 1108 - return output; 1109 - } 1110 - 1111 - function keccakF1600(state) { 1112 - const bc = new Uint32Array(10); 1113 - for (let round = 0; round < KECCAK_ROUNDS; round++) { 1114 - // Theta 1115 - for (let x = 0; x < 5; x++) { 1116 - bc[x * 2] = 1117 - state[x * 2] ^ 1118 - state[x * 2 + 10] ^ 1119 - state[x * 2 + 20] ^ 1120 - state[x * 2 + 30] ^ 1121 - state[x * 2 + 40]; 1122 - bc[x * 2 + 1] = 1123 - state[x * 2 + 1] ^ 1124 - state[x * 2 + 11] ^ 1125 - state[x * 2 + 21] ^ 1126 - state[x * 2 + 31] ^ 1127 - state[x * 2 + 41]; 1128 - } 1129 - for (let x = 0; x < 5; x++) { 1130 - const t0 = bc[((x + 4) % 5) * 2]; 1131 - const t1 = bc[((x + 4) % 5) * 2 + 1]; 1132 - const u0 = bc[((x + 1) % 5) * 2]; 1133 - const u1 = bc[((x + 1) % 5) * 2 + 1]; 1134 - const r0 = (u0 << 1) | (u1 >>> 31); 1135 - const r1 = (u1 << 1) | (u0 >>> 31); 1136 - for (let y = 0; y < 5; y++) { 1137 - state[(y * 5 + x) * 2] ^= t0 ^ r0; 1138 - state[(y * 5 + x) * 2 + 1] ^= t1 ^ r1; 1139 - } 1140 - } 1141 - // Rho + Pi 1142 - let last = [state[2], state[3]]; 1143 - for (let i = 0; i < 24; i++) { 1144 - const j = KECCAK_PILN[i]; 1145 - const tmp = [state[j * 2], state[j * 2 + 1]]; 1146 - const rot = KECCAK_ROTC[i]; 1147 - if (rot < 32) { 1148 - state[j * 2] = 1149 - (last[0] << rot) | (last[1] >>> (32 - rot)); 1150 - state[j * 2 + 1] = 1151 - (last[1] << rot) | (last[0] >>> (32 - rot)); 1152 - } else { 1153 - state[j * 2] = 1154 - (last[1] << (rot - 32)) | 1155 - (last[0] >>> (64 - rot)); 1156 - state[j * 2 + 1] = 1157 - (last[0] << (rot - 32)) | 1158 - (last[1] >>> (64 - rot)); 1159 - } 1160 - last = tmp; 1161 - } 1162 - // Chi 1163 - for (let y = 0; y < 5; y++) { 1164 - const t = new Uint32Array(10); 1165 - for (let x = 0; x < 5; x++) { 1166 - t[x * 2] = state[(y * 5 + x) * 2]; 1167 - t[x * 2 + 1] = state[(y * 5 + x) * 2 + 1]; 1168 - } 1169 - for (let x = 0; x < 5; x++) { 1170 - state[(y * 5 + x) * 2] ^= 1171 - ~t[((x + 1) % 5) * 2] & t[((x + 2) % 5) * 2]; 1172 - state[(y * 5 + x) * 2 + 1] ^= 1173 - ~t[((x + 1) % 5) * 2 + 1] & 1174 - t[((x + 2) % 5) * 2 + 1]; 1175 - } 1176 - } 1177 - // Iota 1178 - state[0] ^= KECCAK_RC[round][0]; 1179 - state[1] ^= KECCAK_RC[round][1]; 1180 - } 1181 - } 1182 </script> 1183 </body> 1184 </html>
··· 55 <h3>Signing Key</h3> 56 {{ if .HasSigningKey }} 57 <p> 58 + A passkey is registered for this account. {{ if 59 + .CredentialID }} 60 + <br /> 61 <small style="opacity: 0.7" 62 + >Credential ID: 63 + <code>{{ slice .CredentialID 0 16 }}…</code></small 64 > 65 + {{ end }} 66 </p> 67 {{ else }} 68 <p> 69 + No signing key is registered yet. Register a passkey to 70 + enable signing for this account. 71 </p> 72 {{ end }} 73 ··· 78 79 <div class="button-row"> 80 <button id="btn-register-key" class="primary"> 81 + {{ if .HasSigningKey }}Update passkey{{ else }}Register 82 + passkey{{ end }} 83 </button> 84 </div> 85 </div> ··· 90 <h3>Signer</h3> 91 <p> 92 The signer connects to your PDS over a WebSocket and signs 93 + commits using your passkey. Keep this page open (a pinned 94 + tab works great) to approve requests automatically. 95 </p> 96 97 <div ··· 191 {{ if .HasSigningKey }} 192 <p> 193 <small style="opacity: 0.7"> 194 + Your passkey will be asked to confirm this action. 195 + No data will be sent externally. 196 </small> 197 </p> 198 <div class="button-row"> ··· 203 {{ else }} 204 <p> 205 <small style="opacity: 0.7"> 206 + Account deletion requires a registered passkey to 207 + confirm the request. Please register a passkey above 208 + first. 209 </small> 210 </p> 211 {{ end }} ··· 216 <p><strong>Are you absolutely sure?</strong></p> 217 <p> 218 <small style="opacity: 0.7"> 219 + Clicking the button below will prompt your passkey 220 + to confirm deletion of 221 + <code style="word-break: break-all">{{ .Did }}</code 222 >. The server will verify the signature and 223 permanently erase all your data. This cannot be 224 undone. ··· 226 </p> 227 <div class="button-row"> 228 <button id="btn-delete-confirm" class="danger"> 229 + Yes, confirm and delete my account 230 </button> 231 <button id="btn-delete-cancel">Cancel</button> 232 </div> ··· 236 237 <script> 238 // --------------------------------------------------------------------------- 239 + // Utilities 240 + // --------------------------------------------------------------------------- 241 + 242 + function bytesToBase64url(bytes) { 243 + let binary = ""; 244 + for (let i = 0; i < bytes.byteLength; i++) { 245 + binary += String.fromCharCode(bytes[i]); 246 + } 247 + return btoa(binary) 248 + .replace(/\+/g, "-") 249 + .replace(/\//g, "_") 250 + .replace(/=+$/, ""); 251 + } 252 + 253 + function base64urlToBytes(b64url) { 254 + let b64 = b64url.replace(/-/g, "+").replace(/_/g, "/"); 255 + while (b64.length % 4 !== 0) b64 += "="; 256 + const raw = atob(b64); 257 + const out = new Uint8Array(raw.length); 258 + for (let i = 0; i < raw.length; i++) { 259 + out[i] = raw.charCodeAt(i); 260 + } 261 + return out; 262 + } 263 + 264 + // --------------------------------------------------------------------------- 265 + // Passkey registration 266 // --------------------------------------------------------------------------- 267 268 const btn = document.getElementById("btn-register-key"); ··· 284 btn.addEventListener("click", async () => { 285 hideMsg(); 286 287 + if (!window.PublicKeyCredential) { 288 showMsg( 289 + "Your browser does not support passkeys (WebAuthn). Please use a modern browser.", 290 "error", 291 ); 292 return; 293 } 294 295 btn.disabled = true; 296 + btn.textContent = "Requesting passkey options…"; 297 298 try { 299 + // 1. Fetch creation options from the server. 300 + const optResp = await fetch("/account/passkey-challenge", { 301 + method: "POST", 302 }); 303 + if (!optResp.ok) { 304 + throw new Error( 305 + "Failed to get passkey challenge (" + 306 + optResp.status + 307 + ")", 308 + ); 309 } 310 + const options = await optResp.json(); 311 312 + // Convert base64url strings to ArrayBuffers for the WebAuthn API. 313 + options.challenge = base64urlToBytes(options.challenge); 314 + options.user.id = base64urlToBytes(options.user.id); 315 + 316 + // 2. Create the passkey. 317 + btn.textContent = "Confirm with your passkey…"; 318 + const credential = await navigator.credentials.create({ 319 + publicKey: options, 320 }); 321 322 + if (!credential) { 323 + throw new Error("Passkey creation was cancelled."); 324 + } 325 + 326 + // 3. Send the attestation response to the server. 327 btn.textContent = "Registering…"; 328 const res = await fetch("/account/supply-signing-key", { 329 method: "POST", 330 headers: { "Content-Type": "application/json" }, 331 body: JSON.stringify({ 332 + clientDataJSON: bytesToBase64url( 333 + new Uint8Array( 334 + credential.response.clientDataJSON, 335 + ), 336 + ), 337 + attestationObject: bytesToBase64url( 338 + new Uint8Array( 339 + credential.response.attestationObject, 340 + ), 341 + ), 342 }), 343 }); 344 ··· 346 const body = await res.json().catch(() => ({})); 347 throw new Error( 348 body.message || 349 + `Passkey registration failed (${res.status})`, 350 ); 351 } 352 353 + showMsg("Passkey registered! Reloading…", "success"); 354 setTimeout(() => window.location.reload(), 1000); 355 } catch (err) { 356 showMsg(err.message || String(err), "error"); 357 btn.textContent = 358 + "{{ if .HasSigningKey }}Update passkey{{ else }}Register passkey{{ end }}"; 359 } finally { 360 btn.disabled = false; 361 } 362 }); 363 364 // --------------------------------------------------------------------------- 365 + // Browser Signer — WebSocket + passkey signing loop 366 // --------------------------------------------------------------------------- 367 368 {{ if .HasSigningKey }} 369 (function () { 370 + // The credential ID is needed to build the allowCredentials list 371 + // so the browser can find the right passkey immediately. 372 + const credentialIdB64 = {{ .CredentialID | js }}; 373 + 374 const dot = document.getElementById("signer-dot"); 375 const statusEl = document.getElementById("signer-status"); 376 const signerMsg = document.getElementById("signer-msg"); ··· 386 let intentionalDisconnect = false; 387 let pendingRequestId = null; 388 389 // --------------------------------------------------------------------------- 390 // Notification permission 391 // --------------------------------------------------------------------------- 392 393 function requestNotificationPermission() { 394 + if ( 395 + "Notification" in window && 396 + Notification.permission === "default" 397 + ) { 398 Notification.requestPermission(); 399 } 400 } 401 402 function showNotification(title, body) { 403 + if ( 404 + "Notification" in window && 405 + Notification.permission === "granted" 406 + ) { 407 try { 408 const n = new Notification(title, { 409 body: body, ··· 416 n.close(); 417 }; 418 } catch (e) { 419 + // Notifications not supported in this context. 420 } 421 } 422 } ··· 434 function setState(state, detail) { 435 dot.style.background = STATE_COLORS[state] || "#ef4444"; 436 const labels = { 437 + connected: 438 + "Connected — listening for signing requests", 439 connecting: "Connecting…", 440 disconnected: "Disconnected", 441 }; 442 + statusEl.textContent = 443 + detail || labels[state] || state; 444 445 if (state === "connected") { 446 btnConnect.style.display = "none"; ··· 467 function showPending(ops) { 468 if (ops && ops.length > 0) { 469 pendingOpsEl.textContent = ops 470 + .map( 471 + (op) => 472 + op.type + 473 + " " + 474 + op.collection + 475 + (op.rkey ? "/" + op.rkey : ""), 476 + ) 477 .join(", "); 478 } else { 479 pendingOpsEl.textContent = "(details unavailable)"; ··· 486 } 487 488 // --------------------------------------------------------------------------- 489 // WebSocket connection 490 // --------------------------------------------------------------------------- 491 492 function connect() { 493 + if ( 494 + ws && 495 + (ws.readyState === WebSocket.OPEN || 496 + ws.readyState === WebSocket.CONNECTING) 497 + ) { 498 return; 499 } 500 ··· 503 setState("connecting"); 504 hideSignerMsg(); 505 506 + const wsScheme = 507 + location.protocol === "https:" ? "wss" : "ws"; 508 + const url = 509 + wsScheme + "://" + location.host + "/account/signer"; 510 511 try { 512 ws = new WebSocket(url); 513 } catch (err) { 514 + console.error( 515 + "[vow/signer] WebSocket constructor error", 516 + err, 517 + ); 518 scheduleReconnect(); 519 return; 520 } ··· 532 533 ws.addEventListener("close", (event) => { 534 console.warn( 535 + "[vow/signer] closed: code=" + 536 + event.code + 537 + " reason=" + 538 + event.reason + 539 + " clean=" + 540 + event.wasClean, 541 ); 542 ws = null; 543 hidePending(); ··· 546 if (intentionalDisconnect) { 547 setState("disconnected"); 548 } else { 549 + setState( 550 + "disconnected", 551 + "Disconnected — reconnecting…", 552 + ); 553 scheduleReconnect(); 554 } 555 }); ··· 573 function scheduleReconnect() { 574 clearReconnectTimer(); 575 const delay = reconnectDelay; 576 + reconnectDelay = Math.min( 577 + reconnectDelay * 2, 578 + MAX_DELAY, 579 + ); 580 + console.log( 581 + "[vow/signer] reconnecting in " + delay + "ms", 582 + ); 583 reconnectTimer = setTimeout(connect, delay); 584 } 585 ··· 594 if (ws && ws.readyState === WebSocket.OPEN) { 595 ws.send(data); 596 } else { 597 + console.error( 598 + "[vow/signer] cannot send — WebSocket not open", 599 + ); 600 } 601 } 602 ··· 615 616 if (msg.type === "sign_request") { 617 handleSignRequest(msg); 618 } else { 619 + console.log( 620 + "[vow/signer] unknown message type", 621 + msg.type, 622 + ); 623 } 624 } 625 626 // --------------------------------------------------------------------------- 627 + // sign_request — WebAuthn passkey assertion 628 // --------------------------------------------------------------------------- 629 630 async function handleSignRequest(msg) { 631 const { requestId, payload, ops, expiresAt } = msg; 632 633 if (!requestId || !payload) { 634 + console.warn( 635 + "[vow/signer] malformed sign_request", 636 + msg, 637 + ); 638 return; 639 } 640 641 if (expiresAt && new Date(expiresAt) <= new Date()) { 642 + console.warn( 643 + "[vow/signer] sign_request expired", 644 + requestId, 645 + ); 646 wsSend(buildSignReject(requestId)); 647 return; 648 } 649 650 if (pendingRequestId) { 651 + console.warn( 652 + "[vow/signer] already pending — rejecting", 653 + requestId, 654 + ); 655 wsSend(buildSignReject(requestId)); 656 return; 657 } 658 659 pendingRequestId = requestId; 660 showPending(ops); 661 + showNotification( 662 + "Vow — Signing Request", 663 + opsToSummary(ops), 664 + ); 665 666 try { 667 + // The payload is base64url-encoded commit CBOR bytes. 668 + // We use those raw bytes directly as the WebAuthn challenge. 669 + const challenge = base64urlToBytes(payload); 670 671 + const allowCredentials = credentialIdB64 672 + ? [ 673 + { 674 + id: base64urlToBytes(credentialIdB64), 675 + type: "public-key", 676 + }, 677 + ] 678 + : []; 679 680 + const assertion = await navigator.credentials.get({ 681 + publicKey: { 682 + challenge, 683 + allowCredentials, 684 + timeout: 30000, 685 + userVerification: "preferred", 686 + }, 687 + }); 688 689 + if (!assertion) { 690 + throw new Error("Passkey assertion was cancelled."); 691 + } 692 693 + wsSend( 694 + buildSignResponse( 695 + requestId, 696 + assertion.response, 697 + ), 698 + ); 699 } catch (err) { 700 + console.warn("[vow/signer] signing failed", err); 701 + wsSend(buildSignReject(requestId)); 702 } finally { 703 pendingRequestId = null; 704 hidePending(); ··· 709 // Protocol message builders 710 // --------------------------------------------------------------------------- 711 712 + function buildSignResponse(requestId, assertionResponse) { 713 return JSON.stringify({ 714 type: "sign_response", 715 requestId: requestId, 716 + authenticatorData: bytesToBase64url( 717 + new Uint8Array( 718 + assertionResponse.authenticatorData, 719 + ), 720 + ), 721 + clientDataJSON: bytesToBase64url( 722 + new Uint8Array(assertionResponse.clientDataJSON), 723 + ), 724 + signature: bytesToBase64url( 725 + new Uint8Array(assertionResponse.signature), 726 + ), 727 }); 728 } 729 730 function buildSignReject(requestId) { 731 return JSON.stringify({ 732 type: "sign_reject", 733 requestId: requestId, 734 }); 735 } 736 737 // --------------------------------------------------------------------------- 738 + // Helpers 739 // --------------------------------------------------------------------------- 740 741 function opsToSummary(ops) { 742 + if (!ops || ops.length === 0) 743 + return "A signing request needs your approval."; 744 return ops 745 .map((op) => op.type + " " + op.collection) 746 .join(", "); ··· 751 // --------------------------------------------------------------------------- 752 753 btnConnect.addEventListener("click", () => { 754 + if (!window.PublicKeyCredential) { 755 showSignerMsg( 756 + "Your browser does not support passkeys (WebAuthn). Please use a modern browser.", 757 "error", 758 ); 759 return; ··· 769 // Auto-connect if previously connected (persisted in sessionStorage) 770 // --------------------------------------------------------------------------- 771 772 + if (sessionStorage.getItem("vow-signer-active") === "1") { 773 connect(); 774 } 775 ··· 789 {{ end }} 790 791 // --------------------------------------------------------------------------- 792 + // Account deletion — passkey assertion confirmation 793 // --------------------------------------------------------------------------- 794 795 (function () { 796 + const step1 = document.getElementById("delete-step-1"); 797 + const step2 = document.getElementById("delete-step-2"); 798 + const msgEl = document.getElementById("delete-msg"); 799 + const btnStart = document.getElementById("btn-delete-start"); 800 + const btnConfirm = document.getElementById( 801 + "btn-delete-confirm", 802 + ); 803 + const btnCancel = document.getElementById("btn-delete-cancel"); 804 805 + if (!btnStart) return; // no passkey registered — nothing to wire up 806 807 function showDeleteMsg(text, type) { 808 msgEl.textContent = text; 809 msgEl.style.display = "block"; 810 msgEl.className = 811 + "alert " + 812 + (type === "error" ? "alert-danger" : "alert-success"); 813 } 814 815 function hideDeleteMsg() { ··· 832 btnConfirm.addEventListener("click", async () => { 833 hideDeleteMsg(); 834 835 + if (!window.PublicKeyCredential) { 836 showDeleteMsg( 837 + "Your browser does not support passkeys (WebAuthn). Please use a modern browser.", 838 "error", 839 ); 840 return; 841 } 842 843 btnConfirm.disabled = true; 844 + btnCancel.disabled = true; 845 + btnConfirm.textContent = 846 + "Fetching challenge…"; 847 848 try { 849 + // 1. Get the assertion challenge from the server. 850 + const challengeResp = await fetch( 851 + "/account/passkey-assertion-challenge", 852 + { method: "POST" }, 853 + ); 854 + if (!challengeResp.ok) { 855 + const body = await challengeResp 856 + .json() 857 + .catch(() => ({})); 858 + throw new Error( 859 + body.message || 860 + "Failed to get challenge (" + 861 + challengeResp.status + 862 + ")", 863 + ); 864 } 865 + const opts = await challengeResp.json(); 866 867 + // Convert base64url fields for the WebAuthn API. 868 + const challenge = base64urlToBytes(opts.challenge); 869 + const allowCredentials = ( 870 + opts.allowCredentials || [] 871 + ).map((c) => ({ 872 + id: base64urlToBytes(c.id), 873 + type: c.type, 874 + })); 875 876 + // 2. Prompt the passkey. 877 + btnConfirm.textContent = "Confirm with your passkey…"; 878 + const assertion = await navigator.credentials.get({ 879 + publicKey: { 880 + challenge, 881 + allowCredentials, 882 + timeout: opts.timeout || 30000, 883 + userVerification: 884 + opts.userVerification || "preferred", 885 + rpId: opts.rpId, 886 + }, 887 }); 888 889 + if (!assertion) { 890 + throw new Error("Passkey confirmation cancelled."); 891 + } 892 + 893 + // 3. Send the assertion to the server. 894 btnConfirm.textContent = "Deleting…"; 895 const res = await fetch("/account/delete", { 896 method: "POST", 897 headers: { "Content-Type": "application/json" }, 898 + body: JSON.stringify({ 899 + credentialId: bytesToBase64url( 900 + new Uint8Array(assertion.rawId), 901 + ), 902 + clientDataJSON: bytesToBase64url( 903 + new Uint8Array( 904 + assertion.response.clientDataJSON, 905 + ), 906 + ), 907 + authenticatorData: bytesToBase64url( 908 + new Uint8Array( 909 + assertion.response.authenticatorData, 910 + ), 911 + ), 912 + signature: bytesToBase64url( 913 + new Uint8Array( 914 + assertion.response.signature, 915 + ), 916 + ), 917 + }), 918 }); 919 920 if (!res.ok) { 921 const body = await res.json().catch(() => ({})); 922 + throw new Error( 923 + body.message || 924 + body.error || 925 + `Deletion failed (${res.status})`, 926 + ); 927 } 928 929 + showDeleteMsg( 930 + "Account deleted. Redirecting…", 931 + "success", 932 + ); 933 + setTimeout(() => { 934 + window.location.href = "/"; 935 + }, 1500); 936 } catch (err) { 937 showDeleteMsg(err.message || String(err), "error"); 938 + btnConfirm.textContent = 939 + "Yes, confirm and delete my account"; 940 btnConfirm.disabled = false; 941 + btnCancel.disabled = false; 942 } 943 }); 944 })(); 945 </script> 946 </body> 947 </html>
+336
server/webauthn.go
···
··· 1 + package server 2 + 3 + import ( 4 + "crypto/ecdsa" 5 + "crypto/elliptic" 6 + "crypto/sha256" 7 + "encoding/asn1" 8 + "encoding/base64" 9 + "encoding/json" 10 + "fmt" 11 + "math/big" 12 + 13 + "github.com/fxamacker/cbor/v2" 14 + ) 15 + 16 + // ────────────────────────────────────────────────────────────────────────────── 17 + // Attestation parsing (key registration) 18 + // ────────────────────────────────────────────────────────────────────────────── 19 + 20 + // attestationObject is the top-level CBOR structure returned by 21 + // navigator.credentials.create(). 22 + type attestationObject struct { 23 + Fmt string `cbor:"fmt"` 24 + AttStmt map[string]any `cbor:"attStmt"` 25 + AuthData []byte `cbor:"authData"` 26 + } 27 + 28 + // parseAttestationObject extracts the compressed P-256 public key and the 29 + // credential ID from a WebAuthn attestation object (base64url-encoded CBOR). 30 + // 31 + // Only "none" and "packed" attestation formats are handled; for packed we 32 + // accept self-attestation without verifying the attStmt certificate chain 33 + // (sufficient for a PDS that trusts its own users). 34 + func parseAttestationObject(attestationObjectB64 string) (pubKeyBytes []byte, credentialID []byte, err error) { 35 + raw, err := base64.RawURLEncoding.DecodeString(attestationObjectB64) 36 + if err != nil { 37 + return nil, nil, fmt.Errorf("decode attestationObject: %w", err) 38 + } 39 + 40 + var ao attestationObject 41 + if err := cbor.Unmarshal(raw, &ao); err != nil { 42 + return nil, nil, fmt.Errorf("unmarshal attestationObject CBOR: %w", err) 43 + } 44 + 45 + pub, cid, err := parseAuthData(ao.AuthData) 46 + if err != nil { 47 + return nil, nil, fmt.Errorf("parse authData: %w", err) 48 + } 49 + 50 + return pub, cid, nil 51 + } 52 + 53 + // parseAuthData extracts the credential ID and the compressed P-256 public key 54 + // from a WebAuthn authenticatorData byte string. 55 + // 56 + // The authenticatorData layout is defined in the WebAuthn spec §6.1: 57 + // 58 + // rpIdHash [32]byte 59 + // flags [1]byte 60 + // signCount [4]byte (big-endian uint32) 61 + // attestedCredentialData (variable, present when AT flag is set) 62 + // aaguid [16]byte 63 + // credIdLen [2]byte (big-endian uint16) 64 + // credId [credIdLen]byte 65 + // credPubKey CBOR map (COSE_Key) 66 + func parseAuthData(authData []byte) (pubKeyBytes []byte, credentialID []byte, err error) { 67 + // Minimum length: 32 (rpIdHash) + 1 (flags) + 4 (signCount) = 37 bytes. 68 + if len(authData) < 37 { 69 + return nil, nil, fmt.Errorf("authData too short (%d bytes)", len(authData)) 70 + } 71 + 72 + flags := authData[32] 73 + // Bit 6 (AT) must be set for attested credential data to be present. 74 + const flagAT = 0x40 75 + if flags&flagAT == 0 { 76 + return nil, nil, fmt.Errorf("authData AT flag not set — no attested credential data") 77 + } 78 + 79 + if len(authData) < 55 { 80 + return nil, nil, fmt.Errorf("authData too short for attested credential data (%d bytes)", len(authData)) 81 + } 82 + 83 + // Skip: rpIdHash (32) + flags (1) + signCount (4) + aaguid (16) = 53 bytes. 84 + credIDLen := int(authData[53])<<8 | int(authData[54]) 85 + offset := 55 86 + if len(authData) < offset+credIDLen { 87 + return nil, nil, fmt.Errorf("authData too short for credId (need %d, have %d)", offset+credIDLen, len(authData)) 88 + } 89 + 90 + credentialID = authData[offset : offset+credIDLen] 91 + offset += credIDLen 92 + 93 + // The remaining bytes are a CBOR-encoded COSE_Key map. 94 + coseKey := authData[offset:] 95 + 96 + pub, err := parseCOSEKey(coseKey) 97 + if err != nil { 98 + return nil, nil, fmt.Errorf("parse COSE key: %w", err) 99 + } 100 + 101 + return pub, credentialID, nil 102 + } 103 + 104 + // parseCOSEKey decodes a CBOR COSE_Key map and returns the compressed P-256 105 + // public key (33 bytes). 106 + // 107 + // Relevant COSE key parameters for EC2 keys (kty=2): 108 + // 109 + // 1 kty = 2 (EC2) 110 + // 3 alg = -7 (ES256) 111 + // -1 crv = 1 (P-256) 112 + // -2 x (32 bytes) 113 + // -3 y (32 bytes) 114 + func parseCOSEKey(coseKey []byte) ([]byte, error) { 115 + // Use integer keys because CBOR maps in COSE use small ints. 116 + var m map[int]cbor.RawMessage 117 + if err := cbor.Unmarshal(coseKey, &m); err != nil { 118 + return nil, fmt.Errorf("unmarshal COSE_Key: %w", err) 119 + } 120 + 121 + // Check kty == 2 (EC2). 122 + var kty int 123 + if raw, ok := m[1]; ok { 124 + if err := cbor.Unmarshal(raw, &kty); err != nil { 125 + return nil, fmt.Errorf("decode kty: %w", err) 126 + } 127 + } 128 + if kty != 2 { 129 + return nil, fmt.Errorf("unsupported COSE key type %d (expected 2 for EC2)", kty) 130 + } 131 + 132 + // Check crv == 1 (P-256). 133 + var crv int 134 + if raw, ok := m[-1]; ok { 135 + if err := cbor.Unmarshal(raw, &crv); err != nil { 136 + return nil, fmt.Errorf("decode crv: %w", err) 137 + } 138 + } 139 + if crv != 1 { 140 + return nil, fmt.Errorf("unsupported COSE curve %d (expected 1 for P-256)", crv) 141 + } 142 + 143 + var xBytes, yBytes []byte 144 + if raw, ok := m[-2]; ok { 145 + if err := cbor.Unmarshal(raw, &xBytes); err != nil { 146 + return nil, fmt.Errorf("decode x: %w", err) 147 + } 148 + } 149 + if raw, ok := m[-3]; ok { 150 + if err := cbor.Unmarshal(raw, &yBytes); err != nil { 151 + return nil, fmt.Errorf("decode y: %w", err) 152 + } 153 + } 154 + 155 + if len(xBytes) != 32 || len(yBytes) != 32 { 156 + return nil, fmt.Errorf("unexpected key coordinate lengths (x=%d, y=%d)", len(xBytes), len(yBytes)) 157 + } 158 + 159 + // Compress the public key: prefix 0x02 if y is even, 0x03 if y is odd. 160 + prefix := byte(0x02) 161 + if yBytes[31]&1 == 1 { 162 + prefix = 0x03 163 + } 164 + 165 + compressed := make([]byte, 33) 166 + compressed[0] = prefix 167 + copy(compressed[1:], xBytes) 168 + 169 + return compressed, nil 170 + } 171 + 172 + // ────────────────────────────────────────────────────────────────────────────── 173 + // Assertion verification (signing operations & account deletion) 174 + // ────────────────────────────────────────────────────────────────────────────── 175 + 176 + // clientDataJSON is the parsed form of the clientDataJSON field returned by 177 + // navigator.credentials.get(). 178 + type clientDataJSON struct { 179 + Type string `json:"type"` 180 + Challenge string `json:"challenge"` // base64url 181 + Origin string `json:"origin"` 182 + } 183 + 184 + // verifyAssertion verifies a WebAuthn assertion and returns the raw 64-byte 185 + // (r‖s) ECDSA signature suitable for use in ATProto commits and JWTs. 186 + // 187 + // Parameters: 188 + // - pubKeyCompressed: 33-byte compressed P-256 public key stored in the DB. 189 + // - expectedChallenge: the raw challenge bytes the server originally sent. 190 + // - clientDataJSONBytes: the clientDataJSON bytes from the assertion response. 191 + // - authenticatorDataBytes: the authenticatorData bytes from the assertion response. 192 + // - signatureDER: the DER-encoded ECDSA signature from the assertion response. 193 + // - rpID: the relying party ID (hostname), e.g. "example.com". 194 + func verifyAssertion( 195 + pubKeyCompressed []byte, 196 + expectedChallenge []byte, 197 + clientDataJSONBytes []byte, 198 + authenticatorDataBytes []byte, 199 + signatureDER []byte, 200 + rpID string, 201 + ) (rawSig []byte, err error) { 202 + // ── 1. Parse and validate clientDataJSON ───────────────────────────── 203 + var cd clientDataJSON 204 + if err := json.Unmarshal(clientDataJSONBytes, &cd); err != nil { 205 + return nil, fmt.Errorf("unmarshal clientDataJSON: %w", err) 206 + } 207 + 208 + if cd.Type != "webauthn.get" { 209 + return nil, fmt.Errorf("unexpected clientData type %q (want webauthn.get)", cd.Type) 210 + } 211 + 212 + // The challenge in clientDataJSON is base64url-encoded (the browser 213 + // re-encodes the ArrayBuffer it was given). 214 + gotChallenge, err := base64.RawURLEncoding.DecodeString(cd.Challenge) 215 + if err != nil { 216 + // Some browsers include padding — try with std encoding as fallback. 217 + gotChallenge, err = base64.URLEncoding.DecodeString(cd.Challenge) 218 + if err != nil { 219 + return nil, fmt.Errorf("decode challenge from clientDataJSON: %w", err) 220 + } 221 + } 222 + 223 + if len(gotChallenge) != len(expectedChallenge) { 224 + return nil, fmt.Errorf("challenge length mismatch (got %d, want %d)", len(gotChallenge), len(expectedChallenge)) 225 + } 226 + for i := range expectedChallenge { 227 + if gotChallenge[i] != expectedChallenge[i] { 228 + return nil, fmt.Errorf("challenge mismatch") 229 + } 230 + } 231 + 232 + // ── 2. Validate authenticatorData ──────────────────────────────────── 233 + if len(authenticatorDataBytes) < 37 { 234 + return nil, fmt.Errorf("authenticatorData too short (%d bytes)", len(authenticatorDataBytes)) 235 + } 236 + 237 + // Verify rpIdHash matches SHA-256(rpID). 238 + rpIDHash := sha256.Sum256([]byte(rpID)) 239 + for i := range 32 { 240 + if authenticatorDataBytes[i] != rpIDHash[i] { 241 + return nil, fmt.Errorf("rpIdHash mismatch") 242 + } 243 + } 244 + 245 + // Check UP (user presence) flag — bit 0 must be set. 246 + flags := authenticatorDataBytes[32] 247 + const flagUP = 0x01 248 + if flags&flagUP == 0 { 249 + return nil, fmt.Errorf("user presence flag not set") 250 + } 251 + 252 + // ── 3. Reconstruct and verify the signed message ────────────────────── 253 + // WebAuthn signed data = authenticatorData ‖ SHA-256(clientDataJSON). 254 + cdHash := sha256.Sum256(clientDataJSONBytes) 255 + signedData := make([]byte, len(authenticatorDataBytes)+32) 256 + copy(signedData, authenticatorDataBytes) 257 + copy(signedData[len(authenticatorDataBytes):], cdHash[:]) 258 + 259 + // ── 4. Parse the DER signature ──────────────────────────────────────── 260 + rawSig64, err := derToRawECDSA(signatureDER) 261 + if err != nil { 262 + return nil, fmt.Errorf("parse DER signature: %w", err) 263 + } 264 + 265 + // ── 5. Verify the P-256 signature ───────────────────────────────────── 266 + pub, err := decompressP256(pubKeyCompressed) 267 + if err != nil { 268 + return nil, fmt.Errorf("decompress public key: %w", err) 269 + } 270 + 271 + digest := sha256.Sum256(signedData) 272 + r := new(big.Int).SetBytes(rawSig64[:32]) 273 + s := new(big.Int).SetBytes(rawSig64[32:]) 274 + 275 + if !ecdsa.Verify(pub, digest[:], r, s) { 276 + return nil, fmt.Errorf("signature verification failed") 277 + } 278 + 279 + return rawSig64, nil 280 + } 281 + 282 + // ────────────────────────────────────────────────────────────────────────────── 283 + // DER → raw (r‖s) conversion 284 + // ────────────────────────────────────────────────────────────────────────────── 285 + 286 + // derToRawECDSA parses a DER-encoded ECDSA signature (as produced by a WebAuthn 287 + // authenticator) and returns the 64-byte (r‖s) concatenation with each 288 + // component zero-padded to 32 bytes. 289 + func derToRawECDSA(der []byte) ([]byte, error) { 290 + var sig struct { 291 + R, S *big.Int 292 + } 293 + rest, err := asn1.Unmarshal(der, &sig) 294 + if err != nil { 295 + return nil, fmt.Errorf("asn1 unmarshal: %w", err) 296 + } 297 + if len(rest) != 0 { 298 + return nil, fmt.Errorf("trailing bytes after DER signature (%d bytes)", len(rest)) 299 + } 300 + if sig.R == nil || sig.S == nil { 301 + return nil, fmt.Errorf("nil r or s in DER signature") 302 + } 303 + 304 + out := make([]byte, 64) 305 + rBytes := sig.R.Bytes() 306 + sBytes := sig.S.Bytes() 307 + 308 + if len(rBytes) > 32 || len(sBytes) > 32 { 309 + return nil, fmt.Errorf("r or s component exceeds 32 bytes (r=%d, s=%d)", len(rBytes), len(sBytes)) 310 + } 311 + 312 + copy(out[32-len(rBytes):32], rBytes) 313 + copy(out[64-len(sBytes):64], sBytes) 314 + 315 + return out, nil 316 + } 317 + 318 + // ────────────────────────────────────────────────────────────────────────────── 319 + // Key helpers 320 + // ────────────────────────────────────────────────────────────────────────────── 321 + 322 + // decompressP256 decompresses a 33-byte compressed P-256 public key into an 323 + // *ecdsa.PublicKey. 324 + func decompressP256(compressed []byte) (*ecdsa.PublicKey, error) { 325 + if len(compressed) != 33 { 326 + return nil, fmt.Errorf("expected 33-byte compressed key, got %d bytes", len(compressed)) 327 + } 328 + 329 + curve := elliptic.P256() 330 + x, y := elliptic.UnmarshalCompressed(curve, compressed) 331 + if x == nil { 332 + return nil, fmt.Errorf("failed to unmarshal compressed P-256 key") 333 + } 334 + 335 + return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil 336 + }