Vow, uncensorable PDS written in Go

feat: support passkeys

+1151 -810
+4 -3
go.mod
··· 6 6 github.com/bluesky-social/indigo v0.0.0-20260203235305-a86f3ae1f8ec 7 7 github.com/btcsuite/websocket v0.0.0-20150119174127-31079b680792 8 8 github.com/domodwyer/mailyak/v3 v3.6.2 9 - github.com/ethereum/go-ethereum v1.17.1 9 + github.com/fxamacker/cbor/v2 v2.9.0 10 10 github.com/glebarez/sqlite v1.11.0 11 11 github.com/go-chi/chi/v5 v5.2.5 12 12 github.com/go-pkgz/expirable-cache/v3 v3.0.0 ··· 35 35 ) 36 36 37 37 require ( 38 - github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251001021608-1fe7b43fc4d6 // indirect 39 38 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b // indirect 40 39 github.com/beorn7/perks v1.0.1 // indirect 41 40 github.com/cespare/xxhash/v2 v2.3.0 // indirect ··· 54 53 github.com/gocql/gocql v1.7.0 // indirect 55 54 github.com/gogo/protobuf v1.3.2 // indirect 56 55 github.com/golang/snappy v1.0.0 // indirect 56 + github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect 57 57 github.com/gorilla/securecookie v1.1.2 // indirect 58 58 github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed // indirect 59 59 github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 60 60 github.com/hashicorp/go-retryablehttp v0.7.7 // indirect 61 61 github.com/hashicorp/golang-lru v1.0.2 // indirect 62 - github.com/holiman/uint256 v1.3.2 // indirect 62 + github.com/huin/goupnp v1.3.0 // indirect 63 63 github.com/inconshreveable/mousetrap v1.1.0 // indirect 64 64 github.com/ipfs/bbloom v0.0.4 // indirect 65 65 github.com/ipfs/go-blockservice v0.5.2 // indirect ··· 116 116 github.com/spf13/cast v1.10.0 // indirect 117 117 github.com/spf13/pflag v1.0.10 // indirect 118 118 github.com/subosito/gotenv v1.6.0 // indirect 119 + github.com/x448/float16 v0.8.4 // indirect 119 120 gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect 120 121 gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect 121 122 go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+4 -8
go.sum
··· 1 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 2 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b h1:5/++qT1/z812ZqBvqQt6ToRswSuPZ/B33m6xVHRzADU= 5 3 github.com/RussellLuo/slidingwindow v0.0.0-20200528002341-535bb99d338b/go.mod h1:4+EPqMRApwwE/6yo6CxiHoSnBzjRr3jsqer7frxP8y4= 6 4 github.com/alexbrainman/goissue34681 v0.0.0-20191006012335-3fc7a47baff5 h1:iW0a5ljuFxkLGPNem5Ui+KBjFJzKg4Fv2fnxe4dvzpM= ··· 27 25 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 28 26 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 29 27 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 28 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0 h1:8UrgZ3GkP4i/CLijOJx79Yu+etlyjdBU4sfcs2WYQMs= 33 29 github.com/decred/dcrd/dcrec/secp256k1/v4 v4.2.0/go.mod h1:v57UDF4pDQJcEfFUCRop3lJL149eHGSe9Jvczhzjo/0= 34 30 github.com/domodwyer/mailyak/v3 v3.6.2 h1:x3tGMsyFhTCaxp6ycgR0FE/bu5QiNp+hetUuCOBXMn8= ··· 37 33 github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 38 34 github.com/earthboundkid/versioninfo/v2 v2.24.1 h1:SJTMHaoUx3GzjjnUO1QzP3ZXK6Ee/nbWyCm58eY3oUg= 39 35 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 36 github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= 43 37 github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= 44 38 github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= ··· 47 41 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 48 42 github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= 49 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= 50 46 github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= 51 47 github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= 52 48 github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= ··· 116 112 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 117 113 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= 118 114 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 115 github.com/huin/goupnp v1.3.0 h1:UvLUlWDNpoUdYzb2TCn+MuTWtcjXKSza2n6CBdQ0xXc= 122 116 github.com/huin/goupnp v1.3.0/go.mod h1:gnGPsThkYa7bFi/KWmEysQRf48l2dvR5bxr2OFckNX8= 123 117 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= ··· 362 356 github.com/whyrusleeping/cbor v0.0.0-20171005072247-63513f603b11/go.mod h1:Wlo/SzPmxVp6vXpGt/zaXhHH0fn4IxgqZc82aKg6bpQ= 363 357 github.com/whyrusleeping/cbor-gen v0.2.1-0.20241030202151-b7a6831be65e h1:28X54ciEwwUxyHn9yrZfl5ojgF4CBNLWX7LR0rvBkf4= 364 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= 365 361 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 366 362 github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 367 363 github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
+10 -10
internal/helpers/helpers.go
··· 13 13 ) 14 14 15 15 var ( 16 - // ErrSignerNotConnected is the sentinel returned when no wallet is connected. 16 + // ErrSignerNotConnected is the sentinel returned when no signer tab is open. 17 17 ErrSignerNotConnected = errors.New("signer not connected") 18 18 19 - // ErrSignerRejected is the sentinel returned when the wallet rejected the 20 - // signing request. 19 + // ErrSignerRejected is the sentinel returned when the passkey prompt was 20 + // dismissed or the signing request was explicitly rejected. 21 21 ErrSignerRejected = errors.New("signer rejected") 22 22 23 - // ErrSignerTimeout is the sentinel returned when the wallet did not respond 24 - // within the deadline. 23 + // ErrSignerTimeout is the sentinel returned when the passkey did not 24 + // respond within the deadline. 25 25 ErrSignerTimeout = errors.New("signer timeout") 26 26 ) 27 27 ··· 48 48 // guard). 49 49 // 50 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. 51 + // - "AccountNotFound" — no signer tab is open / connected. 52 + // - "UserTookDownRepo" — the user dismissed the passkey prompt or rejected it. 53 53 // - "RepoDeactivated" — the signing deadline elapsed with no response. 54 54 // 55 55 // These are the closest standard codes to what happened; they tell AppViews ··· 63 63 case errors.Is(err, ErrSignerNotConnected): 64 64 writeJSON(w, http.StatusBadRequest, map[string]string{ 65 65 "error": "AccountNotFound", 66 - "message": "No wallet signer is connected for this account. Open the account page and keep it in a browser tab.", 66 + "message": "No signer is connected for this account. Open the account page and keep it in a browser tab.", 67 67 }) 68 68 case errors.Is(err, ErrSignerRejected): 69 69 writeJSON(w, http.StatusBadRequest, map[string]string{ 70 70 "error": "UserTookDownRepo", 71 - "message": "The wallet rejected the signing request.", 71 + "message": "The passkey prompt was dismissed or the signing request was rejected.", 72 72 }) 73 73 case errors.Is(err, ErrSignerTimeout): 74 74 writeJSON(w, http.StatusBadRequest, map[string]string{ 75 75 "error": "RepoDeactivated", 76 - "message": "The wallet did not respond within the signing deadline.", 76 + "message": "The passkey did not respond within the signing deadline.", 77 77 }) 78 78 default: 79 79 ServerError(w, nil)
+11 -21
models/models.go
··· 2 2 3 3 import ( 4 4 "time" 5 - 6 - gethcrypto "github.com/ethereum/go-ethereum/crypto" 7 5 ) 8 6 9 7 type Repo struct { ··· 22 20 AccountDeleteCode *string 23 21 AccountDeleteCodeExpiresAt *time.Time 24 22 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() 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 44 34 } 45 35 46 36 func (r *Repo) Status() *string {
+5 -1
readme.md
··· 222 222 { 223 223 "type": "sign_response", 224 224 "requestId": "uuid", 225 - "signature": "<base64url-encoded signature bytes>" 225 + "authenticatorData": "<base64url authenticatorData bytes>", 226 + "clientDataJSON": "<base64url clientDataJSON bytes>", 227 + "signature": "<base64url DER-encoded ECDSA signature>" 226 228 } 227 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. 228 232 229 233 **Rejection message (browser → PDS):** 230 234
+14 -6
server/handle_account.go
··· 1 1 package server 2 2 3 3 import ( 4 + "encoding/base64" 4 5 "net/http" 5 6 "time" 6 7 ··· 65 66 }) 66 67 } 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 + 68 76 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), 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), 75 83 }); err != nil { 76 84 logger.Error("failed to render template", "error", err) 77 85 }
+21 -6
server/handle_account_signer.go
··· 1 1 package server 2 2 3 3 import ( 4 - "encoding/base64" 5 4 "encoding/json" 6 5 "net/http" 7 6 "time" ··· 62 61 inbound := make(chan wsIncoming, 4) 63 62 nextReq := make(chan signerRequest, 1) 64 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 + 65 68 ctx := r.Context() 66 69 go func() { 67 70 for { ··· 110 113 case in := <-inbound: 111 114 switch in.Type { 112 115 case "sign_response": 113 - if in.Signature == "" { 114 - logger.Warn("signer: sign_response missing signature", "did", did) 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) 115 119 continue 116 120 } 117 - sigBytes, err := base64.RawURLEncoding.DecodeString(in.Signature) 121 + delete(pendingPayloads, in.RequestID) 122 + 123 + rawSig, err := verifyWebAuthnSignResponse(repo.PublicKey, payload, in, s.config.Hostname, logger) 118 124 if err != nil { 119 - logger.Warn("signer: sign_response bad base64url", "did", did, "error", err) 125 + logger.Warn("signer: sign_response verification failed", "did", did, "requestId", in.RequestID, "error", err) 120 126 continue 121 127 } 122 - if !s.signerHub.DeliverSignature(did, in.RequestID, sigBytes) { 128 + if !s.signerHub.DeliverSignature(did, in.RequestID, rawSig) { 123 129 logger.Warn("signer: sign_response for unknown requestId", "did", did, "requestId", in.RequestID) 124 130 } 125 131 126 132 case "sign_reject": 133 + delete(pendingPayloads, in.RequestID) 127 134 if !s.signerHub.DeliverRejection(did, in.RequestID) { 128 135 logger.Warn("signer: sign_reject for unknown requestId", "did", did, "requestId", in.RequestID) 129 136 } ··· 137 144 logger.Error("signer: failed to write request", "did", did, "error", err) 138 145 req.reply <- signerReply{err: helpers.ErrSignerNotConnected} 139 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) 140 155 } 141 156 142 157 logger.Info("signer: request sent", "did", did, "requestId", req.requestID)
+3 -3
server/handle_identity_sign_plc_operation.go
··· 34 34 // 35 35 // Unlike the previous implementation this handler never touches a private key. 36 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. 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 39 func (s *Server) handleSignPlcOperation(w http.ResponseWriter, r *http.Request) { 40 40 logger := s.logger.With("name", "handleSignPlcOperation") 41 41 ··· 100 100 op.Services = *req.Services 101 101 } 102 102 103 - // Serialise the operation to CBOR — this is the payload the user's wallet 103 + // Serialise the operation to CBOR — this is the payload the user's passkey 104 104 // must sign. We send it to the signer and wait for the signature. 105 105 opCBOR, err := op.MarshalCBOR() 106 106 if err != nil {
+2 -2
server/handle_identity_submit_plc_operation.go
··· 52 52 // 1. The signing key (verificationMethods.atproto) matches the registered key. 53 53 // 2. The service endpoint still points to this PDS. 54 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 55 + // (either the user's passkey or the PDS key, depending on whether 56 56 // sovereignty has been transferred). 57 57 // 4. The operation was signed by one of the current rotation keys (enforced 58 58 // by plc.directory on submission, not re-checked here). ··· 62 62 return 63 63 } 64 64 65 - pubKey, err := atcrypto.ParsePublicBytesK256(repo.PublicKey) 65 + pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey) 66 66 if err != nil { 67 67 logger.Error("error parsing stored public key", "error", err) 68 68 helpers.ServerError(w, nil)
+2 -2
server/handle_identity_update_handle.go
··· 75 75 76 76 // Determine whether the PDS rotation key still has authority over 77 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 78 + // user's passkey, the PDS key is no longer in the rotation key list 79 79 // and cannot sign PLC operations. 80 80 pdsRotationDIDKey := s.plcClient.RotationDIDKey() 81 81 pdsCanSign := slices.Contains(latest.Operation.RotationKeys, pdsRotationDIDKey) ··· 88 88 return 89 89 } 90 90 } else { 91 - // Rotation key belongs to the user's wallet. Delegate the 91 + // Rotation key belongs to the user's passkey. Delegate the 92 92 // signing to the signer over WebSocket, same as 93 93 // handleSignPlcOperation does for other PLC operations. 94 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 89 90 90 // exp=0 tells signServiceAuthJWT to use the default lifetime and 91 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. 92 + // (aud, lxm) pair reuse it instead of prompting the passkey each time. 93 93 token, err := s.signServiceAuthJWT(r.Context(), repo, aud, lxm, 0) 94 94 if helpers.HandleSignerError(w, err) { 95 95 logger.Error("error signing proxy JWT", "error", err)
+50 -49
server/handle_server_delete_account.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "encoding/hex" 5 + "crypto/sha256" 6 + "encoding/base64" 6 7 "encoding/json" 7 8 "fmt" 8 9 "net/http" 9 - "strings" 10 10 "time" 11 11 12 12 "github.com/bluesky-social/indigo/api/atproto" 13 13 "github.com/bluesky-social/indigo/events" 14 14 "github.com/bluesky-social/indigo/util" 15 - gethcrypto "github.com/ethereum/go-ethereum/crypto" 16 15 "golang.org/x/crypto/bcrypt" 17 16 "pkg.rbrt.fr/vow/internal/helpers" 18 17 "pkg.rbrt.fr/vow/models" ··· 149 148 } 150 149 151 150 // --------------------------------------------------------------------------- 152 - // /account/delete — browser endpoint (web session + wallet signature) 151 + // /account/delete — browser endpoint (web session + WebAuthn assertion) 153 152 // --------------------------------------------------------------------------- 154 153 154 + // AccountDeleteRequest carries the WebAuthn assertion response fields sent by 155 + // the browser after the user confirms account deletion with their passkey. 155 156 type AccountDeleteRequest struct { 156 - WalletAddress string `json:"walletAddress" validate:"required"` 157 - Signature string `json:"signature" validate:"required"` 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 158 161 } 159 162 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. 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. 165 167 func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request) { 166 168 ctx := r.Context() 167 169 logger := s.logger.With("name", "handleAccountDelete") ··· 172 174 return 173 175 } 174 176 175 - // The account must have a registered signing key; without it we have no 176 - // wallet to verify against. 177 + // The account must have a registered passkey; without it there is nothing 178 + // to verify against. 177 179 if len(repo.PublicKey) == 0 { 178 180 s.writeJSON(w, http.StatusBadRequest, map[string]any{ 179 181 "error": "NoSigningKey", 180 - "message": "No signing key is registered for this account. Please register your wallet first.", 182 + "message": "No passkey is registered for this account. Please register a passkey first.", 181 183 }) 182 184 return 183 185 } ··· 189 191 return 190 192 } 191 193 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"}) 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 + }) 195 198 return 196 199 } 197 200 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"}) 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"}) 203 205 return 204 206 } 205 207 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) 208 + authenticatorDataBytes, err := base64.RawURLEncoding.DecodeString(req.AuthenticatorData) 219 209 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"}) 210 + s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid authenticatorData encoding"}) 222 211 return 223 212 } 224 213 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"}) 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"}) 230 217 return 231 218 } 232 219 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, 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", 239 237 "did", repo.Repo.Did, 238 + "error", err, 240 239 ) 241 - s.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "signature wallet does not match the key registered for this account"}) 240 + s.writeJSON(w, http.StatusUnauthorized, map[string]string{ 241 + "error": "passkey verification failed: " + err.Error(), 242 + }) 242 243 return 243 244 } 244 245
+13 -17
server/handle_server_get_service_auth.go
··· 80 80 }) 81 81 } 82 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. 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. 87 86 // 88 87 // The returned string is a fully formed "header.payload.signature" JWT ready to 89 88 // be placed in an Authorization: Bearer header. ··· 104 103 105 104 // ── Build header + payload ──────────────────────────────────────────── 106 105 header := map[string]string{ 107 - "alg": "ES256K", 108 - "crv": "secp256k1", 106 + "alg": "ES256", 107 + "crv": "P-256", 109 108 "typ": "JWT", 110 109 } 111 110 hj, err := json.Marshal(header) ··· 144 143 // base64url(header) + "." + base64url(payload). 145 144 signingInput := encHeader + "." + encPayload 146 145 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. 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. 156 151 hash := sha256.Sum256([]byte(signingInput)) 157 152 payloadB64 := base64.RawURLEncoding.EncodeToString(hash[:]) 158 153 ··· 180 175 return "", err 181 176 } 182 177 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. 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. 185 181 if len(sigBytes) == 65 { 186 182 sigBytes = sigBytes[:64] 187 183 }
+3 -3
server/handle_server_get_signing_key.go
··· 10 10 11 11 // PendingWriteOp is a human-readable summary of a single operation inside a 12 12 // signing request, sent to the signer so the user knows what they are 13 - // approving before the wallet prompt appears. 13 + // approving before the passkey prompt appears. 14 14 type PendingWriteOp struct { 15 15 Type string `json:"type"` 16 16 Collection string `json:"collection"` ··· 25 25 PublicKey string `json:"publicKey"` 26 26 } 27 27 28 - // handleGetSigningKey returns the compressed secp256k1 public key registered 28 + // handleGetSigningKey returns the compressed P-256 public key registered 29 29 // for the authenticated account, encoded as a did:key string. 30 30 // 31 31 // The private key is never held by the PDS; this endpoint only confirms that a ··· 44 44 return 45 45 } 46 46 47 - pubKey, err := atcrypto.ParsePublicBytesK256(repo.PublicKey) 47 + pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey) 48 48 if err != nil { 49 49 logger.Error("error parsing stored public key", "error", err) 50 50 helpers.ServerError(w, nil)
+51 -89
server/handle_server_supply_signing_key.go
··· 1 1 package server 2 2 3 3 import ( 4 - "encoding/hex" 4 + "encoding/base64" 5 5 "encoding/json" 6 - "fmt" 7 6 "maps" 8 7 "net/http" 9 8 "strings" 10 9 11 10 "github.com/bluesky-social/indigo/atproto/atcrypto" 12 - gethcrypto "github.com/ethereum/go-ethereum/crypto" 13 11 "pkg.rbrt.fr/vow/identity" 14 12 "pkg.rbrt.fr/vow/internal/helpers" 15 13 "pkg.rbrt.fr/vow/models" 16 14 "pkg.rbrt.fr/vow/plc" 17 15 ) 18 16 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"` 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 29 } 30 30 31 - type ComAtprotoServerSupplySigningKeyResponse struct { 32 - Did string `json:"did"` 33 - PublicKey string `json:"publicKey"` // did:key representation 31 + type SupplySigningKeyResponse struct { 32 + Did string `json:"did"` 33 + PublicKey string `json:"publicKey"` // did:key representation 34 + CredentialID string `json:"credentialId"` // base64url 34 35 } 35 36 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. 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 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 - 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. 47 43 func (s *Server) handleSupplySigningKey(w http.ResponseWriter, r *http.Request) { 48 44 ctx := r.Context() 49 45 logger := s.logger.With("name", "handleSupplySigningKey") ··· 54 50 return 55 51 } 56 52 57 - var req ComAtprotoServerSupplySigningKeyRequest 53 + var req SupplySigningKeyRequest 58 54 if err := json.NewDecoder(r.Body).Decode(&req); err != nil { 59 55 logger.Error("error decoding request", "error", err) 60 56 helpers.InputError(w, new("could not decode request body")) ··· 63 59 64 60 if err := s.validator.Struct(req); err != nil { 65 61 logger.Error("validation failed", "error", err) 66 - helpers.InputError(w, new("walletAddress and signature are required")) 62 + helpers.InputError(w, new("clientDataJSON and attestationObject are required")) 67 63 return 68 64 } 69 65 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) 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) 92 69 if err != nil { 93 - logger.Warn("public key recovery failed", "error", err) 94 - helpers.InputError(w, new("could not recover public key from signature")) 70 + logger.Warn("attestation parsing failed", "error", err) 71 + helpers.InputError(w, new("could not parse attestation object")) 95 72 return 96 73 } 97 74 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) 75 + // Validate the compressed key is a well-formed P-256 point. 76 + pubKey, err := atcrypto.ParsePublicBytesP256(keyBytes) 114 77 if err != nil { 115 - logger.Error("compressed key rejected by atcrypto", "error", err) 116 - helpers.ServerError(w, 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")) 117 80 return 118 81 } 119 82 120 83 pubDIDKey := pubKey.DIDKey() 121 84 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. 85 + // Update the PLC DID document so the passkey's did:key becomes the active 86 + // atproto verification method and the sole rotation key. 124 87 if strings.HasPrefix(repo.Repo.Did, "did:plc:") { 125 88 log, err := identity.FetchDidAuditLog(ctx, nil, repo.Repo.Did) 126 89 if err != nil { ··· 135 98 maps.Copy(newVerificationMethods, latest.Operation.VerificationMethods) 136 99 newVerificationMethods["atproto"] = pubDIDKey 137 100 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. 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. 143 104 newRotationKeys := []string{pubDIDKey} 144 105 145 106 op := plc.Operation{ ··· 151 112 Prev: &latest.Cid, 152 113 } 153 114 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. 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. 158 118 if err := s.plcClient.SignOp(&op); err != nil { 159 119 logger.Error("error signing PLC operation with rotation key", "error", err) 160 120 helpers.ServerError(w, nil) ··· 168 128 } 169 129 } 170 130 171 - // Persist the compressed public key. 131 + // Persist the compressed P-256 public key and credential ID. 172 132 if err := s.db.Exec(ctx, 173 - "UPDATE repos SET public_key = ? WHERE did = ?", 174 - nil, keyBytes, repo.Repo.Did, 133 + "UPDATE repos SET public_key = ?, credential_id = ? WHERE did = ?", 134 + nil, keyBytes, credentialID, repo.Repo.Did, 175 135 ).Error; err != nil { 176 - logger.Error("error updating public key in db", "error", err) 136 + logger.Error("error updating public key and credential ID in db", "error", err) 177 137 helpers.ServerError(w, nil) 178 138 return 179 139 } ··· 183 143 logger.Warn("error busting DID doc cache", "error", err) 184 144 } 185 145 186 - logger.Info("public signing key registered via BYOK — rotation key transferred to user", 146 + logger.Info("passkey registered — rotation key transferred to user", 187 147 "did", repo.Repo.Did, 188 148 "publicKey", pubDIDKey, 149 + "credentialIDLen", len(credentialID), 189 150 ) 190 151 191 - s.writeJSON(w, 200, ComAtprotoServerSupplySigningKeyResponse{ 192 - Did: repo.Repo.Did, 193 - PublicKey: pubDIDKey, 152 + s.writeJSON(w, 200, SupplySigningKeyResponse{ 153 + Did: repo.Repo.Did, 154 + PublicKey: pubDIDKey, 155 + CredentialID: base64.RawURLEncoding.EncodeToString(credentialID), 194 156 }) 195 157 }
+108 -19
server/handle_signer_connect.go
··· 3 3 import ( 4 4 "encoding/base64" 5 5 "encoding/json" 6 + "log/slog" 6 7 "net/http" 7 8 "strings" 8 9 "time" ··· 35 36 Type string `json:"type"` // always "sign_request" 36 37 RequestID string `json:"requestId"` // UUID, echoed back in the response 37 38 Did string `json:"did"` 38 - Payload string `json:"payload"` // base64url-encoded unsigned commit CBOR 39 + Payload string `json:"payload"` // base64url-encoded unsigned commit CBOR (used as the WebAuthn challenge) 39 40 Ops []PendingWriteOp `json:"ops"` // human-readable summary shown to user 40 41 ExpiresAt string `json:"expiresAt"` // RFC3339 41 42 } 42 43 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 44 50 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"` 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 49 56 } 50 57 51 58 // handleSignerConnect upgrades the connection to a WebSocket and registers it ··· 57 64 // 58 65 // 1. When a write handler needs a signature it calls SignerHub.RequestSignature 59 66 // 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. 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. 65 71 // 66 72 // The loop also handles WebSocket ping/pong: the server sends a ping every 20 s 67 73 // and expects a pong within 10 s (gorilla handles pong automatically). ··· 135 141 // inbound carries decoded messages from the reader goroutine. 136 142 inbound := make(chan wsIncoming, 4) 137 143 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). 144 + // nextReq carries the next queued request to be sent to the signer. 141 145 nextReq := make(chan signerRequest, 1) 142 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 + 143 152 ctx := r.Context() 144 153 145 154 // Read pump: conn.ReadMessage blocks so it runs in its own goroutine. ··· 164 173 }() 165 174 166 175 // Queue pump: feeds the main loop one request at a time, respecting the 167 - // wallet's one-at-a-time constraint enforced inside NextRequest. 176 + // passkey's one-at-a-time constraint enforced inside NextRequest. 168 177 go func() { 169 178 for { 170 179 req, ok := sc.NextRequest(ctx) ··· 206 215 case in := <-inbound: 207 216 switch in.Type { 208 217 case "sign_response": 209 - if in.Signature == "" { 210 - logger.Warn("signer: sign_response missing signature", "did", did) 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) 211 221 continue 212 222 } 213 - sigBytes, err := base64.RawURLEncoding.DecodeString(in.Signature) 223 + delete(pendingPayloads, in.RequestID) 224 + 225 + rawSig, err := verifyWebAuthnSignResponse(repo.PublicKey, payload, in, s.config.Hostname, logger) 214 226 if err != nil { 215 - logger.Warn("signer: sign_response bad base64url", "did", did, "error", err) 227 + logger.Warn("signer: sign_response verification failed", "did", did, "requestId", in.RequestID, "error", err) 216 228 continue 217 229 } 218 - if !s.signerHub.DeliverSignature(did, in.RequestID, sigBytes) { 230 + if !s.signerHub.DeliverSignature(did, in.RequestID, rawSig) { 219 231 logger.Warn("signer: sign_response for unknown requestId", "did", did, "requestId", in.RequestID) 220 232 } 221 233 222 234 case "sign_reject": 235 + delete(pendingPayloads, in.RequestID) 223 236 if !s.signerHub.DeliverRejection(did, in.RequestID) { 224 237 logger.Warn("signer: sign_reject for unknown requestId", "did", did, "requestId", in.RequestID) 225 238 } ··· 234 247 logger.Error("signer: failed to write request", "did", did, "error", err) 235 248 req.reply <- signerReply{err: helpers.ErrSignerNotConnected} 236 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) 237 258 } 238 259 239 260 logger.Info("signer: request sent", "did", did, "requestId", req.requestID) ··· 269 290 Ops: ops, 270 291 ExpiresAt: expiresAt.UTC().Format(time.RFC3339), 271 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 272 361 } 273 362 274 363 // isTokenExpired returns true if the JWT's exp claim is in the past.
+13 -10
server/middleware.go
··· 155 155 repo = maybeRepo 156 156 } 157 157 158 - if token.Header["alg"] != "ES256K" { 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 { 159 164 token, err = new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) { 160 165 if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok { 161 166 return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"]) 162 167 } 163 - return s.privateKey.Public(), nil 168 + return &s.privateKey.PublicKey, nil 164 169 }) 165 170 if err != nil { 166 171 logger.Error("error parsing jwt", "error", err) ··· 191 196 if repo == nil { 192 197 sub, ok := claims["sub"].(string) 193 198 if !ok { 194 - s.logger.Error("no sub claim in ES256K token and repo not set") 199 + s.logger.Error("no sub claim in user-signed token and repo not set") 195 200 helpers.InvalidTokenError(w) 196 201 return 197 202 } 198 203 maybeRepo, err := s.getRepoActorByDid(ctx, sub) 199 204 if err != nil { 200 - s.logger.Error("error fetching repo for ES256K verification", "error", err) 205 + s.logger.Error("error fetching repo for user-signed token verification", "error", err) 201 206 helpers.ServerError(w, nil) 202 207 return 203 208 } ··· 205 210 did = sub 206 211 } 207 212 208 - // The PDS never holds a private key. Verify the ES256K JWT 209 - // signature using the compressed public key stored in PublicKey. 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. 210 215 if len(repo.PublicKey) == 0 { 211 216 logger.Error("no public key registered for account", "did", repo.Repo.Did) 212 217 helpers.ServerError(w, nil) 213 218 return 214 219 } 215 220 216 - pubKey, err := atcrypto.ParsePublicBytesK256(repo.PublicKey) 221 + pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey) 217 222 if err != nil { 218 223 logger.Error("can't parse stored public key", "error", err) 219 224 helpers.ServerError(w, nil) 220 225 return 221 226 } 222 227 223 - // sigBytes is already the compact (r||s) 64-byte form. Verify 224 - // using HashAndVerifyLenient which hashes signingInput internally. 225 228 if err := pubKey.HashAndVerifyLenient([]byte(signingInput), sigBytes); err != nil { 226 - logger.Error("ES256K signature verification failed", "error", err) 229 + logger.Error("user-signed JWT verification failed", "error", err) 227 230 helpers.ServerError(w, nil) 228 231 return 229 232 }
+3 -3
server/repo.go
··· 219 219 // provided raw signature bytes, reserialises the commit, writes the commit 220 220 // block to the blockstore, and returns the commit CID. 221 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. 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 224 func finaliseCommit(ctx context.Context, bs blockstore.Blockstore, uc *unsignedCommit, sig []byte) (cid.Cid, error) { 225 225 // Decode the unsigned commit so we can attach the signature field. 226 226 var commit atp.Commit ··· 599 599 return nil, fmt.Errorf("no public key registered for account %s", urepo.Did) 600 600 } 601 601 602 - pubKey, err := atcrypto.ParsePublicBytesK256(urepo.PublicKey) 602 + pubKey, err := atcrypto.ParsePublicBytesP256(urepo.PublicKey) 603 603 if err != nil { 604 604 return nil, fmt.Errorf("parsing stored public key: %w", err) 605 605 }
+2
server/server.go
··· 516 516 r.Post("/account/signup", s.handleAccountSignupPost) 517 517 r.Get("/account/signout", s.handleAccountSignout) 518 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) 519 521 r.With(s.handleWebSessionMiddleware).Post("/account/delete", s.handleAccountDelete) 520 522 r.Get("/account/signer", s.handleAccountSigner) 521 523
+12 -12
server/signer_hub.go
··· 31 31 32 32 // signerConn represents one active signer WebSocket connection for a DID. 33 33 // It owns an unbounded queue of pending requests and a map of in-flight 34 - // requests waiting for a reply from the wallet. 34 + // requests waiting for a reply from the passkey. 35 35 type signerConn struct { 36 36 // mu protects pending and inflight. 37 37 mu sync.Mutex 38 38 39 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, 40 + // the passkey. The WS goroutine drains it one at a time: it pops the head, 41 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 42 + // pops the next one after a reply arrives. This serialises passkey prompts 43 43 // (the user must confirm each one before the next appears) while allowing 44 44 // any number of callers to enqueue work concurrently. 45 45 pending []signerRequest 46 46 47 - // inflight holds the single request that has been sent to the wallet and 47 + // inflight holds the single request that has been sent to the passkey and 48 48 // is awaiting a sign_response / sign_reject. Keyed by requestID. 49 49 inflight map[string]signerRequest 50 50 ··· 127 127 } 128 128 129 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. 130 + // - The signer sends sign_response → returns the signature bytes. 131 + // - The signer sends sign_reject → returns helpers.ErrSignerRejected. 132 132 // - The WebSocket disconnects → returns helpers.ErrSignerNotConnected. 133 133 // - ctx is cancelled or times out → returns helpers.ErrSignerTimeout or ctx.Err(). 134 134 // 135 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 136 + // (the passkey sees one prompt at a time). Callers for different DIDs are 137 137 // independent. 138 138 func (h *SignerHub) RequestSignature(ctx context.Context, did string, requestID string, msg []byte) ([]byte, error) { 139 139 h.mu.Lock() ··· 243 243 return true 244 244 } 245 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. 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 250 // 251 251 // Returns (request, true) on success, or (zero, false) if the connection is 252 252 // going away (done closed or ctx cancelled). 253 253 func (conn *signerConn) NextRequest(ctx context.Context) (signerRequest, bool) { 254 254 for { 255 255 conn.mu.Lock() 256 - // Only dequeue when nothing is in-flight (wallet is free). 256 + // Only dequeue when nothing is in-flight (passkey is free). 257 257 if len(conn.pending) > 0 && len(conn.inflight) == 0 { 258 258 req := conn.pending[0] 259 259 conn.pending = conn.pending[1:]
+306 -543
server/templates/account.html
··· 55 55 <h3>Signing Key</h3> 56 56 {{ if .HasSigningKey }} 57 57 <p> 58 - A signing key is registered for this account.<br /> 58 + A passkey is registered for this account. {{ if 59 + .CredentialID }} 60 + <br /> 59 61 <small style="opacity: 0.7" 60 - >Ethereum address: 61 - <code>{{ .EthereumAddress }}</code></small 62 + >Credential ID: 63 + <code>{{ slice .CredentialID 0 16 }}…</code></small 62 64 > 65 + {{ end }} 63 66 </p> 64 67 {{ else }} 65 68 <p> 66 - No signing key is registered yet. Connect your Ethereum 67 - wallet to register your public key with this PDS. 69 + No signing key is registered yet. Register a passkey to 70 + enable signing for this account. 68 71 </p> 69 72 {{ end }} 70 73 ··· 75 78 76 79 <div class="button-row"> 77 80 <button id="btn-register-key" class="primary"> 78 - {{ if .HasSigningKey }}Update signing key{{ else 79 - }}Register signing key{{ end }} 81 + {{ if .HasSigningKey }}Update passkey{{ else }}Register 82 + passkey{{ end }} 80 83 </button> 81 84 </div> 82 85 </div> ··· 87 90 <h3>Signer</h3> 88 91 <p> 89 92 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. 93 + commits using your passkey. Keep this page open (a pinned 94 + tab works great) to approve requests automatically. 92 95 </p> 93 96 94 97 <div ··· 188 191 {{ if .HasSigningKey }} 189 192 <p> 190 193 <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 + Your passkey will be asked to confirm this action. 195 + No data will be sent externally. 194 196 </small> 195 197 </p> 196 198 <div class="button-row"> ··· 201 203 {{ else }} 202 204 <p> 203 205 <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. 206 + Account deletion requires a registered passkey to 207 + confirm the request. Please register a passkey above 208 + first. 207 209 </small> 208 210 </p> 209 211 {{ end }} ··· 214 216 <p><strong>Are you absolutely sure?</strong></p> 215 217 <p> 216 218 <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 219 + Clicking the button below will prompt your passkey 220 + to confirm deletion of 221 + <code style="word-break: break-all">{{ .Did }}</code 221 222 >. The server will verify the signature and 222 223 permanently erase all your data. This cannot be 223 224 undone. ··· 225 226 </p> 226 227 <div class="button-row"> 227 228 <button id="btn-delete-confirm" class="danger"> 228 - Yes, sign and delete my account 229 + Yes, confirm and delete my account 229 230 </button> 230 231 <button id="btn-delete-cancel">Cancel</button> 231 232 </div> ··· 235 236 236 237 <script> 237 238 // --------------------------------------------------------------------------- 238 - // Signing key registration via window.ethereum (EIP-1193) 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 239 266 // --------------------------------------------------------------------------- 240 267 241 268 const btn = document.getElementById("btn-register-key"); ··· 257 284 btn.addEventListener("click", async () => { 258 285 hideMsg(); 259 286 260 - if (!window.ethereum) { 287 + if (!window.PublicKeyCredential) { 261 288 showMsg( 262 - "No Ethereum wallet detected. Please install MetaMask, Rabby, or another EIP-1193 wallet and reload.", 289 + "Your browser does not support passkeys (WebAuthn). Please use a modern browser.", 263 290 "error", 264 291 ); 265 292 return; 266 293 } 267 294 268 295 btn.disabled = true; 269 - btn.textContent = "Connecting…"; 296 + btn.textContent = "Requesting passkey options…"; 270 297 271 298 try { 272 - // 1. Request accounts 273 - const accounts = await window.ethereum.request({ 274 - method: "eth_requestAccounts", 299 + // 1. Fetch creation options from the server. 300 + const optResp = await fetch("/account/passkey-challenge", { 301 + method: "POST", 275 302 }); 276 - if (!accounts || accounts.length === 0) { 277 - throw new Error("No accounts returned from wallet."); 303 + if (!optResp.ok) { 304 + throw new Error( 305 + "Failed to get passkey challenge (" + 306 + optResp.status + 307 + ")", 308 + ); 278 309 } 279 - const account = accounts[0]; 310 + const options = await optResp.json(); 280 311 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], 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, 286 320 }); 287 321 288 - // 3. POST signature + address; the server recovers the key. 322 + if (!credential) { 323 + throw new Error("Passkey creation was cancelled."); 324 + } 325 + 326 + // 3. Send the attestation response to the server. 289 327 btn.textContent = "Registering…"; 290 328 const res = await fetch("/account/supply-signing-key", { 291 329 method: "POST", 292 330 headers: { "Content-Type": "application/json" }, 293 331 body: JSON.stringify({ 294 - walletAddress: account, 295 - signature, 332 + clientDataJSON: bytesToBase64url( 333 + new Uint8Array( 334 + credential.response.clientDataJSON, 335 + ), 336 + ), 337 + attestationObject: bytesToBase64url( 338 + new Uint8Array( 339 + credential.response.attestationObject, 340 + ), 341 + ), 296 342 }), 297 343 }); 298 344 ··· 300 346 const body = await res.json().catch(() => ({})); 301 347 throw new Error( 302 348 body.message || 303 - `Key registration failed (${res.status})`, 349 + `Passkey registration failed (${res.status})`, 304 350 ); 305 351 } 306 352 307 - showMsg("Signing key registered! Reloading…", "success"); 353 + showMsg("Passkey registered! Reloading…", "success"); 308 354 setTimeout(() => window.location.reload(), 1000); 309 355 } catch (err) { 310 356 showMsg(err.message || String(err), "error"); 311 357 btn.textContent = 312 - "{{ if .HasSigningKey }}Update signing key{{ else }}Register signing key{{ end }}"; 358 + "{{ if .HasSigningKey }}Update passkey{{ else }}Register passkey{{ end }}"; 313 359 } finally { 314 360 btn.disabled = false; 315 361 } 316 362 }); 317 363 318 364 // --------------------------------------------------------------------------- 319 - // Browser Signer — WebSocket + signing loop 365 + // Browser Signer — WebSocket + passkey signing loop 320 366 // --------------------------------------------------------------------------- 321 367 322 368 {{ if .HasSigningKey }} 323 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 + 324 374 const dot = document.getElementById("signer-dot"); 325 375 const statusEl = document.getElementById("signer-status"); 326 376 const signerMsg = document.getElementById("signer-msg"); ··· 336 386 let intentionalDisconnect = false; 337 387 let pendingRequestId = null; 338 388 339 - // Wallet address cached after first eth_requestAccounts call 340 - let walletAddress = null; 341 - 342 389 // --------------------------------------------------------------------------- 343 390 // Notification permission 344 391 // --------------------------------------------------------------------------- 345 392 346 393 function requestNotificationPermission() { 347 - if ("Notification" in window && Notification.permission === "default") { 394 + if ( 395 + "Notification" in window && 396 + Notification.permission === "default" 397 + ) { 348 398 Notification.requestPermission(); 349 399 } 350 400 } 351 401 352 402 function showNotification(title, body) { 353 - if ("Notification" in window && Notification.permission === "granted") { 403 + if ( 404 + "Notification" in window && 405 + Notification.permission === "granted" 406 + ) { 354 407 try { 355 408 const n = new Notification(title, { 356 409 body: body, ··· 363 416 n.close(); 364 417 }; 365 418 } catch (e) { 366 - // Notifications not supported in this context 419 + // Notifications not supported in this context. 367 420 } 368 421 } 369 422 } ··· 381 434 function setState(state, detail) { 382 435 dot.style.background = STATE_COLORS[state] || "#ef4444"; 383 436 const labels = { 384 - connected: "Connected — listening for signing requests", 437 + connected: 438 + "Connected — listening for signing requests", 385 439 connecting: "Connecting…", 386 440 disconnected: "Disconnected", 387 441 }; 388 - statusEl.textContent = detail || labels[state] || state; 442 + statusEl.textContent = 443 + detail || labels[state] || state; 389 444 390 445 if (state === "connected") { 391 446 btnConnect.style.display = "none"; ··· 412 467 function showPending(ops) { 413 468 if (ops && ops.length > 0) { 414 469 pendingOpsEl.textContent = ops 415 - .map((op) => op.type + " " + op.collection + "/" + op.rkey) 470 + .map( 471 + (op) => 472 + op.type + 473 + " " + 474 + op.collection + 475 + (op.rkey ? "/" + op.rkey : ""), 476 + ) 416 477 .join(", "); 417 478 } else { 418 479 pendingOpsEl.textContent = "(details unavailable)"; ··· 425 486 } 426 487 427 488 // --------------------------------------------------------------------------- 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 489 // WebSocket connection 450 490 // --------------------------------------------------------------------------- 451 491 452 492 function connect() { 453 - if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) { 493 + if ( 494 + ws && 495 + (ws.readyState === WebSocket.OPEN || 496 + ws.readyState === WebSocket.CONNECTING) 497 + ) { 454 498 return; 455 499 } 456 500 ··· 459 503 setState("connecting"); 460 504 hideSignerMsg(); 461 505 462 - const wsScheme = location.protocol === "https:" ? "wss" : "ws"; 463 - const url = wsScheme + "://" + location.host + "/account/signer"; 506 + const wsScheme = 507 + location.protocol === "https:" ? "wss" : "ws"; 508 + const url = 509 + wsScheme + "://" + location.host + "/account/signer"; 464 510 465 511 try { 466 512 ws = new WebSocket(url); 467 513 } catch (err) { 468 - console.error("[vow/signer] WebSocket constructor error", err); 514 + console.error( 515 + "[vow/signer] WebSocket constructor error", 516 + err, 517 + ); 469 518 scheduleReconnect(); 470 519 return; 471 520 } ··· 483 532 484 533 ws.addEventListener("close", (event) => { 485 534 console.warn( 486 - "[vow/signer] closed: code=" + event.code + 487 - " reason=" + event.reason + 488 - " clean=" + event.wasClean, 535 + "[vow/signer] closed: code=" + 536 + event.code + 537 + " reason=" + 538 + event.reason + 539 + " clean=" + 540 + event.wasClean, 489 541 ); 490 542 ws = null; 491 543 hidePending(); ··· 494 546 if (intentionalDisconnect) { 495 547 setState("disconnected"); 496 548 } else { 497 - setState("disconnected", "Disconnected — reconnecting…"); 549 + setState( 550 + "disconnected", 551 + "Disconnected — reconnecting…", 552 + ); 498 553 scheduleReconnect(); 499 554 } 500 555 }); ··· 518 573 function scheduleReconnect() { 519 574 clearReconnectTimer(); 520 575 const delay = reconnectDelay; 521 - reconnectDelay = Math.min(reconnectDelay * 2, MAX_DELAY); 522 - console.log("[vow/signer] reconnecting in " + delay + "ms"); 576 + reconnectDelay = Math.min( 577 + reconnectDelay * 2, 578 + MAX_DELAY, 579 + ); 580 + console.log( 581 + "[vow/signer] reconnecting in " + delay + "ms", 582 + ); 523 583 reconnectTimer = setTimeout(connect, delay); 524 584 } 525 585 ··· 534 594 if (ws && ws.readyState === WebSocket.OPEN) { 535 595 ws.send(data); 536 596 } else { 537 - console.error("[vow/signer] cannot send — WebSocket not open"); 597 + console.error( 598 + "[vow/signer] cannot send — WebSocket not open", 599 + ); 538 600 } 539 601 } 540 602 ··· 553 615 554 616 if (msg.type === "sign_request") { 555 617 handleSignRequest(msg); 556 - } else if (msg.type === "pay_request") { 557 - handlePayRequest(msg); 558 618 } else { 559 - console.log("[vow/signer] unknown message type", msg.type); 619 + console.log( 620 + "[vow/signer] unknown message type", 621 + msg.type, 622 + ); 560 623 } 561 624 } 562 625 563 626 // --------------------------------------------------------------------------- 564 - // sign_request — EIP-191 personal_sign 627 + // sign_request — WebAuthn passkey assertion 565 628 // --------------------------------------------------------------------------- 566 629 567 630 async function handleSignRequest(msg) { 568 631 const { requestId, payload, ops, expiresAt } = msg; 569 632 570 633 if (!requestId || !payload) { 571 - console.warn("[vow/signer] malformed sign_request", msg); 634 + console.warn( 635 + "[vow/signer] malformed sign_request", 636 + msg, 637 + ); 572 638 return; 573 639 } 574 640 575 641 if (expiresAt && new Date(expiresAt) <= new Date()) { 576 - console.warn("[vow/signer] sign_request expired", requestId); 642 + console.warn( 643 + "[vow/signer] sign_request expired", 644 + requestId, 645 + ); 577 646 wsSend(buildSignReject(requestId)); 578 647 return; 579 648 } 580 649 581 650 if (pendingRequestId) { 582 - console.warn("[vow/signer] already pending — rejecting", requestId); 651 + console.warn( 652 + "[vow/signer] already pending — rejecting", 653 + requestId, 654 + ); 583 655 wsSend(buildSignReject(requestId)); 584 656 return; 585 657 } 586 658 587 659 pendingRequestId = requestId; 588 660 showPending(ops); 589 - showNotification("Vow — Signing Request", opsToSummary(ops)); 661 + showNotification( 662 + "Vow — Signing Request", 663 + opsToSummary(ops), 664 + ); 590 665 591 666 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 - } 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); 607 670 608 - // --------------------------------------------------------------------------- 609 - // pay_request — EIP-712 eth_signTypedData_v4 610 - // --------------------------------------------------------------------------- 671 + const allowCredentials = credentialIdB64 672 + ? [ 673 + { 674 + id: base64urlToBytes(credentialIdB64), 675 + type: "public-key", 676 + }, 677 + ] 678 + : []; 611 679 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 - } 680 + const assertion = await navigator.credentials.get({ 681 + publicKey: { 682 + challenge, 683 + allowCredentials, 684 + timeout: 30000, 685 + userVerification: "preferred", 686 + }, 687 + }); 625 688 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"); 689 + if (!assertion) { 690 + throw new Error("Passkey assertion was cancelled."); 691 + } 635 692 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)); 693 + wsSend( 694 + buildSignResponse( 695 + requestId, 696 + assertion.response, 697 + ), 698 + ); 647 699 } catch (err) { 648 - console.warn("[vow/signer] payment signing failed", err); 649 - wsSend(buildPayReject(requestId)); 700 + console.warn("[vow/signer] signing failed", err); 701 + wsSend(buildSignReject(requestId)); 650 702 } finally { 651 703 pendingRequestId = null; 652 704 hidePending(); ··· 657 709 // Protocol message builders 658 710 // --------------------------------------------------------------------------- 659 711 660 - function buildSignResponse(requestId, signatureHex) { 661 - const hex = signatureHex.startsWith("0x") 662 - ? signatureHex.slice(2) 663 - : signatureHex; 712 + function buildSignResponse(requestId, assertionResponse) { 664 713 return JSON.stringify({ 665 714 type: "sign_response", 666 715 requestId: requestId, 667 - signature: hexToBase64url(hex), 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 + ), 668 727 }); 669 728 } 670 729 671 730 function buildSignReject(requestId) { 672 731 return JSON.stringify({ 673 732 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 733 requestId: requestId, 690 734 }); 691 735 } 692 736 693 737 // --------------------------------------------------------------------------- 694 - // Encoding helpers 738 + // Helpers 695 739 // --------------------------------------------------------------------------- 696 740 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 741 function opsToSummary(ops) { 724 - if (!ops || ops.length === 0) return "A signing request needs your approval."; 742 + if (!ops || ops.length === 0) 743 + return "A signing request needs your approval."; 725 744 return ops 726 745 .map((op) => op.type + " " + op.collection) 727 746 .join(", "); ··· 732 751 // --------------------------------------------------------------------------- 733 752 734 753 btnConnect.addEventListener("click", () => { 735 - if (!window.ethereum) { 754 + if (!window.PublicKeyCredential) { 736 755 showSignerMsg( 737 - "No Ethereum wallet detected. Please install MetaMask, Rabby, or another EIP-1193 wallet.", 756 + "Your browser does not support passkeys (WebAuthn). Please use a modern browser.", 738 757 "error", 739 758 ); 740 759 return; ··· 750 769 // Auto-connect if previously connected (persisted in sessionStorage) 751 770 // --------------------------------------------------------------------------- 752 771 753 - if (sessionStorage.getItem("vow-signer-active") === "1" && window.ethereum) { 772 + if (sessionStorage.getItem("vow-signer-active") === "1") { 754 773 connect(); 755 774 } 756 775 ··· 770 789 {{ end }} 771 790 772 791 // --------------------------------------------------------------------------- 773 - // Account deletion — wallet signature confirmation 792 + // Account deletion — passkey assertion confirmation 774 793 // --------------------------------------------------------------------------- 775 794 776 795 (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"); 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"); 783 804 784 - if (!btnStart) return; // no signing key registered — nothing to wire up 805 + if (!btnStart) return; // no passkey registered — nothing to wire up 785 806 786 807 function showDeleteMsg(text, type) { 787 808 msgEl.textContent = text; 788 809 msgEl.style.display = "block"; 789 810 msgEl.className = 790 - "alert " + (type === "error" ? "alert-danger" : "alert-success"); 811 + "alert " + 812 + (type === "error" ? "alert-danger" : "alert-success"); 791 813 } 792 814 793 815 function hideDeleteMsg() { ··· 810 832 btnConfirm.addEventListener("click", async () => { 811 833 hideDeleteMsg(); 812 834 813 - if (!window.ethereum) { 835 + if (!window.PublicKeyCredential) { 814 836 showDeleteMsg( 815 - "No Ethereum wallet detected. Please install MetaMask, Rabby, or another EIP-1193 wallet and reload.", 837 + "Your browser does not support passkeys (WebAuthn). Please use a modern browser.", 816 838 "error", 817 839 ); 818 840 return; 819 841 } 820 842 821 843 btnConfirm.disabled = true; 822 - btnCancel.disabled = true; 823 - btnConfirm.textContent = "Connecting to wallet…"; 844 + btnCancel.disabled = true; 845 + btnConfirm.textContent = 846 + "Fetching challenge…"; 824 847 825 848 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."); 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 + ); 831 864 } 832 - const walletAddress = accounts[0]; 865 + const opts = await challengeResp.json(); 833 866 834 - const did = {{ .Did | js }}; 835 - const message = "Delete account: " + did; 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 + })); 836 875 837 - btnConfirm.textContent = "Sign the message in your wallet…"; 838 - const signature = await window.ethereum.request({ 839 - method: "personal_sign", 840 - params: [message, walletAddress], 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 + }, 841 887 }); 842 888 889 + if (!assertion) { 890 + throw new Error("Passkey confirmation cancelled."); 891 + } 892 + 893 + // 3. Send the assertion to the server. 843 894 btnConfirm.textContent = "Deleting…"; 844 895 const res = await fetch("/account/delete", { 845 896 method: "POST", 846 897 headers: { "Content-Type": "application/json" }, 847 - body: JSON.stringify({ walletAddress, signature }), 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 + }), 848 918 }); 849 919 850 920 if (!res.ok) { 851 921 const body = await res.json().catch(() => ({})); 852 - throw new Error(body.message || body.error || `Deletion failed (${res.status})`); 922 + throw new Error( 923 + body.message || 924 + body.error || 925 + `Deletion failed (${res.status})`, 926 + ); 853 927 } 854 928 855 - showDeleteMsg("Account deleted. Redirecting…", "success"); 856 - setTimeout(() => { window.location.href = "/"; }, 1500); 929 + showDeleteMsg( 930 + "Account deleted. Redirecting…", 931 + "success", 932 + ); 933 + setTimeout(() => { 934 + window.location.href = "/"; 935 + }, 1500); 857 936 } catch (err) { 858 937 showDeleteMsg(err.message || String(err), "error"); 859 - btnConfirm.textContent = "Yes, sign and delete my account"; 938 + btnConfirm.textContent = 939 + "Yes, confirm and delete my account"; 860 940 btnConfirm.disabled = false; 861 - btnCancel.disabled = false; 941 + btnCancel.disabled = false; 862 942 } 863 943 }); 864 944 })(); 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 945 </script> 1183 946 </body> 1184 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 + }