···1313)
14141515var (
1616- // ErrSignerNotConnected is the sentinel returned when no wallet is connected.
1616+ // ErrSignerNotConnected is the sentinel returned when no signer tab is open.
1717 ErrSignerNotConnected = errors.New("signer not connected")
18181919- // ErrSignerRejected is the sentinel returned when the wallet rejected the
2020- // signing request.
1919+ // ErrSignerRejected is the sentinel returned when the passkey prompt was
2020+ // dismissed or the signing request was explicitly rejected.
2121 ErrSignerRejected = errors.New("signer rejected")
22222323- // ErrSignerTimeout is the sentinel returned when the wallet did not respond
2424- // within the deadline.
2323+ // ErrSignerTimeout is the sentinel returned when the passkey did not
2424+ // respond within the deadline.
2525 ErrSignerTimeout = errors.New("signer timeout")
2626)
2727···4848// guard).
4949//
5050// The error codes follow the ATProto convention used by the official PDS:
5151-// - "AccountNotFound" — no wallet tab is open / connected.
5252-// - "UserTookDownRepo" — the user explicitly rejected the signing prompt.
5151+// - "AccountNotFound" — no signer tab is open / connected.
5252+// - "UserTookDownRepo" — the user dismissed the passkey prompt or rejected it.
5353// - "RepoDeactivated" — the signing deadline elapsed with no response.
5454//
5555// These are the closest standard codes to what happened; they tell AppViews
···6363 case errors.Is(err, ErrSignerNotConnected):
6464 writeJSON(w, http.StatusBadRequest, map[string]string{
6565 "error": "AccountNotFound",
6666- "message": "No wallet signer is connected for this account. Open the account page and keep it in a browser tab.",
6666+ "message": "No signer is connected for this account. Open the account page and keep it in a browser tab.",
6767 })
6868 case errors.Is(err, ErrSignerRejected):
6969 writeJSON(w, http.StatusBadRequest, map[string]string{
7070 "error": "UserTookDownRepo",
7171- "message": "The wallet rejected the signing request.",
7171+ "message": "The passkey prompt was dismissed or the signing request was rejected.",
7272 })
7373 case errors.Is(err, ErrSignerTimeout):
7474 writeJSON(w, http.StatusBadRequest, map[string]string{
7575 "error": "RepoDeactivated",
7676- "message": "The wallet did not respond within the signing deadline.",
7676+ "message": "The passkey did not respond within the signing deadline.",
7777 })
7878 default:
7979 ServerError(w, nil)
+11-21
models/models.go
···2233import (
44 "time"
55-66- gethcrypto "github.com/ethereum/go-ethereum/crypto"
75)
8697type Repo struct {
···2220 AccountDeleteCode *string
2321 AccountDeleteCodeExpiresAt *time.Time
2422 Password string
2525- // PublicKey holds the compressed secp256k1 public key bytes for the
2626- // account. This is the only key material the PDS retains.
2727- PublicKey []byte
2828- Rev string
2929- Root []byte
3030- Preferences []byte
3131- Deactivated bool
3232-}
3333-3434-// EthereumAddress returns the Ethereum address for PublicKey.
3535-func (r *Repo) EthereumAddress() string {
3636- if len(r.PublicKey) == 0 {
3737- return ""
3838- }
3939- ecPub, err := gethcrypto.DecompressPubkey(r.PublicKey)
4040- if err != nil {
4141- return ""
4242- }
4343- return gethcrypto.PubkeyToAddress(*ecPub).Hex()
2323+ // PublicKey holds the compressed P-256 (secp256r1) public key bytes for
2424+ // the account. This is the only key material the PDS retains.
2525+ PublicKey []byte
2626+ // CredentialID is the WebAuthn credential ID returned by the authenticator
2727+ // during registration. It is stored so the server can build the
2828+ // allowCredentials list when requesting an assertion from the passkey.
2929+ CredentialID []byte
3030+ Rev string
3131+ Root []byte
3232+ Preferences []byte
3333+ Deactivated bool
4434}
45354636func (r *Repo) Status() *string {
+5-1
readme.md
···222222{
223223 "type": "sign_response",
224224 "requestId": "uuid",
225225- "signature": "<base64url-encoded signature bytes>"
225225+ "authenticatorData": "<base64url authenticatorData bytes>",
226226+ "clientDataJSON": "<base64url clientDataJSON bytes>",
227227+ "signature": "<base64url DER-encoded ECDSA signature>"
226228}
227229```
230230+231231+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.
228232229233**Rejection message (browser → PDS):**
230234
+14-6
server/handle_account.go
···11package server
2233import (
44+ "encoding/base64"
45 "net/http"
56 "time"
67···6566 })
6667 }
67686969+ // Encode the credential ID as base64url so the template can pass it to
7070+ // navigator.credentials.get() as the allowCredentials entry.
7171+ credentialID := ""
7272+ if len(repo.CredentialID) > 0 {
7373+ credentialID = base64.RawURLEncoding.EncodeToString(repo.CredentialID)
7474+ }
7575+6876 if err := s.renderTemplate(w, "account.html", map[string]any{
6969- "Handle": repo.Handle,
7070- "Did": repo.Repo.Did,
7171- "HasSigningKey": len(repo.PublicKey) > 0,
7272- "EthereumAddress": repo.EthereumAddress(),
7373- "Tokens": tokenInfo,
7474- "flashes": s.getFlashesFromSession(w, r, sess),
7777+ "Handle": repo.Handle,
7878+ "Did": repo.Repo.Did,
7979+ "HasSigningKey": len(repo.PublicKey) > 0,
8080+ "CredentialID": credentialID,
8181+ "Tokens": tokenInfo,
8282+ "flashes": s.getFlashesFromSession(w, r, sess),
7583 }); err != nil {
7684 logger.Error("failed to render template", "error", err)
7785 }
+21-6
server/handle_account_signer.go
···11package server
2233import (
44- "encoding/base64"
54 "encoding/json"
65 "net/http"
76 "time"
···6261 inbound := make(chan wsIncoming, 4)
6362 nextReq := make(chan signerRequest, 1)
64636464+ // pendingPayloads maps requestID → base64url payload so we can reconstruct
6565+ // the expected WebAuthn challenge when a sign_response arrives.
6666+ pendingPayloads := make(map[string]string)
6767+6568 ctx := r.Context()
6669 go func() {
6770 for {
···110113 case in := <-inbound:
111114 switch in.Type {
112115 case "sign_response":
113113- if in.Signature == "" {
114114- logger.Warn("signer: sign_response missing signature", "did", did)
116116+ payload, ok := pendingPayloads[in.RequestID]
117117+ if !ok {
118118+ logger.Warn("signer: sign_response for unknown requestId (no payload)", "did", did, "requestId", in.RequestID)
115119 continue
116120 }
117117- sigBytes, err := base64.RawURLEncoding.DecodeString(in.Signature)
121121+ delete(pendingPayloads, in.RequestID)
122122+123123+ rawSig, err := verifyWebAuthnSignResponse(repo.PublicKey, payload, in, s.config.Hostname, logger)
118124 if err != nil {
119119- logger.Warn("signer: sign_response bad base64url", "did", did, "error", err)
125125+ logger.Warn("signer: sign_response verification failed", "did", did, "requestId", in.RequestID, "error", err)
120126 continue
121127 }
122122- if !s.signerHub.DeliverSignature(did, in.RequestID, sigBytes) {
128128+ if !s.signerHub.DeliverSignature(did, in.RequestID, rawSig) {
123129 logger.Warn("signer: sign_response for unknown requestId", "did", did, "requestId", in.RequestID)
124130 }
125131126132 case "sign_reject":
133133+ delete(pendingPayloads, in.RequestID)
127134 if !s.signerHub.DeliverRejection(did, in.RequestID) {
128135 logger.Warn("signer: sign_reject for unknown requestId", "did", did, "requestId", in.RequestID)
129136 }
···137144 logger.Error("signer: failed to write request", "did", did, "error", err)
138145 req.reply <- signerReply{err: helpers.ErrSignerNotConnected}
139146 return
147147+ }
148148+149149+ // Record the payload so we can verify the WebAuthn challenge when
150150+ // the sign_response arrives.
151151+ if payload, err := extractPayloadFromMsg(req.msg); err == nil {
152152+ pendingPayloads[req.requestID] = payload
153153+ } else {
154154+ logger.Warn("signer: could not extract payload from sign_request", "did", did, "error", err)
140155 }
141156142157 logger.Info("signer: request sent", "did", did, "requestId", req.requestID)
···2121 return
2222 }
23232424- pubKey, err := atcrypto.ParsePublicBytesK256(repo.PublicKey)
2424+ pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey)
2525 if err != nil {
2626 logger.Error("error parsing stored public key", "error", err)
2727 helpers.ServerError(w, nil)
···39394040 // If this is a did:plc identity, fetch the actual rotation keys from the
4141 // current PLC document. After supplySigningKey transfers the rotation key
4242- // to the user's wallet, the PDS key is no longer authoritative and we
4242+ // to the user's passkey, the PDS key is no longer authoritative and we
4343 // must reflect the real state.
4444 if strings.HasPrefix(repo.Repo.Did, "did:plc:") {
4545 ctx := context.WithValue(r.Context(), identity.SkipCacheKey, true)
+3-3
server/handle_identity_sign_plc_operation.go
···3434//
3535// Unlike the previous implementation this handler never touches a private key.
3636// The rotation key (held by the PDS) signs the PLC operation envelope as
3737-// required by the PLC protocol; the user's signing key (held in their Ethereum
3838-// wallet) signs only the inner payload bytes delivered over the WebSocket.
3737+// required by the PLC protocol; the user's signing key (held in their passkey)
3838+// signs only the inner payload bytes delivered over the WebSocket.
3939func (s *Server) handleSignPlcOperation(w http.ResponseWriter, r *http.Request) {
4040 logger := s.logger.With("name", "handleSignPlcOperation")
4141···100100 op.Services = *req.Services
101101 }
102102103103- // Serialise the operation to CBOR — this is the payload the user's wallet
103103+ // Serialise the operation to CBOR — this is the payload the user's passkey
104104 // must sign. We send it to the signer and wait for the signature.
105105 opCBOR, err := op.MarshalCBOR()
106106 if err != nil {
+2-2
server/handle_identity_submit_plc_operation.go
···5252 // 1. The signing key (verificationMethods.atproto) matches the registered key.
5353 // 2. The service endpoint still points to this PDS.
5454 // 3. The rotation keys include at least one key that was already authorised
5555- // (either the user's wallet key or the PDS key, depending on whether
5555+ // (either the user's passkey or the PDS key, depending on whether
5656 // sovereignty has been transferred).
5757 // 4. The operation was signed by one of the current rotation keys (enforced
5858 // by plc.directory on submission, not re-checked here).
···6262 return
6363 }
64646565- pubKey, err := atcrypto.ParsePublicBytesK256(repo.PublicKey)
6565+ pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey)
6666 if err != nil {
6767 logger.Error("error parsing stored public key", "error", err)
6868 helpers.ServerError(w, nil)
+2-2
server/handle_identity_update_handle.go
···75757676 // Determine whether the PDS rotation key still has authority over
7777 // this DID. After supplySigningKey transfers the rotation key to the
7878- // user's wallet, the PDS key is no longer in the rotation key list
7878+ // user's passkey, the PDS key is no longer in the rotation key list
7979 // and cannot sign PLC operations.
8080 pdsRotationDIDKey := s.plcClient.RotationDIDKey()
8181 pdsCanSign := slices.Contains(latest.Operation.RotationKeys, pdsRotationDIDKey)
···8888 return
8989 }
9090 } else {
9191- // Rotation key belongs to the user's wallet. Delegate the
9191+ // Rotation key belongs to the user's passkey. Delegate the
9292 // signing to the signer over WebSocket, same as
9393 // handleSignPlcOperation does for other PLC operations.
9494 opCBOR, err := op.MarshalCBOR()
+78
server/handle_passkey_assertion_challenge.go
···11+package server
22+33+import (
44+ "crypto/sha256"
55+ "encoding/base64"
66+ "fmt"
77+ "net/http"
88+99+ "pkg.rbrt.fr/vow/internal/helpers"
1010+ "pkg.rbrt.fr/vow/models"
1111+)
1212+1313+// passkeyAssertionOptions is the JSON structure returned to the browser so it
1414+// can call navigator.credentials.get(). It mirrors the
1515+// PublicKeyCredentialRequestOptions WebAuthn type.
1616+type passkeyAssertionOptions struct {
1717+ Challenge string `json:"challenge"`
1818+ AllowCredentials []allowedCredential `json:"allowCredentials"`
1919+ Timeout int `json:"timeout"`
2020+ UserVerification string `json:"userVerification"`
2121+ RpID string `json:"rpId"`
2222+}
2323+2424+type allowedCredential struct {
2525+ ID string `json:"id"` // base64url-encoded credential ID
2626+ Type string `json:"type"` // always "public-key"
2727+}
2828+2929+// handlePasskeyAssertionChallenge returns WebAuthn PublicKeyCredentialRequestOptions
3030+// for operations that need a fresh passkey assertion — currently only account
3131+// deletion. The challenge is SHA-256("Delete account: <did>") so the server
3232+// can reconstruct and verify it without storing session state.
3333+//
3434+// POST /account/passkey-assertion-challenge
3535+func (s *Server) handlePasskeyAssertionChallenge(w http.ResponseWriter, r *http.Request) {
3636+ repo, ok := getContextValue[*models.RepoActor](r, contextKeyRepo)
3737+ if !ok {
3838+ helpers.UnauthorizedError(w, nil)
3939+ return
4040+ }
4141+4242+ if len(repo.PublicKey) == 0 {
4343+ s.writeJSON(w, http.StatusBadRequest, map[string]string{
4444+ "error": "NoSigningKey",
4545+ "message": "No passkey is registered for this account.",
4646+ })
4747+ return
4848+ }
4949+5050+ if len(repo.CredentialID) == 0 {
5151+ s.writeJSON(w, http.StatusBadRequest, map[string]string{
5252+ "error": "NoCredentialID",
5353+ "message": "No credential ID found for this account. Please re-register your passkey.",
5454+ })
5555+ return
5656+ }
5757+5858+ // Derive a deterministic challenge so we can verify it server-side without
5959+ // storing per-request state: SHA-256("Delete account: <did>").
6060+ msg := fmt.Sprintf("Delete account: %s", repo.Repo.Did)
6161+ sum := sha256.Sum256([]byte(msg))
6262+ challenge := base64.RawURLEncoding.EncodeToString(sum[:])
6363+6464+ opts := passkeyAssertionOptions{
6565+ Challenge: challenge,
6666+ AllowCredentials: []allowedCredential{
6767+ {
6868+ ID: base64.RawURLEncoding.EncodeToString(repo.CredentialID),
6969+ Type: "public-key",
7070+ },
7171+ },
7272+ Timeout: 30000,
7373+ UserVerification: "preferred",
7474+ RpID: s.config.Hostname,
7575+ }
7676+7777+ s.writeJSON(w, http.StatusOK, opts)
7878+}
+97
server/handle_passkey_challenge.go
···11+package server
22+33+import (
44+ "crypto/rand"
55+ "encoding/base64"
66+ "net/http"
77+88+ "pkg.rbrt.fr/vow/internal/helpers"
99+ "pkg.rbrt.fr/vow/models"
1010+)
1111+1212+// passkeyCreationOptions is the JSON structure returned to the browser so it
1313+// can call navigator.credentials.create(). It mirrors the
1414+// PublicKeyCredentialCreationOptions WebAuthn type.
1515+type passkeyCreationOptions struct {
1616+ Rp passkeyRp `json:"rp"`
1717+ User passkeyUser `json:"user"`
1818+ // Challenge is a base64url-encoded random byte string. The browser passes
1919+ // it through to the authenticator unchanged; the server doesn't need to
2020+ // verify it later because the attestation is verified via the
2121+ // clientDataJSON embedded in the attestationObject.
2222+ Challenge string `json:"challenge"`
2323+ PubKeyCredParams []pubKeyCredParam `json:"pubKeyCredParams"`
2424+ AuthenticatorSel authenticatorSelection `json:"authenticatorSelection"`
2525+ Attestation string `json:"attestation"`
2626+ Timeout int `json:"timeout"`
2727+}
2828+2929+type passkeyRp struct {
3030+ ID string `json:"id"`
3131+ Name string `json:"name"`
3232+}
3333+3434+type passkeyUser struct {
3535+ // ID is the base64url-encoded DID bytes. The WebAuthn spec requires it to
3636+ // be opaque user-handle bytes, not a human-readable string.
3737+ ID string `json:"id"`
3838+ Name string `json:"name"`
3939+ DisplayName string `json:"displayName"`
4040+}
4141+4242+type pubKeyCredParam struct {
4343+ Type string `json:"type"`
4444+ Alg int `json:"alg"` // -7 = ES256 (P-256)
4545+}
4646+4747+type authenticatorSelection struct {
4848+ UserVerification string `json:"userVerification"`
4949+ ResidentKey string `json:"residentKey"`
5050+}
5151+5252+// handlePasskeyChallenge returns WebAuthn PublicKeyCredentialCreationOptions
5353+// so the browser can register a new passkey for the authenticated account.
5454+//
5555+// POST /account/passkey-challenge
5656+func (s *Server) handlePasskeyChallenge(w http.ResponseWriter, r *http.Request) {
5757+ repo, ok := getContextValue[*models.RepoActor](r, contextKeyRepo)
5858+ if !ok {
5959+ helpers.UnauthorizedError(w, nil)
6060+ return
6161+ }
6262+6363+ // Generate a fresh 32-byte random challenge.
6464+ challengeBytes := make([]byte, 32)
6565+ if _, err := rand.Read(challengeBytes); err != nil {
6666+ helpers.ServerError(w, nil)
6767+ return
6868+ }
6969+ challenge := base64.RawURLEncoding.EncodeToString(challengeBytes)
7070+7171+ // Use the DID as the opaque user ID (base64url-encoded UTF-8 bytes).
7272+ userID := base64.RawURLEncoding.EncodeToString([]byte(repo.Repo.Did))
7373+7474+ opts := passkeyCreationOptions{
7575+ Rp: passkeyRp{
7676+ ID: s.config.Hostname,
7777+ Name: "Vow PDS",
7878+ },
7979+ User: passkeyUser{
8080+ ID: userID,
8181+ Name: repo.Handle,
8282+ DisplayName: repo.Handle,
8383+ },
8484+ Challenge: challenge,
8585+ PubKeyCredParams: []pubKeyCredParam{
8686+ {Type: "public-key", Alg: -7}, // ES256 / P-256
8787+ },
8888+ AuthenticatorSel: authenticatorSelection{
8989+ UserVerification: "preferred",
9090+ ResidentKey: "preferred",
9191+ },
9292+ Attestation: "none",
9393+ Timeout: 60000,
9494+ }
9595+9696+ s.writeJSON(w, http.StatusOK, opts)
9797+}
+1-1
server/handle_proxy.go
···89899090 // exp=0 tells signServiceAuthJWT to use the default lifetime and
9191 // cache the resulting token so repeated proxy calls for the same
9292- // (aud, lxm) pair reuse it instead of prompting the wallet each time.
9292+ // (aud, lxm) pair reuse it instead of prompting the passkey each time.
9393 token, err := s.signServiceAuthJWT(r.Context(), repo, aud, lxm, 0)
9494 if helpers.HandleSignerError(w, err) {
9595 logger.Error("error signing proxy JWT", "error", err)
+50-49
server/handle_server_delete_account.go
···2233import (
44 "context"
55- "encoding/hex"
55+ "crypto/sha256"
66+ "encoding/base64"
67 "encoding/json"
78 "fmt"
89 "net/http"
99- "strings"
1010 "time"
11111212 "github.com/bluesky-social/indigo/api/atproto"
1313 "github.com/bluesky-social/indigo/events"
1414 "github.com/bluesky-social/indigo/util"
1515- gethcrypto "github.com/ethereum/go-ethereum/crypto"
1615 "golang.org/x/crypto/bcrypt"
1716 "pkg.rbrt.fr/vow/internal/helpers"
1817 "pkg.rbrt.fr/vow/models"
···149148}
150149151150// ---------------------------------------------------------------------------
152152-// /account/delete — browser endpoint (web session + wallet signature)
151151+// /account/delete — browser endpoint (web session + WebAuthn assertion)
153152// ---------------------------------------------------------------------------
154153154154+// AccountDeleteRequest carries the WebAuthn assertion response fields sent by
155155+// the browser after the user confirms account deletion with their passkey.
155156type AccountDeleteRequest struct {
156156- WalletAddress string `json:"walletAddress" validate:"required"`
157157- Signature string `json:"signature" validate:"required"`
157157+ CredentialID string `json:"credentialId"` // base64url
158158+ ClientDataJSON string `json:"clientDataJSON"` // base64url
159159+ AuthenticatorData string `json:"authenticatorData"` // base64url
160160+ Signature string `json:"signature"` // base64url DER-encoded ECDSA
158161}
159162160160-// handleAccountDelete deletes the authenticated account after verifying that
161161-// the request is signed by the wallet whose public key is registered with the
162162-// account. Authentication is done via the web session cookie; the wallet
163163-// signature proves the user still controls the key, with no email or password
164164-// needed.
163163+// handleAccountDelete deletes the authenticated account after verifying a
164164+// WebAuthn assertion signed by the passkey registered for the account.
165165+// Authentication is via the web session cookie; the passkey assertion proves
166166+// the user still controls the device, with no password or email needed.
165167func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request) {
166168 ctx := r.Context()
167169 logger := s.logger.With("name", "handleAccountDelete")
···172174 return
173175 }
174176175175- // The account must have a registered signing key; without it we have no
176176- // wallet to verify against.
177177+ // The account must have a registered passkey; without it there is nothing
178178+ // to verify against.
177179 if len(repo.PublicKey) == 0 {
178180 s.writeJSON(w, http.StatusBadRequest, map[string]any{
179181 "error": "NoSigningKey",
180180- "message": "No signing key is registered for this account. Please register your wallet first.",
182182+ "message": "No passkey is registered for this account. Please register a passkey first.",
181183 })
182184 return
183185 }
···189191 return
190192 }
191193192192- if err := s.validator.Struct(&req); err != nil {
193193- logger.Error("validation failed", "error", err)
194194- s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "walletAddress and signature are required"})
194194+ if req.ClientDataJSON == "" || req.AuthenticatorData == "" || req.Signature == "" {
195195+ s.writeJSON(w, http.StatusBadRequest, map[string]string{
196196+ "error": "clientDataJSON, authenticatorData, and signature are required",
197197+ })
195198 return
196199 }
197200198198- // Decode the 65-byte personal_sign signature.
199199- sigHex := strings.TrimPrefix(req.Signature, "0x")
200200- sig, err := hex.DecodeString(sigHex)
201201- if err != nil || len(sig) != 65 {
202202- s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "signature must be a 65-byte hex string"})
201201+ // Decode base64url fields.
202202+ clientDataJSONBytes, err := base64.RawURLEncoding.DecodeString(req.ClientDataJSON)
203203+ if err != nil {
204204+ s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid clientDataJSON encoding"})
203205 return
204206 }
205207206206- // personal_sign uses v=27/28; go-ethereum SigToPub expects v=0/1.
207207- if sig[64] >= 27 {
208208- sig[64] -= 27
209209- }
210210-211211- // Hash the message with the Ethereum personal_sign envelope.
212212- msg := fmt.Sprintf("Delete account: %s", repo.Repo.Did)
213213- msgHash := gethcrypto.Keccak256(
214214- fmt.Appendf(nil, "\x19Ethereum Signed Message:\n%d%s", len(msg), msg),
215215- )
216216-217217- // Recover the public key from the signature.
218218- ecPub, err := gethcrypto.SigToPub(msgHash, sig)
208208+ authenticatorDataBytes, err := base64.RawURLEncoding.DecodeString(req.AuthenticatorData)
219209 if err != nil {
220220- logger.Warn("public key recovery failed", "error", err)
221221- s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "could not recover public key from signature"})
210210+ s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid authenticatorData encoding"})
222211 return
223212 }
224213225225- // Verify the recovered address matches the claimed wallet address.
226226- recoveredAddr := gethcrypto.PubkeyToAddress(*ecPub).Hex()
227227- if !strings.EqualFold(recoveredAddr, req.WalletAddress) {
228228- logger.Warn("address mismatch", "claimed", req.WalletAddress, "recovered", recoveredAddr)
229229- s.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "signature does not match the provided wallet address"})
214214+ signatureDER, err := base64.RawURLEncoding.DecodeString(req.Signature)
215215+ if err != nil {
216216+ s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid signature encoding"})
230217 return
231218 }
232219233233- // Verify the recovered address matches the wallet registered on the account.
234234- registeredAddr := repo.EthereumAddress()
235235- if !strings.EqualFold(recoveredAddr, registeredAddr) {
236236- logger.Warn("wallet not registered for account",
237237- "recovered", recoveredAddr,
238238- "registered", registeredAddr,
220220+ // Reconstruct the expected challenge: SHA-256("Delete account: <did>").
221221+ // This is the same derivation used by handlePasskeyAssertionChallenge, so
222222+ // no server-side session state is needed.
223223+ msg := fmt.Sprintf("Delete account: %s", repo.Repo.Did)
224224+ sum := sha256.Sum256([]byte(msg))
225225+226226+ // verifyAssertion checks the challenge, rpIdHash, UP flag, and P-256
227227+ // signature. It also returns the raw (r‖s) bytes, which we discard here.
228228+ if _, err := verifyAssertion(
229229+ repo.PublicKey,
230230+ sum[:],
231231+ clientDataJSONBytes,
232232+ authenticatorDataBytes,
233233+ signatureDER,
234234+ s.config.Hostname,
235235+ ); err != nil {
236236+ logger.Warn("WebAuthn assertion verification failed for account delete",
239237 "did", repo.Repo.Did,
238238+ "error", err,
240239 )
241241- s.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "signature wallet does not match the key registered for this account"})
240240+ s.writeJSON(w, http.StatusUnauthorized, map[string]string{
241241+ "error": "passkey verification failed: " + err.Error(),
242242+ })
242243 return
243244 }
244245
+13-17
server/handle_server_get_service_auth.go
···8080 })
8181}
82828383-// signServiceAuthJWT returns a signed ES256K service-auth JWT for the given
8484-// (aud, lxm) pair, reusing a cached token when possible. Only when no cached
8585-// token is available does it send a signing request to the user's wallet via
8686-// the SignerHub WebSocket.
8383+// signServiceAuthJWT returns a signed ES256 service-auth JWT for the given
8484+// (aud, lxm) pair. It sends a signing request to the user's passkey via the
8585+// SignerHub WebSocket and waits for the verified raw (r‖s) signature.
8786//
8887// The returned string is a fully formed "header.payload.signature" JWT ready to
8988// be placed in an Authorization: Bearer header.
···104103105104 // ── Build header + payload ────────────────────────────────────────────
106105 header := map[string]string{
107107- "alg": "ES256K",
108108- "crv": "secp256k1",
106106+ "alg": "ES256",
107107+ "crv": "P-256",
109108 "typ": "JWT",
110109 }
111110 hj, err := json.Marshal(header)
···144143 // base64url(header) + "." + base64url(payload).
145144 signingInput := encHeader + "." + encPayload
146145147147- // The wallet signs the SHA-256 hash of the signing input, which is what
148148- // ES256K requires. We pass the raw signingInput bytes as the payload;
149149- // HashAndVerifyLenient on the verification side hashes them before
150150- // verifying, matching what personal_sign does after EIP-191 prefix
151151- // stripping (or eth_sign which skips the prefix).
152152- //
153153- // We send the SHA-256 pre-image (the signingInput string) rather than the
154154- // hash so the signer can display it meaningfully and so the wallet can
155155- // apply its own hashing. This matches the pattern used for commit signing.
146146+ // ES256 requires signing the SHA-256 hash of the signing input. We send
147147+ // the hash as the WebAuthn challenge (the passkey will sign
148148+ // authenticatorData ‖ SHA-256(clientDataJSON) where clientDataJSON.challenge
149149+ // = base64url(hash)). The WS handler verifies the full assertion and
150150+ // delivers the raw (r‖s) signature bytes back to this function.
156151 hash := sha256.Sum256([]byte(signingInput))
157152 payloadB64 := base64.RawURLEncoding.EncodeToString(hash[:])
158153···180175 return "", err
181176 }
182177183183- // sigBytes is the raw compact (r||s) or EIP-191 signature returned by the
184184- // wallet. Trim to 64 bytes (r||s) if the wallet appended a recovery byte.
178178+ // sigBytes is the raw 64-byte (r‖s) P-256 signature delivered by the WS
179179+ // handler after WebAuthn assertion verification. Trim to 64 bytes just in
180180+ // case an old client appended a recovery byte.
185181 if len(sigBytes) == 65 {
186182 sigBytes = sigBytes[:64]
187183 }
+3-3
server/handle_server_get_signing_key.go
···10101111// PendingWriteOp is a human-readable summary of a single operation inside a
1212// signing request, sent to the signer so the user knows what they are
1313-// approving before the wallet prompt appears.
1313+// approving before the passkey prompt appears.
1414type PendingWriteOp struct {
1515 Type string `json:"type"`
1616 Collection string `json:"collection"`
···2525 PublicKey string `json:"publicKey"`
2626}
27272828-// handleGetSigningKey returns the compressed secp256k1 public key registered
2828+// handleGetSigningKey returns the compressed P-256 public key registered
2929// for the authenticated account, encoded as a did:key string.
3030//
3131// The private key is never held by the PDS; this endpoint only confirms that a
···4444 return
4545 }
46464747- pubKey, err := atcrypto.ParsePublicBytesK256(repo.PublicKey)
4747+ pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey)
4848 if err != nil {
4949 logger.Error("error parsing stored public key", "error", err)
5050 helpers.ServerError(w, nil)
+51-89
server/handle_server_supply_signing_key.go
···11package server
2233import (
44- "encoding/hex"
44+ "encoding/base64"
55 "encoding/json"
66- "fmt"
76 "maps"
87 "net/http"
98 "strings"
1091110 "github.com/bluesky-social/indigo/atproto/atcrypto"
1212- gethcrypto "github.com/ethereum/go-ethereum/crypto"
1311 "pkg.rbrt.fr/vow/identity"
1412 "pkg.rbrt.fr/vow/internal/helpers"
1513 "pkg.rbrt.fr/vow/models"
1614 "pkg.rbrt.fr/vow/plc"
1715)
18161919-// ComAtprotoServerSupplySigningKeyRequest is sent by the account page to
2020-// register the user's secp256k1 public key with the PDS. The client sends the
2121-// wallet address and the signature over a fixed registration message; the PDS
2222-// recovers the public key server-side using go-ethereum and verifies it
2323-// matches the wallet address before storing it.
2424-type ComAtprotoServerSupplySigningKeyRequest struct {
2525- // WalletAddress is the EIP-55 checksummed Ethereum address of the wallet.
2626- WalletAddress string `json:"walletAddress" validate:"required"`
2727- // Signature is the hex-encoded 65-byte personal_sign signature (0x-prefixed).
2828- Signature string `json:"signature" validate:"required"`
1717+// SupplySigningKeyRequest is sent by the account page to register a WebAuthn
1818+// passkey as the account's signing key. The browser calls
1919+// navigator.credentials.create() and forwards the raw attestation response
2020+// fields here; the server parses the CBOR attestation object, extracts the
2121+// P-256 public key, and stores it alongside the credential ID.
2222+type SupplySigningKeyRequest struct {
2323+ // ClientDataJSON is the base64url-encoded clientDataJSON bytes from the
2424+ // AuthenticatorAttestationResponse.
2525+ ClientDataJSON string `json:"clientDataJSON" validate:"required"`
2626+ // AttestationObject is the base64url-encoded attestationObject CBOR from
2727+ // the AuthenticatorAttestationResponse.
2828+ AttestationObject string `json:"attestationObject" validate:"required"`
2929}
30303131-type ComAtprotoServerSupplySigningKeyResponse struct {
3232- Did string `json:"did"`
3333- PublicKey string `json:"publicKey"` // did:key representation
3131+type SupplySigningKeyResponse struct {
3232+ Did string `json:"did"`
3333+ PublicKey string `json:"publicKey"` // did:key representation
3434+ CredentialID string `json:"credentialId"` // base64url
3435}
35363636-// handleSupplySigningKey lets the account page register the user's
3737-// secp256k1 public key. The PDS stores only the compressed public key bytes
3838-// and updates the PLC DID document so the key becomes the active
3939-// verificationMethods.atproto entry.
3737+// handleSupplySigningKey registers a WebAuthn passkey for the authenticated
3838+// account. The private key never leaves the authenticator; the PDS stores only
3939+// the compressed P-256 public key and the credential ID.
4040//
4141-// The private key is never transmitted to or stored by the PDS.
4242-// registrationMessage is the fixed plaintext that the wallet must sign during
4343-// key registration. It is prefixed with the Ethereum personal_sign envelope
4444-// ("\x19Ethereum Signed Message:\n<len>") by the wallet before signing.
4545-const registrationMessage = "Vow key registration"
4646-4141+// On success, the account's PLC DID document is updated so that the passkey's
4242+// did:key becomes the active atproto verification method and rotation key.
4743func (s *Server) handleSupplySigningKey(w http.ResponseWriter, r *http.Request) {
4844 ctx := r.Context()
4945 logger := s.logger.With("name", "handleSupplySigningKey")
···5450 return
5551 }
56525757- var req ComAtprotoServerSupplySigningKeyRequest
5353+ var req SupplySigningKeyRequest
5854 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
5955 logger.Error("error decoding request", "error", err)
6056 helpers.InputError(w, new("could not decode request body"))
···63596460 if err := s.validator.Struct(req); err != nil {
6561 logger.Error("validation failed", "error", err)
6666- helpers.InputError(w, new("walletAddress and signature are required"))
6262+ helpers.InputError(w, new("clientDataJSON and attestationObject are required"))
6763 return
6864 }
69657070- // Decode the 65-byte personal_sign signature.
7171- sigHex := strings.TrimPrefix(req.Signature, "0x")
7272- sig, err := hex.DecodeString(sigHex)
7373- if err != nil || len(sig) != 65 {
7474- helpers.InputError(w, new("signature must be a 65-byte hex string"))
7575- return
7676- }
7777-7878- // personal_sign uses v=27/28; go-ethereum SigToPub expects v=0/1.
7979- if sig[64] >= 27 {
8080- sig[64] -= 27
8181- }
8282-8383- // Hash the message the same way personal_sign does:
8484- // keccak256("\x19Ethereum Signed Message:\n<len><message>")
8585- msgHash := gethcrypto.Keccak256(
8686- fmt.Appendf(nil, "\x19Ethereum Signed Message:\n%d%s",
8787- len(registrationMessage), registrationMessage),
8888- )
8989-9090- // Recover the uncompressed public key.
9191- ecPub, err := gethcrypto.SigToPub(msgHash, sig)
6666+ // Parse the attestation object and extract the P-256 public key +
6767+ // credential ID. We accept both "none" and self-attestation.
6868+ keyBytes, credentialID, err := parseAttestationObject(req.AttestationObject)
9269 if err != nil {
9393- logger.Warn("public key recovery failed", "error", err)
9494- helpers.InputError(w, new("could not recover public key from signature"))
7070+ logger.Warn("attestation parsing failed", "error", err)
7171+ helpers.InputError(w, new("could not parse attestation object"))
9572 return
9673 }
97749898- // Verify the recovered key matches the claimed wallet address.
9999- recoveredAddr := gethcrypto.PubkeyToAddress(*ecPub).Hex()
100100- if !strings.EqualFold(recoveredAddr, req.WalletAddress) {
101101- logger.Warn("recovered address mismatch",
102102- "claimed", req.WalletAddress,
103103- "recovered", recoveredAddr,
104104- )
105105- helpers.InputError(w, new("recovered address does not match walletAddress"))
106106- return
107107- }
108108-109109- // Compress the public key (33 bytes).
110110- keyBytes := gethcrypto.CompressPubkey(ecPub)
111111-112112- // Validate the compressed key is accepted by the atproto library.
113113- pubKey, err := atcrypto.ParsePublicBytesK256(keyBytes)
7575+ // Validate the compressed key is a well-formed P-256 point.
7676+ pubKey, err := atcrypto.ParsePublicBytesP256(keyBytes)
11477 if err != nil {
115115- logger.Error("compressed key rejected by atcrypto", "error", err)
116116- helpers.ServerError(w, nil)
7878+ logger.Error("compressed P-256 key rejected by atcrypto", "error", err)
7979+ helpers.InputError(w, new("invalid P-256 public key in attestation"))
11780 return
11881 }
1198212083 pubDIDKey := pubKey.DIDKey()
12184122122- // Update the PLC DID document if this is a did:plc identity so that the
123123- // new public key is the active atproto verification method.
8585+ // Update the PLC DID document so the passkey's did:key becomes the active
8686+ // atproto verification method and the sole rotation key.
12487 if strings.HasPrefix(repo.Repo.Did, "did:plc:") {
12588 log, err := identity.FetchDidAuditLog(ctx, nil, repo.Repo.Did)
12689 if err != nil {
···13598 maps.Copy(newVerificationMethods, latest.Operation.VerificationMethods)
13699 newVerificationMethods["atproto"] = pubDIDKey
137100138138- // Replace the PDS rotation key with the user's wallet key. After
139139- // this operation the PDS can no longer unilaterally modify the DID
140140- // document — only the user's Ethereum wallet can authorise future
141141- // PLC operations. This is the moment the identity becomes
142142- // user-sovereign.
101101+ // Replace the PDS rotation key with the passkey's did:key. After this
102102+ // operation the PDS can no longer unilaterally modify the DID document
103103+ // — only the user's passkey can authorise future PLC operations.
143104 newRotationKeys := []string{pubDIDKey}
144105145106 op := plc.Operation{
···151112 Prev: &latest.Cid,
152113 }
153114154154- // The PLC operation is signed by the PDS rotation key, which still
155155- // has authority over the DID at this point. This is the last
156156- // operation the PDS will ever be able to sign — it is voluntarily
157157- // handing over control to the user's wallet key.
115115+ // The PDS rotation key signs this PLC operation — this is the last
116116+ // PLC operation the PDS will ever be able to sign on behalf of the
117117+ // user. It is voluntarily handing over control to the passkey.
158118 if err := s.plcClient.SignOp(&op); err != nil {
159119 logger.Error("error signing PLC operation with rotation key", "error", err)
160120 helpers.ServerError(w, nil)
···168128 }
169129 }
170130171171- // Persist the compressed public key.
131131+ // Persist the compressed P-256 public key and credential ID.
172132 if err := s.db.Exec(ctx,
173173- "UPDATE repos SET public_key = ? WHERE did = ?",
174174- nil, keyBytes, repo.Repo.Did,
133133+ "UPDATE repos SET public_key = ?, credential_id = ? WHERE did = ?",
134134+ nil, keyBytes, credentialID, repo.Repo.Did,
175135 ).Error; err != nil {
176176- logger.Error("error updating public key in db", "error", err)
136136+ logger.Error("error updating public key and credential ID in db", "error", err)
177137 helpers.ServerError(w, nil)
178138 return
179139 }
···183143 logger.Warn("error busting DID doc cache", "error", err)
184144 }
185145186186- logger.Info("public signing key registered via BYOK — rotation key transferred to user",
146146+ logger.Info("passkey registered — rotation key transferred to user",
187147 "did", repo.Repo.Did,
188148 "publicKey", pubDIDKey,
149149+ "credentialIDLen", len(credentialID),
189150 )
190151191191- s.writeJSON(w, 200, ComAtprotoServerSupplySigningKeyResponse{
192192- Did: repo.Repo.Did,
193193- PublicKey: pubDIDKey,
152152+ s.writeJSON(w, 200, SupplySigningKeyResponse{
153153+ Did: repo.Repo.Did,
154154+ PublicKey: pubDIDKey,
155155+ CredentialID: base64.RawURLEncoding.EncodeToString(credentialID),
194156 })
195157}
+108-19
server/handle_signer_connect.go
···33import (
44 "encoding/base64"
55 "encoding/json"
66+ "log/slog"
67 "net/http"
78 "strings"
89 "time"
···3536 Type string `json:"type"` // always "sign_request"
3637 RequestID string `json:"requestId"` // UUID, echoed back in the response
3738 Did string `json:"did"`
3838- Payload string `json:"payload"` // base64url-encoded unsigned commit CBOR
3939+ Payload string `json:"payload"` // base64url-encoded unsigned commit CBOR (used as the WebAuthn challenge)
3940 Ops []PendingWriteOp `json:"ops"` // human-readable summary shown to user
4041 ExpiresAt string `json:"expiresAt"` // RFC3339
4142}
42434344// wsIncoming is used for initial type-sniffing before full decode.
4545+//
4646+// sign_response carries the three fields from the WebAuthn AuthenticatorAssertionResponse:
4747+// - AuthenticatorData: base64url authenticatorData bytes
4848+// - ClientDataJSON: base64url clientDataJSON bytes
4949+// - Signature: base64url DER-encoded ECDSA signature
4450type wsIncoming struct {
4545- Type string `json:"type"`
4646- RequestID string `json:"requestId"`
4747- // sign_response: base64url-encoded signature bytes.
4848- Signature string `json:"signature,omitempty"`
5151+ Type string `json:"type"`
5252+ RequestID string `json:"requestId"`
5353+ AuthenticatorData string `json:"authenticatorData,omitempty"` // base64url
5454+ ClientDataJSON string `json:"clientDataJSON,omitempty"` // base64url
5555+ Signature string `json:"signature,omitempty"` // base64url DER-encoded ECDSA
4956}
50575158// handleSignerConnect upgrades the connection to a WebSocket and registers it
···5764//
5865// 1. When a write handler needs a signature it calls SignerHub.RequestSignature
5966// which pushes a signerRequest onto the conn.requests channel.
6060-// 2. This goroutine picks it up, writes the sign_request (or pay_request) JSON
6161-// frame, and waits for a sign_response / pay_response or their reject
6262-// counterparts from the client.
6363-// 3. The reply is forwarded back to the waiting write handler via the reply
6464-// channel inside the signerRequest.
6767+// 2. This goroutine picks it up, writes the sign_request JSON frame, and waits
6868+// for a sign_response or sign_reject from the client.
6969+// 3. The WebAuthn assertion is verified here; the resulting raw (r‖s) signature
7070+// bytes are forwarded to the waiting write handler via DeliverSignature.
6571//
6672// The loop also handles WebSocket ping/pong: the server sends a ping every 20 s
6773// and expects a pong within 10 s (gorilla handles pong automatically).
···135141 // inbound carries decoded messages from the reader goroutine.
136142 inbound := make(chan wsIncoming, 4)
137143138138- // nextReq carries the next queued request to be sent to the wallet.
139139- // The NextRequest goroutine blocks until a request is ready and no other
140140- // request is in-flight (serialising wallet prompts automatically).
144144+ // nextReq carries the next queued request to be sent to the signer.
141145 nextReq := make(chan signerRequest, 1)
142146147147+ // pendingPayloads maps requestID → base64url payload so that when a
148148+ // sign_response arrives we can reconstruct the expected WebAuthn challenge
149149+ // (the raw bytes that the payload string encodes).
150150+ pendingPayloads := make(map[string]string)
151151+143152 ctx := r.Context()
144153145154 // Read pump: conn.ReadMessage blocks so it runs in its own goroutine.
···164173 }()
165174166175 // Queue pump: feeds the main loop one request at a time, respecting the
167167- // wallet's one-at-a-time constraint enforced inside NextRequest.
176176+ // passkey's one-at-a-time constraint enforced inside NextRequest.
168177 go func() {
169178 for {
170179 req, ok := sc.NextRequest(ctx)
···206215 case in := <-inbound:
207216 switch in.Type {
208217 case "sign_response":
209209- if in.Signature == "" {
210210- logger.Warn("signer: sign_response missing signature", "did", did)
218218+ payload, ok := pendingPayloads[in.RequestID]
219219+ if !ok {
220220+ logger.Warn("signer: sign_response for unknown requestId (no payload)", "did", did, "requestId", in.RequestID)
211221 continue
212222 }
213213- sigBytes, err := base64.RawURLEncoding.DecodeString(in.Signature)
223223+ delete(pendingPayloads, in.RequestID)
224224+225225+ rawSig, err := verifyWebAuthnSignResponse(repo.PublicKey, payload, in, s.config.Hostname, logger)
214226 if err != nil {
215215- logger.Warn("signer: sign_response bad base64url", "did", did, "error", err)
227227+ logger.Warn("signer: sign_response verification failed", "did", did, "requestId", in.RequestID, "error", err)
216228 continue
217229 }
218218- if !s.signerHub.DeliverSignature(did, in.RequestID, sigBytes) {
230230+ if !s.signerHub.DeliverSignature(did, in.RequestID, rawSig) {
219231 logger.Warn("signer: sign_response for unknown requestId", "did", did, "requestId", in.RequestID)
220232 }
221233222234 case "sign_reject":
235235+ delete(pendingPayloads, in.RequestID)
223236 if !s.signerHub.DeliverRejection(did, in.RequestID) {
224237 logger.Warn("signer: sign_reject for unknown requestId", "did", did, "requestId", in.RequestID)
225238 }
···234247 logger.Error("signer: failed to write request", "did", did, "error", err)
235248 req.reply <- signerReply{err: helpers.ErrSignerNotConnected}
236249 return
250250+ }
251251+252252+ // Record the payload so we can verify the WebAuthn challenge when
253253+ // the sign_response arrives.
254254+ if payload, err := extractPayloadFromMsg(req.msg); err == nil {
255255+ pendingPayloads[req.requestID] = payload
256256+ } else {
257257+ logger.Warn("signer: could not extract payload from sign_request", "did", did, "error", err)
237258 }
238259239260 logger.Info("signer: request sent", "did", did, "requestId", req.requestID)
···269290 Ops: ops,
270291 ExpiresAt: expiresAt.UTC().Format(time.RFC3339),
271292 })
293293+}
294294+295295+// extractPayloadFromMsg extracts the "payload" field from a sign_request JSON
296296+// message without a full re-parse.
297297+func extractPayloadFromMsg(msg []byte) (string, error) {
298298+ var req struct {
299299+ Payload string `json:"payload"`
300300+ }
301301+ if err := json.Unmarshal(msg, &req); err != nil {
302302+ return "", err
303303+ }
304304+ if req.Payload == "" {
305305+ return "", nil
306306+ }
307307+ return req.Payload, nil
308308+}
309309+310310+// verifyWebAuthnSignResponse decodes the three base64url fields from a
311311+// sign_response message, reconstructs the expected challenge from the payload,
312312+// verifies the WebAuthn P-256 assertion, and returns the raw 64-byte (r‖s)
313313+// signature for use in ATProto commits and JWTs.
314314+//
315315+// pubKey is the compressed P-256 public key stored in the database.
316316+// payloadB64 is the base64url-encoded challenge bytes that were sent in the
317317+// sign_request (the raw CBOR bytes of the unsigned commit, or the SHA-256 of
318318+// the JWT signing input for service-auth tokens).
319319+func verifyWebAuthnSignResponse(
320320+ pubKey []byte,
321321+ payloadB64 string,
322322+ in wsIncoming,
323323+ rpID string,
324324+ logger *slog.Logger,
325325+) ([]byte, error) {
326326+ if in.AuthenticatorData == "" || in.ClientDataJSON == "" || in.Signature == "" {
327327+ return nil, helpers.ErrSignerNotConnected // reuse a sentinel; caller logs
328328+ }
329329+330330+ // The challenge passed to navigator.credentials.get() was the raw bytes
331331+ // decoded from payloadB64. The browser re-encodes them as base64url in
332332+ // clientDataJSON.challenge — so the expected challenge is exactly those
333333+ // raw bytes.
334334+ expectedChallenge, err := base64.RawURLEncoding.DecodeString(payloadB64)
335335+ if err != nil {
336336+ return nil, err
337337+ }
338338+339339+ clientDataJSONBytes, err := base64.RawURLEncoding.DecodeString(in.ClientDataJSON)
340340+ if err != nil {
341341+ return nil, err
342342+ }
343343+344344+ authenticatorDataBytes, err := base64.RawURLEncoding.DecodeString(in.AuthenticatorData)
345345+ if err != nil {
346346+ return nil, err
347347+ }
348348+349349+ signatureDER, err := base64.RawURLEncoding.DecodeString(in.Signature)
350350+ if err != nil {
351351+ return nil, err
352352+ }
353353+354354+ rawSig, err := verifyAssertion(pubKey, expectedChallenge, clientDataJSONBytes, authenticatorDataBytes, signatureDER, rpID)
355355+ if err != nil {
356356+ logger.With("rpID", rpID).Debug("verifyAssertion detail", "error", err)
357357+ return nil, err
358358+ }
359359+360360+ return rawSig, nil
272361}
273362274363// isTokenExpired returns true if the JWT's exp claim is in the past.
+13-10
server/middleware.go
···155155 repo = maybeRepo
156156 }
157157158158- if token.Header["alg"] != "ES256K" {
158158+ // isUserSignedToken is true for service-auth JWTs signed by the user's
159159+ // passkey (ES256 with an lxm claim). Regular access tokens use ES256
160160+ // too but are signed by the PDS private key and carry no lxm claim.
161161+ isUserSignedToken := token.Header["alg"] == "ES256" && hasLxm
162162+163163+ if !isUserSignedToken {
159164 token, err = new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) {
160165 if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
161166 return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"])
162167 }
163163- return s.privateKey.Public(), nil
168168+ return &s.privateKey.PublicKey, nil
164169 })
165170 if err != nil {
166171 logger.Error("error parsing jwt", "error", err)
···191196 if repo == nil {
192197 sub, ok := claims["sub"].(string)
193198 if !ok {
194194- s.logger.Error("no sub claim in ES256K token and repo not set")
199199+ s.logger.Error("no sub claim in user-signed token and repo not set")
195200 helpers.InvalidTokenError(w)
196201 return
197202 }
198203 maybeRepo, err := s.getRepoActorByDid(ctx, sub)
199204 if err != nil {
200200- s.logger.Error("error fetching repo for ES256K verification", "error", err)
205205+ s.logger.Error("error fetching repo for user-signed token verification", "error", err)
201206 helpers.ServerError(w, nil)
202207 return
203208 }
···205210 did = sub
206211 }
207212208208- // The PDS never holds a private key. Verify the ES256K JWT
209209- // signature using the compressed public key stored in PublicKey.
213213+ // The PDS never holds the user's private key. Verify the JWT
214214+ // signature using the compressed P-256 public key stored in the DB.
210215 if len(repo.PublicKey) == 0 {
211216 logger.Error("no public key registered for account", "did", repo.Repo.Did)
212217 helpers.ServerError(w, nil)
213218 return
214219 }
215220216216- pubKey, err := atcrypto.ParsePublicBytesK256(repo.PublicKey)
221221+ pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey)
217222 if err != nil {
218223 logger.Error("can't parse stored public key", "error", err)
219224 helpers.ServerError(w, nil)
220225 return
221226 }
222227223223- // sigBytes is already the compact (r||s) 64-byte form. Verify
224224- // using HashAndVerifyLenient which hashes signingInput internally.
225228 if err := pubKey.HashAndVerifyLenient([]byte(signingInput), sigBytes); err != nil {
226226- logger.Error("ES256K signature verification failed", "error", err)
229229+ logger.Error("user-signed JWT verification failed", "error", err)
227230 helpers.ServerError(w, nil)
228231 return
229232 }
+3-3
server/repo.go
···219219// provided raw signature bytes, reserialises the commit, writes the commit
220220// block to the blockstore, and returns the commit CID.
221221//
222222-// sig must be the raw secp256k1 signature (compact or DER) over uc.cbor as
223223-// produced by an Ethereum wallet's personal_sign / eth_sign call.
222222+// sig must be the raw 64-byte (r‖s) P-256 ECDSA signature over uc.cbor as
223223+// produced by the passkey WebAuthn assertion and verified by the WS handler.
224224func finaliseCommit(ctx context.Context, bs blockstore.Blockstore, uc *unsignedCommit, sig []byte) (cid.Cid, error) {
225225 // Decode the unsigned commit so we can attach the signature field.
226226 var commit atp.Commit
···599599 return nil, fmt.Errorf("no public key registered for account %s", urepo.Did)
600600 }
601601602602- pubKey, err := atcrypto.ParsePublicBytesK256(urepo.PublicKey)
602602+ pubKey, err := atcrypto.ParsePublicBytesP256(urepo.PublicKey)
603603 if err != nil {
604604 return nil, fmt.Errorf("parsing stored public key: %w", err)
605605 }
···31313232// signerConn represents one active signer WebSocket connection for a DID.
3333// It owns an unbounded queue of pending requests and a map of in-flight
3434-// requests waiting for a reply from the wallet.
3434+// requests waiting for a reply from the passkey.
3535type signerConn struct {
3636 // mu protects pending and inflight.
3737 mu sync.Mutex
38383939 // pending is an ordered queue of requests that have not yet been sent to
4040- // the wallet. The WS goroutine drains it one at a time: it pops the head,
4040+ // the passkey. The WS goroutine drains it one at a time: it pops the head,
4141 // sends the sign_request frame, moves the request into inflight, and only
4242- // pops the next one after a reply arrives. This serialises wallet prompts
4242+ // pops the next one after a reply arrives. This serialises passkey prompts
4343 // (the user must confirm each one before the next appears) while allowing
4444 // any number of callers to enqueue work concurrently.
4545 pending []signerRequest
46464747- // inflight holds the single request that has been sent to the wallet and
4747+ // inflight holds the single request that has been sent to the passkey and
4848 // is awaiting a sign_response / sign_reject. Keyed by requestID.
4949 inflight map[string]signerRequest
5050···127127}
128128129129// RequestSignature enqueues a signing request for did and blocks until one of:
130130-// - The wallet sends sign_response → returns the signature bytes.
131131-// - The wallet sends sign_reject → returns helpers.ErrSignerRejected.
130130+// - The signer sends sign_response → returns the signature bytes.
131131+// - The signer sends sign_reject → returns helpers.ErrSignerRejected.
132132// - The WebSocket disconnects → returns helpers.ErrSignerNotConnected.
133133// - ctx is cancelled or times out → returns helpers.ErrSignerTimeout or ctx.Err().
134134//
135135// Multiple callers for the same DID are all queued and processed sequentially
136136-// (the wallet sees one prompt at a time). Callers for different DIDs are
136136+// (the passkey sees one prompt at a time). Callers for different DIDs are
137137// independent.
138138func (h *SignerHub) RequestSignature(ctx context.Context, did string, requestID string, msg []byte) ([]byte, error) {
139139 h.mu.Lock()
···243243 return true
244244}
245245246246-// NextRequest blocks until a pending request is ready to be sent to the wallet
247247-// and no other request is currently in-flight (wallets handle one prompt at a
248248-// time). It moves the request from pending into inflight before returning, so
249249-// the caller just needs to write it to the WebSocket.
246246+// NextRequest blocks until a pending request is ready to be sent to the signer
247247+// and no other request is currently in-flight (the passkey handles one prompt
248248+// at a time). It moves the request from pending into inflight before returning,
249249+// so the caller just needs to write it to the WebSocket.
250250//
251251// Returns (request, true) on success, or (zero, false) if the connection is
252252// going away (done closed or ctx cancelled).
253253func (conn *signerConn) NextRequest(ctx context.Context) (signerRequest, bool) {
254254 for {
255255 conn.mu.Lock()
256256- // Only dequeue when nothing is in-flight (wallet is free).
256256+ // Only dequeue when nothing is in-flight (passkey is free).
257257 if len(conn.pending) > 0 && len(conn.inflight) == 0 {
258258 req := conn.pending[0]
259259 conn.pending = conn.pending[1:]
+306-543
server/templates/account.html
···5555 <h3>Signing Key</h3>
5656 {{ if .HasSigningKey }}
5757 <p>
5858- A signing key is registered for this account.<br />
5858+ A passkey is registered for this account. {{ if
5959+ .CredentialID }}
6060+ <br />
5961 <small style="opacity: 0.7"
6060- >Ethereum address:
6161- <code>{{ .EthereumAddress }}</code></small
6262+ >Credential ID:
6363+ <code>{{ slice .CredentialID 0 16 }}…</code></small
6264 >
6565+ {{ end }}
6366 </p>
6467 {{ else }}
6568 <p>
6666- No signing key is registered yet. Connect your Ethereum
6767- wallet to register your public key with this PDS.
6969+ No signing key is registered yet. Register a passkey to
7070+ enable signing for this account.
6871 </p>
6972 {{ end }}
7073···75787679 <div class="button-row">
7780 <button id="btn-register-key" class="primary">
7878- {{ if .HasSigningKey }}Update signing key{{ else
7979- }}Register signing key{{ end }}
8181+ {{ if .HasSigningKey }}Update passkey{{ else }}Register
8282+ passkey{{ end }}
8083 </button>
8184 </div>
8285 </div>
···8790 <h3>Signer</h3>
8891 <p>
8992 The signer connects to your PDS over a WebSocket and signs
9090- commits using your Ethereum wallet. Keep this page open (a
9191- pinned tab works great) to sign requests automatically.
9393+ commits using your passkey. Keep this page open (a pinned
9494+ tab works great) to approve requests automatically.
9295 </p>
93969497 <div
···188191 {{ if .HasSigningKey }}
189192 <p>
190193 <small style="opacity: 0.7">
191191- Your wallet (<code>{{ .EthereumAddress }}</code>)
192192- will be asked to sign a confirmation message. No
193193- transaction will be broadcast.
194194+ Your passkey will be asked to confirm this action.
195195+ No data will be sent externally.
194196 </small>
195197 </p>
196198 <div class="button-row">
···201203 {{ else }}
202204 <p>
203205 <small style="opacity: 0.7">
204204- Account deletion requires a registered signing key
205205- so your wallet can confirm the request. Please
206206- register your wallet above first.
206206+ Account deletion requires a registered passkey to
207207+ confirm the request. Please register a passkey above
208208+ first.
207209 </small>
208210 </p>
209211 {{ end }}
···214216 <p><strong>Are you absolutely sure?</strong></p>
215217 <p>
216218 <small style="opacity: 0.7">
217217- Clicking the button below will prompt your wallet to
218218- sign
219219- <code style="word-break: break-all"
220220- >"Delete account: {{ .Did }}"</code
219219+ Clicking the button below will prompt your passkey
220220+ to confirm deletion of
221221+ <code style="word-break: break-all">{{ .Did }}</code
221222 >. The server will verify the signature and
222223 permanently erase all your data. This cannot be
223224 undone.
···225226 </p>
226227 <div class="button-row">
227228 <button id="btn-delete-confirm" class="danger">
228228- Yes, sign and delete my account
229229+ Yes, confirm and delete my account
229230 </button>
230231 <button id="btn-delete-cancel">Cancel</button>
231232 </div>
···235236236237 <script>
237238 // ---------------------------------------------------------------------------
238238- // Signing key registration via window.ethereum (EIP-1193)
239239+ // Utilities
240240+ // ---------------------------------------------------------------------------
241241+242242+ function bytesToBase64url(bytes) {
243243+ let binary = "";
244244+ for (let i = 0; i < bytes.byteLength; i++) {
245245+ binary += String.fromCharCode(bytes[i]);
246246+ }
247247+ return btoa(binary)
248248+ .replace(/\+/g, "-")
249249+ .replace(/\//g, "_")
250250+ .replace(/=+$/, "");
251251+ }
252252+253253+ function base64urlToBytes(b64url) {
254254+ let b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
255255+ while (b64.length % 4 !== 0) b64 += "=";
256256+ const raw = atob(b64);
257257+ const out = new Uint8Array(raw.length);
258258+ for (let i = 0; i < raw.length; i++) {
259259+ out[i] = raw.charCodeAt(i);
260260+ }
261261+ return out;
262262+ }
263263+264264+ // ---------------------------------------------------------------------------
265265+ // Passkey registration
239266 // ---------------------------------------------------------------------------
240267241268 const btn = document.getElementById("btn-register-key");
···257284 btn.addEventListener("click", async () => {
258285 hideMsg();
259286260260- if (!window.ethereum) {
287287+ if (!window.PublicKeyCredential) {
261288 showMsg(
262262- "No Ethereum wallet detected. Please install MetaMask, Rabby, or another EIP-1193 wallet and reload.",
289289+ "Your browser does not support passkeys (WebAuthn). Please use a modern browser.",
263290 "error",
264291 );
265292 return;
266293 }
267294268295 btn.disabled = true;
269269- btn.textContent = "Connecting…";
296296+ btn.textContent = "Requesting passkey options…";
270297271298 try {
272272- // 1. Request accounts
273273- const accounts = await window.ethereum.request({
274274- method: "eth_requestAccounts",
299299+ // 1. Fetch creation options from the server.
300300+ const optResp = await fetch("/account/passkey-challenge", {
301301+ method: "POST",
275302 });
276276- if (!accounts || accounts.length === 0) {
277277- throw new Error("No accounts returned from wallet.");
303303+ if (!optResp.ok) {
304304+ throw new Error(
305305+ "Failed to get passkey challenge (" +
306306+ optResp.status +
307307+ ")",
308308+ );
278309 }
279279- const account = accounts[0];
310310+ const options = await optResp.json();
280311281281- // 2. Sign the fixed registration message.
282282- btn.textContent = "Sign the message in your wallet…";
283283- const signature = await window.ethereum.request({
284284- method: "personal_sign",
285285- params: ["Vow key registration", account],
312312+ // Convert base64url strings to ArrayBuffers for the WebAuthn API.
313313+ options.challenge = base64urlToBytes(options.challenge);
314314+ options.user.id = base64urlToBytes(options.user.id);
315315+316316+ // 2. Create the passkey.
317317+ btn.textContent = "Confirm with your passkey…";
318318+ const credential = await navigator.credentials.create({
319319+ publicKey: options,
286320 });
287321288288- // 3. POST signature + address; the server recovers the key.
322322+ if (!credential) {
323323+ throw new Error("Passkey creation was cancelled.");
324324+ }
325325+326326+ // 3. Send the attestation response to the server.
289327 btn.textContent = "Registering…";
290328 const res = await fetch("/account/supply-signing-key", {
291329 method: "POST",
292330 headers: { "Content-Type": "application/json" },
293331 body: JSON.stringify({
294294- walletAddress: account,
295295- signature,
332332+ clientDataJSON: bytesToBase64url(
333333+ new Uint8Array(
334334+ credential.response.clientDataJSON,
335335+ ),
336336+ ),
337337+ attestationObject: bytesToBase64url(
338338+ new Uint8Array(
339339+ credential.response.attestationObject,
340340+ ),
341341+ ),
296342 }),
297343 });
298344···300346 const body = await res.json().catch(() => ({}));
301347 throw new Error(
302348 body.message ||
303303- `Key registration failed (${res.status})`,
349349+ `Passkey registration failed (${res.status})`,
304350 );
305351 }
306352307307- showMsg("Signing key registered! Reloading…", "success");
353353+ showMsg("Passkey registered! Reloading…", "success");
308354 setTimeout(() => window.location.reload(), 1000);
309355 } catch (err) {
310356 showMsg(err.message || String(err), "error");
311357 btn.textContent =
312312- "{{ if .HasSigningKey }}Update signing key{{ else }}Register signing key{{ end }}";
358358+ "{{ if .HasSigningKey }}Update passkey{{ else }}Register passkey{{ end }}";
313359 } finally {
314360 btn.disabled = false;
315361 }
316362 });
317363318364 // ---------------------------------------------------------------------------
319319- // Browser Signer — WebSocket + signing loop
365365+ // Browser Signer — WebSocket + passkey signing loop
320366 // ---------------------------------------------------------------------------
321367322368 {{ if .HasSigningKey }}
323369 (function () {
370370+ // The credential ID is needed to build the allowCredentials list
371371+ // so the browser can find the right passkey immediately.
372372+ const credentialIdB64 = {{ .CredentialID | js }};
373373+324374 const dot = document.getElementById("signer-dot");
325375 const statusEl = document.getElementById("signer-status");
326376 const signerMsg = document.getElementById("signer-msg");
···336386 let intentionalDisconnect = false;
337387 let pendingRequestId = null;
338388339339- // Wallet address cached after first eth_requestAccounts call
340340- let walletAddress = null;
341341-342389 // ---------------------------------------------------------------------------
343390 // Notification permission
344391 // ---------------------------------------------------------------------------
345392346393 function requestNotificationPermission() {
347347- if ("Notification" in window && Notification.permission === "default") {
394394+ if (
395395+ "Notification" in window &&
396396+ Notification.permission === "default"
397397+ ) {
348398 Notification.requestPermission();
349399 }
350400 }
351401352402 function showNotification(title, body) {
353353- if ("Notification" in window && Notification.permission === "granted") {
403403+ if (
404404+ "Notification" in window &&
405405+ Notification.permission === "granted"
406406+ ) {
354407 try {
355408 const n = new Notification(title, {
356409 body: body,
···363416 n.close();
364417 };
365418 } catch (e) {
366366- // Notifications not supported in this context
419419+ // Notifications not supported in this context.
367420 }
368421 }
369422 }
···381434 function setState(state, detail) {
382435 dot.style.background = STATE_COLORS[state] || "#ef4444";
383436 const labels = {
384384- connected: "Connected — listening for signing requests",
437437+ connected:
438438+ "Connected — listening for signing requests",
385439 connecting: "Connecting…",
386440 disconnected: "Disconnected",
387441 };
388388- statusEl.textContent = detail || labels[state] || state;
442442+ statusEl.textContent =
443443+ detail || labels[state] || state;
389444390445 if (state === "connected") {
391446 btnConnect.style.display = "none";
···412467 function showPending(ops) {
413468 if (ops && ops.length > 0) {
414469 pendingOpsEl.textContent = ops
415415- .map((op) => op.type + " " + op.collection + "/" + op.rkey)
470470+ .map(
471471+ (op) =>
472472+ op.type +
473473+ " " +
474474+ op.collection +
475475+ (op.rkey ? "/" + op.rkey : ""),
476476+ )
416477 .join(", ");
417478 } else {
418479 pendingOpsEl.textContent = "(details unavailable)";
···425486 }
426487427488 // ---------------------------------------------------------------------------
428428- // Wallet access
429429- // ---------------------------------------------------------------------------
430430-431431- async function getWalletAddress() {
432432- if (walletAddress) return walletAddress;
433433- if (!window.ethereum) {
434434- throw new Error(
435435- "No Ethereum wallet detected. Please install MetaMask, Rabby, or another EIP-1193 wallet.",
436436- );
437437- }
438438- const accounts = await window.ethereum.request({
439439- method: "eth_requestAccounts",
440440- });
441441- if (!accounts || accounts.length === 0) {
442442- throw new Error("No accounts returned from wallet.");
443443- }
444444- walletAddress = accounts[0];
445445- return walletAddress;
446446- }
447447-448448- // ---------------------------------------------------------------------------
449489 // WebSocket connection
450490 // ---------------------------------------------------------------------------
451491452492 function connect() {
453453- if (ws && (ws.readyState === WebSocket.OPEN || ws.readyState === WebSocket.CONNECTING)) {
493493+ if (
494494+ ws &&
495495+ (ws.readyState === WebSocket.OPEN ||
496496+ ws.readyState === WebSocket.CONNECTING)
497497+ ) {
454498 return;
455499 }
456500···459503 setState("connecting");
460504 hideSignerMsg();
461505462462- const wsScheme = location.protocol === "https:" ? "wss" : "ws";
463463- const url = wsScheme + "://" + location.host + "/account/signer";
506506+ const wsScheme =
507507+ location.protocol === "https:" ? "wss" : "ws";
508508+ const url =
509509+ wsScheme + "://" + location.host + "/account/signer";
464510465511 try {
466512 ws = new WebSocket(url);
467513 } catch (err) {
468468- console.error("[vow/signer] WebSocket constructor error", err);
514514+ console.error(
515515+ "[vow/signer] WebSocket constructor error",
516516+ err,
517517+ );
469518 scheduleReconnect();
470519 return;
471520 }
···483532484533 ws.addEventListener("close", (event) => {
485534 console.warn(
486486- "[vow/signer] closed: code=" + event.code +
487487- " reason=" + event.reason +
488488- " clean=" + event.wasClean,
535535+ "[vow/signer] closed: code=" +
536536+ event.code +
537537+ " reason=" +
538538+ event.reason +
539539+ " clean=" +
540540+ event.wasClean,
489541 );
490542 ws = null;
491543 hidePending();
···494546 if (intentionalDisconnect) {
495547 setState("disconnected");
496548 } else {
497497- setState("disconnected", "Disconnected — reconnecting…");
549549+ setState(
550550+ "disconnected",
551551+ "Disconnected — reconnecting…",
552552+ );
498553 scheduleReconnect();
499554 }
500555 });
···518573 function scheduleReconnect() {
519574 clearReconnectTimer();
520575 const delay = reconnectDelay;
521521- reconnectDelay = Math.min(reconnectDelay * 2, MAX_DELAY);
522522- console.log("[vow/signer] reconnecting in " + delay + "ms");
576576+ reconnectDelay = Math.min(
577577+ reconnectDelay * 2,
578578+ MAX_DELAY,
579579+ );
580580+ console.log(
581581+ "[vow/signer] reconnecting in " + delay + "ms",
582582+ );
523583 reconnectTimer = setTimeout(connect, delay);
524584 }
525585···534594 if (ws && ws.readyState === WebSocket.OPEN) {
535595 ws.send(data);
536596 } else {
537537- console.error("[vow/signer] cannot send — WebSocket not open");
597597+ console.error(
598598+ "[vow/signer] cannot send — WebSocket not open",
599599+ );
538600 }
539601 }
540602···553615554616 if (msg.type === "sign_request") {
555617 handleSignRequest(msg);
556556- } else if (msg.type === "pay_request") {
557557- handlePayRequest(msg);
558618 } else {
559559- console.log("[vow/signer] unknown message type", msg.type);
619619+ console.log(
620620+ "[vow/signer] unknown message type",
621621+ msg.type,
622622+ );
560623 }
561624 }
562625563626 // ---------------------------------------------------------------------------
564564- // sign_request — EIP-191 personal_sign
627627+ // sign_request — WebAuthn passkey assertion
565628 // ---------------------------------------------------------------------------
566629567630 async function handleSignRequest(msg) {
568631 const { requestId, payload, ops, expiresAt } = msg;
569632570633 if (!requestId || !payload) {
571571- console.warn("[vow/signer] malformed sign_request", msg);
634634+ console.warn(
635635+ "[vow/signer] malformed sign_request",
636636+ msg,
637637+ );
572638 return;
573639 }
574640575641 if (expiresAt && new Date(expiresAt) <= new Date()) {
576576- console.warn("[vow/signer] sign_request expired", requestId);
642642+ console.warn(
643643+ "[vow/signer] sign_request expired",
644644+ requestId,
645645+ );
577646 wsSend(buildSignReject(requestId));
578647 return;
579648 }
580649581650 if (pendingRequestId) {
582582- console.warn("[vow/signer] already pending — rejecting", requestId);
651651+ console.warn(
652652+ "[vow/signer] already pending — rejecting",
653653+ requestId,
654654+ );
583655 wsSend(buildSignReject(requestId));
584656 return;
585657 }
586658587659 pendingRequestId = requestId;
588660 showPending(ops);
589589- showNotification("Vow — Signing Request", opsToSummary(ops));
661661+ showNotification(
662662+ "Vow — Signing Request",
663663+ opsToSummary(ops),
664664+ );
590665591666 try {
592592- const addr = await getWalletAddress();
593593- const payloadHex = "0x" + base64urlToHex(payload);
594594- const signature = await window.ethereum.request({
595595- method: "personal_sign",
596596- params: [payloadHex, addr],
597597- });
598598- wsSend(buildSignResponse(requestId, signature));
599599- } catch (err) {
600600- console.warn("[vow/signer] signing failed", err);
601601- wsSend(buildSignReject(requestId));
602602- } finally {
603603- pendingRequestId = null;
604604- hidePending();
605605- }
606606- }
667667+ // The payload is base64url-encoded commit CBOR bytes.
668668+ // We use those raw bytes directly as the WebAuthn challenge.
669669+ const challenge = base64urlToBytes(payload);
607670608608- // ---------------------------------------------------------------------------
609609- // pay_request — EIP-712 eth_signTypedData_v4
610610- // ---------------------------------------------------------------------------
671671+ const allowCredentials = credentialIdB64
672672+ ? [
673673+ {
674674+ id: base64urlToBytes(credentialIdB64),
675675+ type: "public-key",
676676+ },
677677+ ]
678678+ : [];
611679612612- async function handlePayRequest(msg) {
613613- const { requestId, walletAddress: payerAddress, typedData, description, expiresAt } = msg;
614614-615615- if (!requestId || !payerAddress || !typedData) {
616616- console.warn("[vow/signer] malformed pay_request", msg);
617617- return;
618618- }
619619-620620- if (expiresAt && new Date(expiresAt) <= new Date()) {
621621- console.warn("[vow/signer] pay_request expired", requestId);
622622- wsSend(buildPayReject(requestId));
623623- return;
624624- }
680680+ const assertion = await navigator.credentials.get({
681681+ publicKey: {
682682+ challenge,
683683+ allowCredentials,
684684+ timeout: 30000,
685685+ userVerification: "preferred",
686686+ },
687687+ });
625688626626- if (pendingRequestId) {
627627- console.warn("[vow/signer] already pending — rejecting", requestId);
628628- wsSend(buildPayReject(requestId));
629629- return;
630630- }
631631-632632- pendingRequestId = requestId;
633633- showPending([{ type: "payment", collection: description || "x402", rkey: "" }]);
634634- showNotification("Vow — Payment Request", description || "x402 payment signing required");
689689+ if (!assertion) {
690690+ throw new Error("Passkey assertion was cancelled.");
691691+ }
635692636636- try {
637637- const addr = await getWalletAddress();
638638- const typedDataStr =
639639- typeof typedData === "string"
640640- ? typedData
641641- : JSON.stringify(typedData);
642642- const signature = await window.ethereum.request({
643643- method: "eth_signTypedData_v4",
644644- params: [payerAddress, typedDataStr],
645645- });
646646- wsSend(buildPayResponse(requestId, signature));
693693+ wsSend(
694694+ buildSignResponse(
695695+ requestId,
696696+ assertion.response,
697697+ ),
698698+ );
647699 } catch (err) {
648648- console.warn("[vow/signer] payment signing failed", err);
649649- wsSend(buildPayReject(requestId));
700700+ console.warn("[vow/signer] signing failed", err);
701701+ wsSend(buildSignReject(requestId));
650702 } finally {
651703 pendingRequestId = null;
652704 hidePending();
···657709 // Protocol message builders
658710 // ---------------------------------------------------------------------------
659711660660- function buildSignResponse(requestId, signatureHex) {
661661- const hex = signatureHex.startsWith("0x")
662662- ? signatureHex.slice(2)
663663- : signatureHex;
712712+ function buildSignResponse(requestId, assertionResponse) {
664713 return JSON.stringify({
665714 type: "sign_response",
666715 requestId: requestId,
667667- signature: hexToBase64url(hex),
716716+ authenticatorData: bytesToBase64url(
717717+ new Uint8Array(
718718+ assertionResponse.authenticatorData,
719719+ ),
720720+ ),
721721+ clientDataJSON: bytesToBase64url(
722722+ new Uint8Array(assertionResponse.clientDataJSON),
723723+ ),
724724+ signature: bytesToBase64url(
725725+ new Uint8Array(assertionResponse.signature),
726726+ ),
668727 });
669728 }
670729671730 function buildSignReject(requestId) {
672731 return JSON.stringify({
673732 type: "sign_reject",
674674- requestId: requestId,
675675- });
676676- }
677677-678678- function buildPayResponse(requestId, signatureHex) {
679679- return JSON.stringify({
680680- type: "pay_response",
681681- requestId: requestId,
682682- signature: signatureHex,
683683- });
684684- }
685685-686686- function buildPayReject(requestId) {
687687- return JSON.stringify({
688688- type: "pay_reject",
689733 requestId: requestId,
690734 });
691735 }
692736693737 // ---------------------------------------------------------------------------
694694- // Encoding helpers
738738+ // Helpers
695739 // ---------------------------------------------------------------------------
696740697697- function base64urlToHex(b64url) {
698698- let b64 = b64url.replace(/-/g, "+").replace(/_/g, "/");
699699- while (b64.length % 4 !== 0) b64 += "=";
700700- const raw = atob(b64);
701701- let hex = "";
702702- for (let i = 0; i < raw.length; i++) {
703703- hex += raw.charCodeAt(i).toString(16).padStart(2, "0");
704704- }
705705- return hex;
706706- }
707707-708708- function hexToBase64url(hex) {
709709- const bytes = new Uint8Array(hex.length / 2);
710710- for (let i = 0; i < bytes.length; i++) {
711711- bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
712712- }
713713- let binary = "";
714714- for (let i = 0; i < bytes.length; i++) {
715715- binary += String.fromCharCode(bytes[i]);
716716- }
717717- return btoa(binary)
718718- .replace(/\+/g, "-")
719719- .replace(/\//g, "_")
720720- .replace(/=+$/, "");
721721- }
722722-723741 function opsToSummary(ops) {
724724- if (!ops || ops.length === 0) return "A signing request needs your approval.";
742742+ if (!ops || ops.length === 0)
743743+ return "A signing request needs your approval.";
725744 return ops
726745 .map((op) => op.type + " " + op.collection)
727746 .join(", ");
···732751 // ---------------------------------------------------------------------------
733752734753 btnConnect.addEventListener("click", () => {
735735- if (!window.ethereum) {
754754+ if (!window.PublicKeyCredential) {
736755 showSignerMsg(
737737- "No Ethereum wallet detected. Please install MetaMask, Rabby, or another EIP-1193 wallet.",
756756+ "Your browser does not support passkeys (WebAuthn). Please use a modern browser.",
738757 "error",
739758 );
740759 return;
···750769 // Auto-connect if previously connected (persisted in sessionStorage)
751770 // ---------------------------------------------------------------------------
752771753753- if (sessionStorage.getItem("vow-signer-active") === "1" && window.ethereum) {
772772+ if (sessionStorage.getItem("vow-signer-active") === "1") {
754773 connect();
755774 }
756775···770789 {{ end }}
771790772791 // ---------------------------------------------------------------------------
773773- // Account deletion — wallet signature confirmation
792792+ // Account deletion — passkey assertion confirmation
774793 // ---------------------------------------------------------------------------
775794776795 (function () {
777777- const step1 = document.getElementById("delete-step-1");
778778- const step2 = document.getElementById("delete-step-2");
779779- const msgEl = document.getElementById("delete-msg");
780780- const btnStart = document.getElementById("btn-delete-start");
781781- const btnConfirm = document.getElementById("btn-delete-confirm");
782782- const btnCancel = document.getElementById("btn-delete-cancel");
796796+ const step1 = document.getElementById("delete-step-1");
797797+ const step2 = document.getElementById("delete-step-2");
798798+ const msgEl = document.getElementById("delete-msg");
799799+ const btnStart = document.getElementById("btn-delete-start");
800800+ const btnConfirm = document.getElementById(
801801+ "btn-delete-confirm",
802802+ );
803803+ const btnCancel = document.getElementById("btn-delete-cancel");
783804784784- if (!btnStart) return; // no signing key registered — nothing to wire up
805805+ if (!btnStart) return; // no passkey registered — nothing to wire up
785806786807 function showDeleteMsg(text, type) {
787808 msgEl.textContent = text;
788809 msgEl.style.display = "block";
789810 msgEl.className =
790790- "alert " + (type === "error" ? "alert-danger" : "alert-success");
811811+ "alert " +
812812+ (type === "error" ? "alert-danger" : "alert-success");
791813 }
792814793815 function hideDeleteMsg() {
···810832 btnConfirm.addEventListener("click", async () => {
811833 hideDeleteMsg();
812834813813- if (!window.ethereum) {
835835+ if (!window.PublicKeyCredential) {
814836 showDeleteMsg(
815815- "No Ethereum wallet detected. Please install MetaMask, Rabby, or another EIP-1193 wallet and reload.",
837837+ "Your browser does not support passkeys (WebAuthn). Please use a modern browser.",
816838 "error",
817839 );
818840 return;
819841 }
820842821843 btnConfirm.disabled = true;
822822- btnCancel.disabled = true;
823823- btnConfirm.textContent = "Connecting to wallet…";
844844+ btnCancel.disabled = true;
845845+ btnConfirm.textContent =
846846+ "Fetching challenge…";
824847825848 try {
826826- const accounts = await window.ethereum.request({
827827- method: "eth_requestAccounts",
828828- });
829829- if (!accounts || accounts.length === 0) {
830830- throw new Error("No accounts returned from wallet.");
849849+ // 1. Get the assertion challenge from the server.
850850+ const challengeResp = await fetch(
851851+ "/account/passkey-assertion-challenge",
852852+ { method: "POST" },
853853+ );
854854+ if (!challengeResp.ok) {
855855+ const body = await challengeResp
856856+ .json()
857857+ .catch(() => ({}));
858858+ throw new Error(
859859+ body.message ||
860860+ "Failed to get challenge (" +
861861+ challengeResp.status +
862862+ ")",
863863+ );
831864 }
832832- const walletAddress = accounts[0];
865865+ const opts = await challengeResp.json();
833866834834- const did = {{ .Did | js }};
835835- const message = "Delete account: " + did;
867867+ // Convert base64url fields for the WebAuthn API.
868868+ const challenge = base64urlToBytes(opts.challenge);
869869+ const allowCredentials = (
870870+ opts.allowCredentials || []
871871+ ).map((c) => ({
872872+ id: base64urlToBytes(c.id),
873873+ type: c.type,
874874+ }));
836875837837- btnConfirm.textContent = "Sign the message in your wallet…";
838838- const signature = await window.ethereum.request({
839839- method: "personal_sign",
840840- params: [message, walletAddress],
876876+ // 2. Prompt the passkey.
877877+ btnConfirm.textContent = "Confirm with your passkey…";
878878+ const assertion = await navigator.credentials.get({
879879+ publicKey: {
880880+ challenge,
881881+ allowCredentials,
882882+ timeout: opts.timeout || 30000,
883883+ userVerification:
884884+ opts.userVerification || "preferred",
885885+ rpId: opts.rpId,
886886+ },
841887 });
842888889889+ if (!assertion) {
890890+ throw new Error("Passkey confirmation cancelled.");
891891+ }
892892+893893+ // 3. Send the assertion to the server.
843894 btnConfirm.textContent = "Deleting…";
844895 const res = await fetch("/account/delete", {
845896 method: "POST",
846897 headers: { "Content-Type": "application/json" },
847847- body: JSON.stringify({ walletAddress, signature }),
898898+ body: JSON.stringify({
899899+ credentialId: bytesToBase64url(
900900+ new Uint8Array(assertion.rawId),
901901+ ),
902902+ clientDataJSON: bytesToBase64url(
903903+ new Uint8Array(
904904+ assertion.response.clientDataJSON,
905905+ ),
906906+ ),
907907+ authenticatorData: bytesToBase64url(
908908+ new Uint8Array(
909909+ assertion.response.authenticatorData,
910910+ ),
911911+ ),
912912+ signature: bytesToBase64url(
913913+ new Uint8Array(
914914+ assertion.response.signature,
915915+ ),
916916+ ),
917917+ }),
848918 });
849919850920 if (!res.ok) {
851921 const body = await res.json().catch(() => ({}));
852852- throw new Error(body.message || body.error || `Deletion failed (${res.status})`);
922922+ throw new Error(
923923+ body.message ||
924924+ body.error ||
925925+ `Deletion failed (${res.status})`,
926926+ );
853927 }
854928855855- showDeleteMsg("Account deleted. Redirecting…", "success");
856856- setTimeout(() => { window.location.href = "/"; }, 1500);
929929+ showDeleteMsg(
930930+ "Account deleted. Redirecting…",
931931+ "success",
932932+ );
933933+ setTimeout(() => {
934934+ window.location.href = "/";
935935+ }, 1500);
857936 } catch (err) {
858937 showDeleteMsg(err.message || String(err), "error");
859859- btnConfirm.textContent = "Yes, sign and delete my account";
938938+ btnConfirm.textContent =
939939+ "Yes, confirm and delete my account";
860940 btnConfirm.disabled = false;
861861- btnCancel.disabled = false;
941941+ btnCancel.disabled = false;
862942 }
863943 });
864944 })();
865865-866866- // ---------------------------------------------------------------------------
867867- // Crypto helpers (used by key registration)
868868- // ---------------------------------------------------------------------------
869869-870870- function stringToHex(str) {
871871- const bytes = new TextEncoder().encode(str);
872872- return (
873873- "0x" +
874874- Array.from(bytes)
875875- .map((b) => b.toString(16).padStart(2, "0"))
876876- .join("")
877877- );
878878- }
879879-880880- function bytesToHex(bytes) {
881881- return Array.from(bytes)
882882- .map((b) => b.toString(16).padStart(2, "0"))
883883- .join("");
884884- }
885885-886886- function hexToBytes(hex) {
887887- hex = hex.replace(/^0x/, "");
888888- const out = new Uint8Array(hex.length / 2);
889889- for (let i = 0; i < out.length; i++) {
890890- out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
891891- }
892892- return out;
893893- }
894894-895895- function ethereumSignedMessageHash(message) {
896896- const msgBytes = new TextEncoder().encode(message);
897897- const prefix = new TextEncoder().encode(
898898- "\x19Ethereum Signed Message:\n" + msgBytes.length,
899899- );
900900- const combined = new Uint8Array(
901901- prefix.length + msgBytes.length,
902902- );
903903- combined.set(prefix);
904904- combined.set(msgBytes, prefix.length);
905905- return keccak256(combined);
906906- }
907907-908908- function parseSignature(hexSig) {
909909- const bytes = hexToBytes(hexSig);
910910- const r = bytes.slice(0, 32);
911911- const s = bytes.slice(32, 64);
912912- let v = bytes[64];
913913- if (v === 0 || v === 1) v += 27;
914914- return { r, s, v };
915915- }
916916-917917- // secp256k1 curve parameters
918918- const P =
919919- 0xfffffffffffffffffffffffffffffffffffffffffffffffffffffffefffffc2fn;
920920- const N =
921921- 0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141n;
922922- const B = 7n;
923923- const GX =
924924- 0x79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798n;
925925- const GY =
926926- 0x483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8n;
927927-928928- function modp(n) {
929929- return ((n % P) + P) % P;
930930- }
931931- function modn(n) {
932932- return ((n % N) + N) % N;
933933- }
934934-935935- function modpow(base, exp, mod) {
936936- let result = 1n;
937937- base = ((base % mod) + mod) % mod;
938938- while (exp > 0n) {
939939- if (exp & 1n) result = (result * base) % mod;
940940- exp >>= 1n;
941941- base = (base * base) % mod;
942942- }
943943- return result;
944944- }
945945-946946- function pointAdd(P1, P2) {
947947- if (P1 === null) return P2;
948948- if (P2 === null) return P1;
949949- const [x1, y1] = P1;
950950- const [x2, y2] = P2;
951951- if (x1 === x2) {
952952- if (y1 !== y2) return null;
953953- const lam = modp(3n * x1 * x1 * modpow(2n * y1, P - 2n, P));
954954- const x3 = modp(lam * lam - 2n * x1);
955955- return [x3, modp(lam * (x1 - x3) - y1)];
956956- }
957957- const lam = modp((y2 - y1) * modpow(x2 - x1, P - 2n, P));
958958- const x3 = modp(lam * lam - x1 - x2);
959959- return [x3, modp(lam * (x1 - x3) - y1)];
960960- }
961961-962962- function pointMul(k, point) {
963963- let R = null;
964964- let Q = point;
965965- k = modn(k);
966966- while (k > 0n) {
967967- if (k & 1n) R = pointAdd(R, Q);
968968- Q = pointAdd(Q, Q);
969969- k >>= 1n;
970970- }
971971- return R;
972972- }
973973-974974- function recoverPublicKeyWithId(msgHash, sig, recId) {
975975- const { r, s } = sig;
976976- const rBig = BigInt("0x" + bytesToHex(r));
977977- const sBig = BigInt("0x" + bytesToHex(s));
978978- const hashBig = BigInt("0x" + bytesToHex(msgHash));
979979-980980- const x = rBig;
981981- const y2 = modp(modpow(x, 3n, P) + B);
982982- let y = modpow(y2, (P + 1n) / 4n, P);
983983- if ((y & 1n) !== BigInt(recId & 1)) y = P - y;
984984- const R = [x, y];
985985-986986- const rInv = modpow(rBig, N - 2n, N);
987987- const G = [GX, GY];
988988- const u1 = modn((N - hashBig) * rInv);
989989- const u2 = modn(sBig * rInv);
990990- const Q = pointAdd(pointMul(u1, G), pointMul(u2, R));
991991-992992- const result = new Uint8Array(65);
993993- result[0] = 0x04;
994994- result.set(bigintToBytes32(Q[0]), 1);
995995- result.set(bigintToBytes32(Q[1]), 33);
996996- return result;
997997- }
998998-999999- function compressPublicKey(uncompressed) {
10001000- const x = uncompressed.slice(1, 33);
10011001- const prefix = (uncompressed[64] & 1) === 0 ? 0x02 : 0x03;
10021002- const out = new Uint8Array(33);
10031003- out[0] = prefix;
10041004- out.set(x, 1);
10051005- return out;
10061006- }
10071007-10081008- function bigintToBytes32(n) {
10091009- return hexToBytes(n.toString(16).padStart(64, "0"));
10101010- }
10111011-10121012- // Derive the Ethereum address from an uncompressed public key
10131013- // (65 bytes, 0x04 prefix). Mirrors what go-ethereum does:
10141014- // keccak256(pubkey[1:]) -> last 20 bytes -> EIP-55 checksum.
10151015- function deriveEthAddress(uncompressed) {
10161016- // Hash the 64 uncompressed coordinate bytes (strip 0x04 prefix)
10171017- const hash = keccak256(uncompressed.slice(1));
10181018- // Take the last 20 bytes
10191019- const addrBytes = hash.slice(12);
10201020- const hex = bytesToHex(addrBytes);
10211021- return eip55Checksum(hex);
10221022- }
10231023-10241024- // EIP-55 mixed-case checksum encoding
10251025- function eip55Checksum(hex) {
10261026- const lower = hex.toLowerCase();
10271027- const hash = keccak256(new TextEncoder().encode(lower));
10281028- const hashHex = bytesToHex(hash);
10291029- let result = "0x";
10301030- for (let i = 0; i < 40; i++) {
10311031- result +=
10321032- parseInt(hashHex[i], 16) >= 8
10331033- ? lower[i].toUpperCase()
10341034- : lower[i];
10351035- }
10361036- return result;
10371037- }
10381038-10391039- // ---------------------------------------------------------------------------
10401040- // keccak256 (minimal, self-contained)
10411041- // ---------------------------------------------------------------------------
10421042-10431043- const KECCAK_ROUNDS = 24;
10441044- const KECCAK_RC = [
10451045- [0x00000001, 0x00000000],
10461046- [0x00008082, 0x00000000],
10471047- [0x0000808a, 0x80000000],
10481048- [0x80008000, 0x80000000],
10491049- [0x0000808b, 0x00000000],
10501050- [0x80000001, 0x00000000],
10511051- [0x80008081, 0x80000000],
10521052- [0x00008009, 0x80000000],
10531053- [0x0000008a, 0x00000000],
10541054- [0x00000088, 0x00000000],
10551055- [0x80008009, 0x00000000],
10561056- [0x8000000a, 0x00000000],
10571057- [0x8000808b, 0x00000000],
10581058- [0x0000008b, 0x80000000],
10591059- [0x00008089, 0x80000000],
10601060- [0x00008003, 0x80000000],
10611061- [0x00008002, 0x80000000],
10621062- [0x00000080, 0x80000000],
10631063- [0x0000800a, 0x00000000],
10641064- [0x8000000a, 0x80000000],
10651065- [0x80008081, 0x80000000],
10661066- [0x00008080, 0x80000000],
10671067- [0x80000001, 0x00000000],
10681068- [0x80008008, 0x80000000],
10691069- ];
10701070- const KECCAK_ROTC = [
10711071- 1, 3, 6, 10, 15, 21, 28, 36, 45, 55, 2, 14, 27, 41, 56, 8, 25,
10721072- 43, 62, 18, 39, 61, 20, 44,
10731073- ];
10741074- const KECCAK_PILN = [
10751075- 10, 7, 11, 17, 18, 3, 5, 16, 8, 21, 24, 4, 15, 23, 19, 13, 12,
10761076- 2, 20, 14, 22, 9, 6, 1,
10771077- ];
10781078-10791079- function keccak256(input) {
10801080- const rate = 136;
10811081- const padLen = rate - (input.length % rate);
10821082- const padded = new Uint8Array(input.length + padLen);
10831083- padded.set(input);
10841084- padded[input.length] = 0x01;
10851085- padded[padded.length - 1] |= 0x80;
10861086-10871087- const state = new Uint32Array(50);
10881088-10891089- for (let block = 0; block < padded.length; block += rate) {
10901090- for (let i = 0; i < rate / 4; i++) {
10911091- state[i] ^=
10921092- padded[block + i * 4] |
10931093- (padded[block + i * 4 + 1] << 8) |
10941094- (padded[block + i * 4 + 2] << 16) |
10951095- (padded[block + i * 4 + 3] << 24);
10961096- }
10971097- keccakF1600(state);
10981098- }
10991099-11001100- const output = new Uint8Array(32);
11011101- for (let i = 0; i < 8; i++) {
11021102- const lo = state[i * 2];
11031103- output[i * 4] = lo & 0xff;
11041104- output[i * 4 + 1] = (lo >> 8) & 0xff;
11051105- output[i * 4 + 2] = (lo >> 16) & 0xff;
11061106- output[i * 4 + 3] = (lo >> 24) & 0xff;
11071107- }
11081108- return output;
11091109- }
11101110-11111111- function keccakF1600(state) {
11121112- const bc = new Uint32Array(10);
11131113- for (let round = 0; round < KECCAK_ROUNDS; round++) {
11141114- // Theta
11151115- for (let x = 0; x < 5; x++) {
11161116- bc[x * 2] =
11171117- state[x * 2] ^
11181118- state[x * 2 + 10] ^
11191119- state[x * 2 + 20] ^
11201120- state[x * 2 + 30] ^
11211121- state[x * 2 + 40];
11221122- bc[x * 2 + 1] =
11231123- state[x * 2 + 1] ^
11241124- state[x * 2 + 11] ^
11251125- state[x * 2 + 21] ^
11261126- state[x * 2 + 31] ^
11271127- state[x * 2 + 41];
11281128- }
11291129- for (let x = 0; x < 5; x++) {
11301130- const t0 = bc[((x + 4) % 5) * 2];
11311131- const t1 = bc[((x + 4) % 5) * 2 + 1];
11321132- const u0 = bc[((x + 1) % 5) * 2];
11331133- const u1 = bc[((x + 1) % 5) * 2 + 1];
11341134- const r0 = (u0 << 1) | (u1 >>> 31);
11351135- const r1 = (u1 << 1) | (u0 >>> 31);
11361136- for (let y = 0; y < 5; y++) {
11371137- state[(y * 5 + x) * 2] ^= t0 ^ r0;
11381138- state[(y * 5 + x) * 2 + 1] ^= t1 ^ r1;
11391139- }
11401140- }
11411141- // Rho + Pi
11421142- let last = [state[2], state[3]];
11431143- for (let i = 0; i < 24; i++) {
11441144- const j = KECCAK_PILN[i];
11451145- const tmp = [state[j * 2], state[j * 2 + 1]];
11461146- const rot = KECCAK_ROTC[i];
11471147- if (rot < 32) {
11481148- state[j * 2] =
11491149- (last[0] << rot) | (last[1] >>> (32 - rot));
11501150- state[j * 2 + 1] =
11511151- (last[1] << rot) | (last[0] >>> (32 - rot));
11521152- } else {
11531153- state[j * 2] =
11541154- (last[1] << (rot - 32)) |
11551155- (last[0] >>> (64 - rot));
11561156- state[j * 2 + 1] =
11571157- (last[0] << (rot - 32)) |
11581158- (last[1] >>> (64 - rot));
11591159- }
11601160- last = tmp;
11611161- }
11621162- // Chi
11631163- for (let y = 0; y < 5; y++) {
11641164- const t = new Uint32Array(10);
11651165- for (let x = 0; x < 5; x++) {
11661166- t[x * 2] = state[(y * 5 + x) * 2];
11671167- t[x * 2 + 1] = state[(y * 5 + x) * 2 + 1];
11681168- }
11691169- for (let x = 0; x < 5; x++) {
11701170- state[(y * 5 + x) * 2] ^=
11711171- ~t[((x + 1) % 5) * 2] & t[((x + 2) % 5) * 2];
11721172- state[(y * 5 + x) * 2 + 1] ^=
11731173- ~t[((x + 1) % 5) * 2 + 1] &
11741174- t[((x + 2) % 5) * 2 + 1];
11751175- }
11761176- }
11771177- // Iota
11781178- state[0] ^= KECCAK_RC[round][0];
11791179- state[1] ^= KECCAK_RC[round][1];
11801180- }
11811181- }
1182945 </script>
1183946 </body>
1184947</html>
+336
server/webauthn.go
···11+package server
22+33+import (
44+ "crypto/ecdsa"
55+ "crypto/elliptic"
66+ "crypto/sha256"
77+ "encoding/asn1"
88+ "encoding/base64"
99+ "encoding/json"
1010+ "fmt"
1111+ "math/big"
1212+1313+ "github.com/fxamacker/cbor/v2"
1414+)
1515+1616+// ──────────────────────────────────────────────────────────────────────────────
1717+// Attestation parsing (key registration)
1818+// ──────────────────────────────────────────────────────────────────────────────
1919+2020+// attestationObject is the top-level CBOR structure returned by
2121+// navigator.credentials.create().
2222+type attestationObject struct {
2323+ Fmt string `cbor:"fmt"`
2424+ AttStmt map[string]any `cbor:"attStmt"`
2525+ AuthData []byte `cbor:"authData"`
2626+}
2727+2828+// parseAttestationObject extracts the compressed P-256 public key and the
2929+// credential ID from a WebAuthn attestation object (base64url-encoded CBOR).
3030+//
3131+// Only "none" and "packed" attestation formats are handled; for packed we
3232+// accept self-attestation without verifying the attStmt certificate chain
3333+// (sufficient for a PDS that trusts its own users).
3434+func parseAttestationObject(attestationObjectB64 string) (pubKeyBytes []byte, credentialID []byte, err error) {
3535+ raw, err := base64.RawURLEncoding.DecodeString(attestationObjectB64)
3636+ if err != nil {
3737+ return nil, nil, fmt.Errorf("decode attestationObject: %w", err)
3838+ }
3939+4040+ var ao attestationObject
4141+ if err := cbor.Unmarshal(raw, &ao); err != nil {
4242+ return nil, nil, fmt.Errorf("unmarshal attestationObject CBOR: %w", err)
4343+ }
4444+4545+ pub, cid, err := parseAuthData(ao.AuthData)
4646+ if err != nil {
4747+ return nil, nil, fmt.Errorf("parse authData: %w", err)
4848+ }
4949+5050+ return pub, cid, nil
5151+}
5252+5353+// parseAuthData extracts the credential ID and the compressed P-256 public key
5454+// from a WebAuthn authenticatorData byte string.
5555+//
5656+// The authenticatorData layout is defined in the WebAuthn spec §6.1:
5757+//
5858+// rpIdHash [32]byte
5959+// flags [1]byte
6060+// signCount [4]byte (big-endian uint32)
6161+// attestedCredentialData (variable, present when AT flag is set)
6262+// aaguid [16]byte
6363+// credIdLen [2]byte (big-endian uint16)
6464+// credId [credIdLen]byte
6565+// credPubKey CBOR map (COSE_Key)
6666+func parseAuthData(authData []byte) (pubKeyBytes []byte, credentialID []byte, err error) {
6767+ // Minimum length: 32 (rpIdHash) + 1 (flags) + 4 (signCount) = 37 bytes.
6868+ if len(authData) < 37 {
6969+ return nil, nil, fmt.Errorf("authData too short (%d bytes)", len(authData))
7070+ }
7171+7272+ flags := authData[32]
7373+ // Bit 6 (AT) must be set for attested credential data to be present.
7474+ const flagAT = 0x40
7575+ if flags&flagAT == 0 {
7676+ return nil, nil, fmt.Errorf("authData AT flag not set — no attested credential data")
7777+ }
7878+7979+ if len(authData) < 55 {
8080+ return nil, nil, fmt.Errorf("authData too short for attested credential data (%d bytes)", len(authData))
8181+ }
8282+8383+ // Skip: rpIdHash (32) + flags (1) + signCount (4) + aaguid (16) = 53 bytes.
8484+ credIDLen := int(authData[53])<<8 | int(authData[54])
8585+ offset := 55
8686+ if len(authData) < offset+credIDLen {
8787+ return nil, nil, fmt.Errorf("authData too short for credId (need %d, have %d)", offset+credIDLen, len(authData))
8888+ }
8989+9090+ credentialID = authData[offset : offset+credIDLen]
9191+ offset += credIDLen
9292+9393+ // The remaining bytes are a CBOR-encoded COSE_Key map.
9494+ coseKey := authData[offset:]
9595+9696+ pub, err := parseCOSEKey(coseKey)
9797+ if err != nil {
9898+ return nil, nil, fmt.Errorf("parse COSE key: %w", err)
9999+ }
100100+101101+ return pub, credentialID, nil
102102+}
103103+104104+// parseCOSEKey decodes a CBOR COSE_Key map and returns the compressed P-256
105105+// public key (33 bytes).
106106+//
107107+// Relevant COSE key parameters for EC2 keys (kty=2):
108108+//
109109+// 1 kty = 2 (EC2)
110110+// 3 alg = -7 (ES256)
111111+// -1 crv = 1 (P-256)
112112+// -2 x (32 bytes)
113113+// -3 y (32 bytes)
114114+func parseCOSEKey(coseKey []byte) ([]byte, error) {
115115+ // Use integer keys because CBOR maps in COSE use small ints.
116116+ var m map[int]cbor.RawMessage
117117+ if err := cbor.Unmarshal(coseKey, &m); err != nil {
118118+ return nil, fmt.Errorf("unmarshal COSE_Key: %w", err)
119119+ }
120120+121121+ // Check kty == 2 (EC2).
122122+ var kty int
123123+ if raw, ok := m[1]; ok {
124124+ if err := cbor.Unmarshal(raw, &kty); err != nil {
125125+ return nil, fmt.Errorf("decode kty: %w", err)
126126+ }
127127+ }
128128+ if kty != 2 {
129129+ return nil, fmt.Errorf("unsupported COSE key type %d (expected 2 for EC2)", kty)
130130+ }
131131+132132+ // Check crv == 1 (P-256).
133133+ var crv int
134134+ if raw, ok := m[-1]; ok {
135135+ if err := cbor.Unmarshal(raw, &crv); err != nil {
136136+ return nil, fmt.Errorf("decode crv: %w", err)
137137+ }
138138+ }
139139+ if crv != 1 {
140140+ return nil, fmt.Errorf("unsupported COSE curve %d (expected 1 for P-256)", crv)
141141+ }
142142+143143+ var xBytes, yBytes []byte
144144+ if raw, ok := m[-2]; ok {
145145+ if err := cbor.Unmarshal(raw, &xBytes); err != nil {
146146+ return nil, fmt.Errorf("decode x: %w", err)
147147+ }
148148+ }
149149+ if raw, ok := m[-3]; ok {
150150+ if err := cbor.Unmarshal(raw, &yBytes); err != nil {
151151+ return nil, fmt.Errorf("decode y: %w", err)
152152+ }
153153+ }
154154+155155+ if len(xBytes) != 32 || len(yBytes) != 32 {
156156+ return nil, fmt.Errorf("unexpected key coordinate lengths (x=%d, y=%d)", len(xBytes), len(yBytes))
157157+ }
158158+159159+ // Compress the public key: prefix 0x02 if y is even, 0x03 if y is odd.
160160+ prefix := byte(0x02)
161161+ if yBytes[31]&1 == 1 {
162162+ prefix = 0x03
163163+ }
164164+165165+ compressed := make([]byte, 33)
166166+ compressed[0] = prefix
167167+ copy(compressed[1:], xBytes)
168168+169169+ return compressed, nil
170170+}
171171+172172+// ──────────────────────────────────────────────────────────────────────────────
173173+// Assertion verification (signing operations & account deletion)
174174+// ──────────────────────────────────────────────────────────────────────────────
175175+176176+// clientDataJSON is the parsed form of the clientDataJSON field returned by
177177+// navigator.credentials.get().
178178+type clientDataJSON struct {
179179+ Type string `json:"type"`
180180+ Challenge string `json:"challenge"` // base64url
181181+ Origin string `json:"origin"`
182182+}
183183+184184+// verifyAssertion verifies a WebAuthn assertion and returns the raw 64-byte
185185+// (r‖s) ECDSA signature suitable for use in ATProto commits and JWTs.
186186+//
187187+// Parameters:
188188+// - pubKeyCompressed: 33-byte compressed P-256 public key stored in the DB.
189189+// - expectedChallenge: the raw challenge bytes the server originally sent.
190190+// - clientDataJSONBytes: the clientDataJSON bytes from the assertion response.
191191+// - authenticatorDataBytes: the authenticatorData bytes from the assertion response.
192192+// - signatureDER: the DER-encoded ECDSA signature from the assertion response.
193193+// - rpID: the relying party ID (hostname), e.g. "example.com".
194194+func verifyAssertion(
195195+ pubKeyCompressed []byte,
196196+ expectedChallenge []byte,
197197+ clientDataJSONBytes []byte,
198198+ authenticatorDataBytes []byte,
199199+ signatureDER []byte,
200200+ rpID string,
201201+) (rawSig []byte, err error) {
202202+ // ── 1. Parse and validate clientDataJSON ─────────────────────────────
203203+ var cd clientDataJSON
204204+ if err := json.Unmarshal(clientDataJSONBytes, &cd); err != nil {
205205+ return nil, fmt.Errorf("unmarshal clientDataJSON: %w", err)
206206+ }
207207+208208+ if cd.Type != "webauthn.get" {
209209+ return nil, fmt.Errorf("unexpected clientData type %q (want webauthn.get)", cd.Type)
210210+ }
211211+212212+ // The challenge in clientDataJSON is base64url-encoded (the browser
213213+ // re-encodes the ArrayBuffer it was given).
214214+ gotChallenge, err := base64.RawURLEncoding.DecodeString(cd.Challenge)
215215+ if err != nil {
216216+ // Some browsers include padding — try with std encoding as fallback.
217217+ gotChallenge, err = base64.URLEncoding.DecodeString(cd.Challenge)
218218+ if err != nil {
219219+ return nil, fmt.Errorf("decode challenge from clientDataJSON: %w", err)
220220+ }
221221+ }
222222+223223+ if len(gotChallenge) != len(expectedChallenge) {
224224+ return nil, fmt.Errorf("challenge length mismatch (got %d, want %d)", len(gotChallenge), len(expectedChallenge))
225225+ }
226226+ for i := range expectedChallenge {
227227+ if gotChallenge[i] != expectedChallenge[i] {
228228+ return nil, fmt.Errorf("challenge mismatch")
229229+ }
230230+ }
231231+232232+ // ── 2. Validate authenticatorData ────────────────────────────────────
233233+ if len(authenticatorDataBytes) < 37 {
234234+ return nil, fmt.Errorf("authenticatorData too short (%d bytes)", len(authenticatorDataBytes))
235235+ }
236236+237237+ // Verify rpIdHash matches SHA-256(rpID).
238238+ rpIDHash := sha256.Sum256([]byte(rpID))
239239+ for i := range 32 {
240240+ if authenticatorDataBytes[i] != rpIDHash[i] {
241241+ return nil, fmt.Errorf("rpIdHash mismatch")
242242+ }
243243+ }
244244+245245+ // Check UP (user presence) flag — bit 0 must be set.
246246+ flags := authenticatorDataBytes[32]
247247+ const flagUP = 0x01
248248+ if flags&flagUP == 0 {
249249+ return nil, fmt.Errorf("user presence flag not set")
250250+ }
251251+252252+ // ── 3. Reconstruct and verify the signed message ──────────────────────
253253+ // WebAuthn signed data = authenticatorData ‖ SHA-256(clientDataJSON).
254254+ cdHash := sha256.Sum256(clientDataJSONBytes)
255255+ signedData := make([]byte, len(authenticatorDataBytes)+32)
256256+ copy(signedData, authenticatorDataBytes)
257257+ copy(signedData[len(authenticatorDataBytes):], cdHash[:])
258258+259259+ // ── 4. Parse the DER signature ────────────────────────────────────────
260260+ rawSig64, err := derToRawECDSA(signatureDER)
261261+ if err != nil {
262262+ return nil, fmt.Errorf("parse DER signature: %w", err)
263263+ }
264264+265265+ // ── 5. Verify the P-256 signature ─────────────────────────────────────
266266+ pub, err := decompressP256(pubKeyCompressed)
267267+ if err != nil {
268268+ return nil, fmt.Errorf("decompress public key: %w", err)
269269+ }
270270+271271+ digest := sha256.Sum256(signedData)
272272+ r := new(big.Int).SetBytes(rawSig64[:32])
273273+ s := new(big.Int).SetBytes(rawSig64[32:])
274274+275275+ if !ecdsa.Verify(pub, digest[:], r, s) {
276276+ return nil, fmt.Errorf("signature verification failed")
277277+ }
278278+279279+ return rawSig64, nil
280280+}
281281+282282+// ──────────────────────────────────────────────────────────────────────────────
283283+// DER → raw (r‖s) conversion
284284+// ──────────────────────────────────────────────────────────────────────────────
285285+286286+// derToRawECDSA parses a DER-encoded ECDSA signature (as produced by a WebAuthn
287287+// authenticator) and returns the 64-byte (r‖s) concatenation with each
288288+// component zero-padded to 32 bytes.
289289+func derToRawECDSA(der []byte) ([]byte, error) {
290290+ var sig struct {
291291+ R, S *big.Int
292292+ }
293293+ rest, err := asn1.Unmarshal(der, &sig)
294294+ if err != nil {
295295+ return nil, fmt.Errorf("asn1 unmarshal: %w", err)
296296+ }
297297+ if len(rest) != 0 {
298298+ return nil, fmt.Errorf("trailing bytes after DER signature (%d bytes)", len(rest))
299299+ }
300300+ if sig.R == nil || sig.S == nil {
301301+ return nil, fmt.Errorf("nil r or s in DER signature")
302302+ }
303303+304304+ out := make([]byte, 64)
305305+ rBytes := sig.R.Bytes()
306306+ sBytes := sig.S.Bytes()
307307+308308+ if len(rBytes) > 32 || len(sBytes) > 32 {
309309+ return nil, fmt.Errorf("r or s component exceeds 32 bytes (r=%d, s=%d)", len(rBytes), len(sBytes))
310310+ }
311311+312312+ copy(out[32-len(rBytes):32], rBytes)
313313+ copy(out[64-len(sBytes):64], sBytes)
314314+315315+ return out, nil
316316+}
317317+318318+// ──────────────────────────────────────────────────────────────────────────────
319319+// Key helpers
320320+// ──────────────────────────────────────────────────────────────────────────────
321321+322322+// decompressP256 decompresses a 33-byte compressed P-256 public key into an
323323+// *ecdsa.PublicKey.
324324+func decompressP256(compressed []byte) (*ecdsa.PublicKey, error) {
325325+ if len(compressed) != 33 {
326326+ return nil, fmt.Errorf("expected 33-byte compressed key, got %d bytes", len(compressed))
327327+ }
328328+329329+ curve := elliptic.P256()
330330+ x, y := elliptic.UnmarshalCompressed(curve, compressed)
331331+ if x == nil {
332332+ return nil, fmt.Errorf("failed to unmarshal compressed P-256 key")
333333+ }
334334+335335+ return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil
336336+}