···13)
1415var (
16- // ErrSignerNotConnected is the sentinel returned when no wallet is connected.
17 ErrSignerNotConnected = errors.New("signer not connected")
1819- // ErrSignerRejected is the sentinel returned when the wallet rejected the
20- // signing request.
21 ErrSignerRejected = errors.New("signer rejected")
2223- // ErrSignerTimeout is the sentinel returned when the wallet did not respond
24- // within the deadline.
25 ErrSignerTimeout = errors.New("signer timeout")
26)
27···48// guard).
49//
50// The error codes follow the ATProto convention used by the official PDS:
51-// - "AccountNotFound" — no wallet tab is open / connected.
52-// - "UserTookDownRepo" — the user explicitly rejected the signing prompt.
53// - "RepoDeactivated" — the signing deadline elapsed with no response.
54//
55// These are the closest standard codes to what happened; they tell AppViews
···63 case errors.Is(err, ErrSignerNotConnected):
64 writeJSON(w, http.StatusBadRequest, map[string]string{
65 "error": "AccountNotFound",
66- "message": "No wallet signer is connected for this account. Open the account page and keep it in a browser tab.",
67 })
68 case errors.Is(err, ErrSignerRejected):
69 writeJSON(w, http.StatusBadRequest, map[string]string{
70 "error": "UserTookDownRepo",
71- "message": "The wallet rejected the signing request.",
72 })
73 case errors.Is(err, ErrSignerTimeout):
74 writeJSON(w, http.StatusBadRequest, map[string]string{
75 "error": "RepoDeactivated",
76- "message": "The wallet did not respond within the signing deadline.",
77 })
78 default:
79 ServerError(w, nil)
···13)
1415var (
16+ // ErrSignerNotConnected is the sentinel returned when no signer tab is open.
17 ErrSignerNotConnected = errors.New("signer not connected")
1819+ // ErrSignerRejected is the sentinel returned when the passkey prompt was
20+ // dismissed or the signing request was explicitly rejected.
21 ErrSignerRejected = errors.New("signer rejected")
2223+ // ErrSignerTimeout is the sentinel returned when the passkey did not
24+ // respond within the deadline.
25 ErrSignerTimeout = errors.New("signer timeout")
26)
27···48// guard).
49//
50// The error codes follow the ATProto convention used by the official PDS:
51+// - "AccountNotFound" — no signer tab is open / connected.
52+// - "UserTookDownRepo" — the user dismissed the passkey prompt or rejected it.
53// - "RepoDeactivated" — the signing deadline elapsed with no response.
54//
55// These are the closest standard codes to what happened; they tell AppViews
···63 case errors.Is(err, ErrSignerNotConnected):
64 writeJSON(w, http.StatusBadRequest, map[string]string{
65 "error": "AccountNotFound",
66+ "message": "No signer is connected for this account. Open the account page and keep it in a browser tab.",
67 })
68 case errors.Is(err, ErrSignerRejected):
69 writeJSON(w, http.StatusBadRequest, map[string]string{
70 "error": "UserTookDownRepo",
71+ "message": "The passkey prompt was dismissed or the signing request was rejected.",
72 })
73 case errors.Is(err, ErrSignerTimeout):
74 writeJSON(w, http.StatusBadRequest, map[string]string{
75 "error": "RepoDeactivated",
76+ "message": "The passkey did not respond within the signing deadline.",
77 })
78 default:
79 ServerError(w, nil)
+11-21
models/models.go
···23import (
4 "time"
5-6- gethcrypto "github.com/ethereum/go-ethereum/crypto"
7)
89type Repo struct {
···22 AccountDeleteCode *string
23 AccountDeleteCodeExpiresAt *time.Time
24 Password string
25- // PublicKey holds the compressed secp256k1 public key bytes for the
26- // account. This is the only key material the PDS retains.
27- PublicKey []byte
28- Rev string
29- Root []byte
30- Preferences []byte
31- Deactivated bool
32-}
33-34-// EthereumAddress returns the Ethereum address for PublicKey.
35-func (r *Repo) EthereumAddress() string {
36- if len(r.PublicKey) == 0 {
37- return ""
38- }
39- ecPub, err := gethcrypto.DecompressPubkey(r.PublicKey)
40- if err != nil {
41- return ""
42- }
43- return gethcrypto.PubkeyToAddress(*ecPub).Hex()
44}
4546func (r *Repo) Status() *string {
···23import (
4 "time"
005)
67type Repo struct {
···20 AccountDeleteCode *string
21 AccountDeleteCodeExpiresAt *time.Time
22 Password string
23+ // PublicKey holds the compressed P-256 (secp256r1) public key bytes for
24+ // the account. This is the only key material the PDS retains.
25+ PublicKey []byte
26+ // CredentialID is the WebAuthn credential ID returned by the authenticator
27+ // during registration. It is stored so the server can build the
28+ // allowCredentials list when requesting an assertion from the passkey.
29+ CredentialID []byte
30+ Rev string
31+ Root []byte
32+ Preferences []byte
33+ Deactivated bool
0000000034}
3536func (r *Repo) Status() *string {
···222{
223 "type": "sign_response",
224 "requestId": "uuid",
225+ "authenticatorData": "<base64url authenticatorData bytes>",
226+ "clientDataJSON": "<base64url clientDataJSON bytes>",
227+ "signature": "<base64url DER-encoded ECDSA signature>"
228}
229```
230+231+The server decodes all three fields, reconstructs the signed message as `authenticatorData ‖ SHA-256(clientDataJSON)`, verifies the P-256 signature, and converts the DER-encoded signature to the raw 64-byte (r‖s) format expected by ATProto before delivering it to the waiting write handler.
232233**Rejection message (browser → PDS):**
234
···21 return
22 }
2324- pubKey, err := atcrypto.ParsePublicBytesK256(repo.PublicKey)
25 if err != nil {
26 logger.Error("error parsing stored public key", "error", err)
27 helpers.ServerError(w, nil)
···3940 // If this is a did:plc identity, fetch the actual rotation keys from the
41 // current PLC document. After supplySigningKey transfers the rotation key
42- // to the user's wallet, the PDS key is no longer authoritative and we
43 // must reflect the real state.
44 if strings.HasPrefix(repo.Repo.Did, "did:plc:") {
45 ctx := context.WithValue(r.Context(), identity.SkipCacheKey, true)
···21 return
22 }
2324+ pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey)
25 if err != nil {
26 logger.Error("error parsing stored public key", "error", err)
27 helpers.ServerError(w, nil)
···3940 // If this is a did:plc identity, fetch the actual rotation keys from the
41 // current PLC document. After supplySigningKey transfers the rotation key
42+ // to the user's passkey, the PDS key is no longer authoritative and we
43 // must reflect the real state.
44 if strings.HasPrefix(repo.Repo.Did, "did:plc:") {
45 ctx := context.WithValue(r.Context(), identity.SkipCacheKey, true)
+3-3
server/handle_identity_sign_plc_operation.go
···34//
35// Unlike the previous implementation this handler never touches a private key.
36// The rotation key (held by the PDS) signs the PLC operation envelope as
37-// required by the PLC protocol; the user's signing key (held in their Ethereum
38-// wallet) signs only the inner payload bytes delivered over the WebSocket.
39func (s *Server) handleSignPlcOperation(w http.ResponseWriter, r *http.Request) {
40 logger := s.logger.With("name", "handleSignPlcOperation")
41···100 op.Services = *req.Services
101 }
102103- // Serialise the operation to CBOR — this is the payload the user's wallet
104 // must sign. We send it to the signer and wait for the signature.
105 opCBOR, err := op.MarshalCBOR()
106 if err != nil {
···34//
35// Unlike the previous implementation this handler never touches a private key.
36// The rotation key (held by the PDS) signs the PLC operation envelope as
37+// required by the PLC protocol; the user's signing key (held in their passkey)
38+// signs only the inner payload bytes delivered over the WebSocket.
39func (s *Server) handleSignPlcOperation(w http.ResponseWriter, r *http.Request) {
40 logger := s.logger.With("name", "handleSignPlcOperation")
41···100 op.Services = *req.Services
101 }
102103+ // Serialise the operation to CBOR — this is the payload the user's passkey
104 // must sign. We send it to the signer and wait for the signature.
105 opCBOR, err := op.MarshalCBOR()
106 if err != nil {
+2-2
server/handle_identity_submit_plc_operation.go
···52 // 1. The signing key (verificationMethods.atproto) matches the registered key.
53 // 2. The service endpoint still points to this PDS.
54 // 3. The rotation keys include at least one key that was already authorised
55- // (either the user's wallet key or the PDS key, depending on whether
56 // sovereignty has been transferred).
57 // 4. The operation was signed by one of the current rotation keys (enforced
58 // by plc.directory on submission, not re-checked here).
···62 return
63 }
6465- pubKey, err := atcrypto.ParsePublicBytesK256(repo.PublicKey)
66 if err != nil {
67 logger.Error("error parsing stored public key", "error", err)
68 helpers.ServerError(w, nil)
···52 // 1. The signing key (verificationMethods.atproto) matches the registered key.
53 // 2. The service endpoint still points to this PDS.
54 // 3. The rotation keys include at least one key that was already authorised
55+ // (either the user's passkey or the PDS key, depending on whether
56 // sovereignty has been transferred).
57 // 4. The operation was signed by one of the current rotation keys (enforced
58 // by plc.directory on submission, not re-checked here).
···62 return
63 }
6465+ pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey)
66 if err != nil {
67 logger.Error("error parsing stored public key", "error", err)
68 helpers.ServerError(w, nil)
+2-2
server/handle_identity_update_handle.go
···7576 // Determine whether the PDS rotation key still has authority over
77 // this DID. After supplySigningKey transfers the rotation key to the
78- // user's wallet, the PDS key is no longer in the rotation key list
79 // and cannot sign PLC operations.
80 pdsRotationDIDKey := s.plcClient.RotationDIDKey()
81 pdsCanSign := slices.Contains(latest.Operation.RotationKeys, pdsRotationDIDKey)
···88 return
89 }
90 } else {
91- // Rotation key belongs to the user's wallet. Delegate the
92 // signing to the signer over WebSocket, same as
93 // handleSignPlcOperation does for other PLC operations.
94 opCBOR, err := op.MarshalCBOR()
···7576 // Determine whether the PDS rotation key still has authority over
77 // this DID. After supplySigningKey transfers the rotation key to the
78+ // user's passkey, the PDS key is no longer in the rotation key list
79 // and cannot sign PLC operations.
80 pdsRotationDIDKey := s.plcClient.RotationDIDKey()
81 pdsCanSign := slices.Contains(latest.Operation.RotationKeys, pdsRotationDIDKey)
···88 return
89 }
90 } else {
91+ // Rotation key belongs to the user's passkey. Delegate the
92 // signing to the signer over WebSocket, same as
93 // handleSignPlcOperation does for other PLC operations.
94 opCBOR, err := op.MarshalCBOR()
···1+package server
2+3+import (
4+ "crypto/rand"
5+ "encoding/base64"
6+ "net/http"
7+8+ "pkg.rbrt.fr/vow/internal/helpers"
9+ "pkg.rbrt.fr/vow/models"
10+)
11+12+// passkeyCreationOptions is the JSON structure returned to the browser so it
13+// can call navigator.credentials.create(). It mirrors the
14+// PublicKeyCredentialCreationOptions WebAuthn type.
15+type passkeyCreationOptions struct {
16+ Rp passkeyRp `json:"rp"`
17+ User passkeyUser `json:"user"`
18+ // Challenge is a base64url-encoded random byte string. The browser passes
19+ // it through to the authenticator unchanged; the server doesn't need to
20+ // verify it later because the attestation is verified via the
21+ // clientDataJSON embedded in the attestationObject.
22+ Challenge string `json:"challenge"`
23+ PubKeyCredParams []pubKeyCredParam `json:"pubKeyCredParams"`
24+ AuthenticatorSel authenticatorSelection `json:"authenticatorSelection"`
25+ Attestation string `json:"attestation"`
26+ Timeout int `json:"timeout"`
27+}
28+29+type passkeyRp struct {
30+ ID string `json:"id"`
31+ Name string `json:"name"`
32+}
33+34+type passkeyUser struct {
35+ // ID is the base64url-encoded DID bytes. The WebAuthn spec requires it to
36+ // be opaque user-handle bytes, not a human-readable string.
37+ ID string `json:"id"`
38+ Name string `json:"name"`
39+ DisplayName string `json:"displayName"`
40+}
41+42+type pubKeyCredParam struct {
43+ Type string `json:"type"`
44+ Alg int `json:"alg"` // -7 = ES256 (P-256)
45+}
46+47+type authenticatorSelection struct {
48+ UserVerification string `json:"userVerification"`
49+ ResidentKey string `json:"residentKey"`
50+}
51+52+// handlePasskeyChallenge returns WebAuthn PublicKeyCredentialCreationOptions
53+// so the browser can register a new passkey for the authenticated account.
54+//
55+// POST /account/passkey-challenge
56+func (s *Server) handlePasskeyChallenge(w http.ResponseWriter, r *http.Request) {
57+ repo, ok := getContextValue[*models.RepoActor](r, contextKeyRepo)
58+ if !ok {
59+ helpers.UnauthorizedError(w, nil)
60+ return
61+ }
62+63+ // Generate a fresh 32-byte random challenge.
64+ challengeBytes := make([]byte, 32)
65+ if _, err := rand.Read(challengeBytes); err != nil {
66+ helpers.ServerError(w, nil)
67+ return
68+ }
69+ challenge := base64.RawURLEncoding.EncodeToString(challengeBytes)
70+71+ // Use the DID as the opaque user ID (base64url-encoded UTF-8 bytes).
72+ userID := base64.RawURLEncoding.EncodeToString([]byte(repo.Repo.Did))
73+74+ opts := passkeyCreationOptions{
75+ Rp: passkeyRp{
76+ ID: s.config.Hostname,
77+ Name: "Vow PDS",
78+ },
79+ User: passkeyUser{
80+ ID: userID,
81+ Name: repo.Handle,
82+ DisplayName: repo.Handle,
83+ },
84+ Challenge: challenge,
85+ PubKeyCredParams: []pubKeyCredParam{
86+ {Type: "public-key", Alg: -7}, // ES256 / P-256
87+ },
88+ AuthenticatorSel: authenticatorSelection{
89+ UserVerification: "preferred",
90+ ResidentKey: "preferred",
91+ },
92+ Attestation: "none",
93+ Timeout: 60000,
94+ }
95+96+ s.writeJSON(w, http.StatusOK, opts)
97+}
+1-1
server/handle_proxy.go
···8990 // exp=0 tells signServiceAuthJWT to use the default lifetime and
91 // cache the resulting token so repeated proxy calls for the same
92- // (aud, lxm) pair reuse it instead of prompting the wallet each time.
93 token, err := s.signServiceAuthJWT(r.Context(), repo, aud, lxm, 0)
94 if helpers.HandleSignerError(w, err) {
95 logger.Error("error signing proxy JWT", "error", err)
···8990 // exp=0 tells signServiceAuthJWT to use the default lifetime and
91 // cache the resulting token so repeated proxy calls for the same
92+ // (aud, lxm) pair reuse it instead of prompting the passkey each time.
93 token, err := s.signServiceAuthJWT(r.Context(), repo, aud, lxm, 0)
94 if helpers.HandleSignerError(w, err) {
95 logger.Error("error signing proxy JWT", "error", err)
+50-49
server/handle_server_delete_account.go
···23import (
4 "context"
5- "encoding/hex"
06 "encoding/json"
7 "fmt"
8 "net/http"
9- "strings"
10 "time"
1112 "github.com/bluesky-social/indigo/api/atproto"
13 "github.com/bluesky-social/indigo/events"
14 "github.com/bluesky-social/indigo/util"
15- gethcrypto "github.com/ethereum/go-ethereum/crypto"
16 "golang.org/x/crypto/bcrypt"
17 "pkg.rbrt.fr/vow/internal/helpers"
18 "pkg.rbrt.fr/vow/models"
···149}
150151// ---------------------------------------------------------------------------
152-// /account/delete — browser endpoint (web session + wallet signature)
153// ---------------------------------------------------------------------------
15400155type AccountDeleteRequest struct {
156- WalletAddress string `json:"walletAddress" validate:"required"`
157- Signature string `json:"signature" validate:"required"`
00158}
159160-// handleAccountDelete deletes the authenticated account after verifying that
161-// the request is signed by the wallet whose public key is registered with the
162-// account. Authentication is done via the web session cookie; the wallet
163-// signature proves the user still controls the key, with no email or password
164-// needed.
165func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request) {
166 ctx := r.Context()
167 logger := s.logger.With("name", "handleAccountDelete")
···172 return
173 }
174175- // The account must have a registered signing key; without it we have no
176- // wallet to verify against.
177 if len(repo.PublicKey) == 0 {
178 s.writeJSON(w, http.StatusBadRequest, map[string]any{
179 "error": "NoSigningKey",
180- "message": "No signing key is registered for this account. Please register your wallet first.",
181 })
182 return
183 }
···189 return
190 }
191192- if err := s.validator.Struct(&req); err != nil {
193- logger.Error("validation failed", "error", err)
194- s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "walletAddress and signature are required"})
0195 return
196 }
197198- // Decode the 65-byte personal_sign signature.
199- sigHex := strings.TrimPrefix(req.Signature, "0x")
200- sig, err := hex.DecodeString(sigHex)
201- if err != nil || len(sig) != 65 {
202- s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "signature must be a 65-byte hex string"})
203 return
204 }
205206- // personal_sign uses v=27/28; go-ethereum SigToPub expects v=0/1.
207- if sig[64] >= 27 {
208- sig[64] -= 27
209- }
210-211- // Hash the message with the Ethereum personal_sign envelope.
212- msg := fmt.Sprintf("Delete account: %s", repo.Repo.Did)
213- msgHash := gethcrypto.Keccak256(
214- fmt.Appendf(nil, "\x19Ethereum Signed Message:\n%d%s", len(msg), msg),
215- )
216-217- // Recover the public key from the signature.
218- ecPub, err := gethcrypto.SigToPub(msgHash, sig)
219 if err != nil {
220- logger.Warn("public key recovery failed", "error", err)
221- s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "could not recover public key from signature"})
222 return
223 }
224225- // Verify the recovered address matches the claimed wallet address.
226- recoveredAddr := gethcrypto.PubkeyToAddress(*ecPub).Hex()
227- if !strings.EqualFold(recoveredAddr, req.WalletAddress) {
228- logger.Warn("address mismatch", "claimed", req.WalletAddress, "recovered", recoveredAddr)
229- s.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "signature does not match the provided wallet address"})
230 return
231 }
232233- // Verify the recovered address matches the wallet registered on the account.
234- registeredAddr := repo.EthereumAddress()
235- if !strings.EqualFold(recoveredAddr, registeredAddr) {
236- logger.Warn("wallet not registered for account",
237- "recovered", recoveredAddr,
238- "registered", registeredAddr,
00000000000239 "did", repo.Repo.Did,
0240 )
241- s.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "signature wallet does not match the key registered for this account"})
00242 return
243 }
244
···23import (
4 "context"
5+ "crypto/sha256"
6+ "encoding/base64"
7 "encoding/json"
8 "fmt"
9 "net/http"
010 "time"
1112 "github.com/bluesky-social/indigo/api/atproto"
13 "github.com/bluesky-social/indigo/events"
14 "github.com/bluesky-social/indigo/util"
015 "golang.org/x/crypto/bcrypt"
16 "pkg.rbrt.fr/vow/internal/helpers"
17 "pkg.rbrt.fr/vow/models"
···148}
149150// ---------------------------------------------------------------------------
151+// /account/delete — browser endpoint (web session + WebAuthn assertion)
152// ---------------------------------------------------------------------------
153154+// AccountDeleteRequest carries the WebAuthn assertion response fields sent by
155+// the browser after the user confirms account deletion with their passkey.
156type AccountDeleteRequest struct {
157+ CredentialID string `json:"credentialId"` // base64url
158+ ClientDataJSON string `json:"clientDataJSON"` // base64url
159+ AuthenticatorData string `json:"authenticatorData"` // base64url
160+ Signature string `json:"signature"` // base64url DER-encoded ECDSA
161}
162163+// handleAccountDelete deletes the authenticated account after verifying a
164+// WebAuthn assertion signed by the passkey registered for the account.
165+// Authentication is via the web session cookie; the passkey assertion proves
166+// the user still controls the device, with no password or email needed.
0167func (s *Server) handleAccountDelete(w http.ResponseWriter, r *http.Request) {
168 ctx := r.Context()
169 logger := s.logger.With("name", "handleAccountDelete")
···174 return
175 }
176177+ // The account must have a registered passkey; without it there is nothing
178+ // to verify against.
179 if len(repo.PublicKey) == 0 {
180 s.writeJSON(w, http.StatusBadRequest, map[string]any{
181 "error": "NoSigningKey",
182+ "message": "No passkey is registered for this account. Please register a passkey first.",
183 })
184 return
185 }
···191 return
192 }
193194+ if req.ClientDataJSON == "" || req.AuthenticatorData == "" || req.Signature == "" {
195+ s.writeJSON(w, http.StatusBadRequest, map[string]string{
196+ "error": "clientDataJSON, authenticatorData, and signature are required",
197+ })
198 return
199 }
200201+ // Decode base64url fields.
202+ clientDataJSONBytes, err := base64.RawURLEncoding.DecodeString(req.ClientDataJSON)
203+ if err != nil {
204+ s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid clientDataJSON encoding"})
0205 return
206 }
207208+ authenticatorDataBytes, err := base64.RawURLEncoding.DecodeString(req.AuthenticatorData)
000000000000209 if err != nil {
210+ s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid authenticatorData encoding"})
0211 return
212 }
213214+ signatureDER, err := base64.RawURLEncoding.DecodeString(req.Signature)
215+ if err != nil {
216+ s.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid signature encoding"})
00217 return
218 }
219220+ // Reconstruct the expected challenge: SHA-256("Delete account: <did>").
221+ // This is the same derivation used by handlePasskeyAssertionChallenge, so
222+ // no server-side session state is needed.
223+ msg := fmt.Sprintf("Delete account: %s", repo.Repo.Did)
224+ sum := sha256.Sum256([]byte(msg))
225+226+ // verifyAssertion checks the challenge, rpIdHash, UP flag, and P-256
227+ // signature. It also returns the raw (r‖s) bytes, which we discard here.
228+ if _, err := verifyAssertion(
229+ repo.PublicKey,
230+ sum[:],
231+ clientDataJSONBytes,
232+ authenticatorDataBytes,
233+ signatureDER,
234+ s.config.Hostname,
235+ ); err != nil {
236+ logger.Warn("WebAuthn assertion verification failed for account delete",
237 "did", repo.Repo.Did,
238+ "error", err,
239 )
240+ s.writeJSON(w, http.StatusUnauthorized, map[string]string{
241+ "error": "passkey verification failed: " + err.Error(),
242+ })
243 return
244 }
245
+13-17
server/handle_server_get_service_auth.go
···80 })
81}
8283-// signServiceAuthJWT returns a signed ES256K service-auth JWT for the given
84-// (aud, lxm) pair, reusing a cached token when possible. Only when no cached
85-// token is available does it send a signing request to the user's wallet via
86-// the SignerHub WebSocket.
87//
88// The returned string is a fully formed "header.payload.signature" JWT ready to
89// be placed in an Authorization: Bearer header.
···104105 // ── Build header + payload ────────────────────────────────────────────
106 header := map[string]string{
107- "alg": "ES256K",
108- "crv": "secp256k1",
109 "typ": "JWT",
110 }
111 hj, err := json.Marshal(header)
···144 // base64url(header) + "." + base64url(payload).
145 signingInput := encHeader + "." + encPayload
146147- // The wallet signs the SHA-256 hash of the signing input, which is what
148- // ES256K requires. We pass the raw signingInput bytes as the payload;
149- // HashAndVerifyLenient on the verification side hashes them before
150- // verifying, matching what personal_sign does after EIP-191 prefix
151- // stripping (or eth_sign which skips the prefix).
152- //
153- // We send the SHA-256 pre-image (the signingInput string) rather than the
154- // hash so the signer can display it meaningfully and so the wallet can
155- // apply its own hashing. This matches the pattern used for commit signing.
156 hash := sha256.Sum256([]byte(signingInput))
157 payloadB64 := base64.RawURLEncoding.EncodeToString(hash[:])
158···180 return "", err
181 }
182183- // sigBytes is the raw compact (r||s) or EIP-191 signature returned by the
184- // wallet. Trim to 64 bytes (r||s) if the wallet appended a recovery byte.
0185 if len(sigBytes) == 65 {
186 sigBytes = sigBytes[:64]
187 }
···80 })
81}
8283+// signServiceAuthJWT returns a signed ES256 service-auth JWT for the given
84+// (aud, lxm) pair. It sends a signing request to the user's passkey via the
85+// SignerHub WebSocket and waits for the verified raw (r‖s) signature.
086//
87// The returned string is a fully formed "header.payload.signature" JWT ready to
88// be placed in an Authorization: Bearer header.
···103104 // ── Build header + payload ────────────────────────────────────────────
105 header := map[string]string{
106+ "alg": "ES256",
107+ "crv": "P-256",
108 "typ": "JWT",
109 }
110 hj, err := json.Marshal(header)
···143 // base64url(header) + "." + base64url(payload).
144 signingInput := encHeader + "." + encPayload
145146+ // ES256 requires signing the SHA-256 hash of the signing input. We send
147+ // the hash as the WebAuthn challenge (the passkey will sign
148+ // authenticatorData ‖ SHA-256(clientDataJSON) where clientDataJSON.challenge
149+ // = base64url(hash)). The WS handler verifies the full assertion and
150+ // delivers the raw (r‖s) signature bytes back to this function.
0000151 hash := sha256.Sum256([]byte(signingInput))
152 payloadB64 := base64.RawURLEncoding.EncodeToString(hash[:])
153···175 return "", err
176 }
177178+ // sigBytes is the raw 64-byte (r‖s) P-256 signature delivered by the WS
179+ // handler after WebAuthn assertion verification. Trim to 64 bytes just in
180+ // case an old client appended a recovery byte.
181 if len(sigBytes) == 65 {
182 sigBytes = sigBytes[:64]
183 }
+3-3
server/handle_server_get_signing_key.go
···1011// PendingWriteOp is a human-readable summary of a single operation inside a
12// signing request, sent to the signer so the user knows what they are
13-// approving before the wallet prompt appears.
14type PendingWriteOp struct {
15 Type string `json:"type"`
16 Collection string `json:"collection"`
···25 PublicKey string `json:"publicKey"`
26}
2728-// handleGetSigningKey returns the compressed secp256k1 public key registered
29// for the authenticated account, encoded as a did:key string.
30//
31// The private key is never held by the PDS; this endpoint only confirms that a
···44 return
45 }
4647- pubKey, err := atcrypto.ParsePublicBytesK256(repo.PublicKey)
48 if err != nil {
49 logger.Error("error parsing stored public key", "error", err)
50 helpers.ServerError(w, nil)
···1011// PendingWriteOp is a human-readable summary of a single operation inside a
12// signing request, sent to the signer so the user knows what they are
13+// approving before the passkey prompt appears.
14type PendingWriteOp struct {
15 Type string `json:"type"`
16 Collection string `json:"collection"`
···25 PublicKey string `json:"publicKey"`
26}
2728+// handleGetSigningKey returns the compressed P-256 public key registered
29// for the authenticated account, encoded as a did:key string.
30//
31// The private key is never held by the PDS; this endpoint only confirms that a
···44 return
45 }
4647+ pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey)
48 if err != nil {
49 logger.Error("error parsing stored public key", "error", err)
50 helpers.ServerError(w, nil)
+51-89
server/handle_server_supply_signing_key.go
···1package server
23import (
4- "encoding/hex"
5 "encoding/json"
6- "fmt"
7 "maps"
8 "net/http"
9 "strings"
1011 "github.com/bluesky-social/indigo/atproto/atcrypto"
12- gethcrypto "github.com/ethereum/go-ethereum/crypto"
13 "pkg.rbrt.fr/vow/identity"
14 "pkg.rbrt.fr/vow/internal/helpers"
15 "pkg.rbrt.fr/vow/models"
16 "pkg.rbrt.fr/vow/plc"
17)
1819-// ComAtprotoServerSupplySigningKeyRequest is sent by the account page to
20-// register the user's secp256k1 public key with the PDS. The client sends the
21-// wallet address and the signature over a fixed registration message; the PDS
22-// recovers the public key server-side using go-ethereum and verifies it
23-// matches the wallet address before storing it.
24-type ComAtprotoServerSupplySigningKeyRequest struct {
25- // WalletAddress is the EIP-55 checksummed Ethereum address of the wallet.
26- WalletAddress string `json:"walletAddress" validate:"required"`
27- // Signature is the hex-encoded 65-byte personal_sign signature (0x-prefixed).
28- Signature string `json:"signature" validate:"required"`
0029}
3031-type ComAtprotoServerSupplySigningKeyResponse struct {
32- Did string `json:"did"`
33- PublicKey string `json:"publicKey"` // did:key representation
034}
3536-// handleSupplySigningKey lets the account page register the user's
37-// secp256k1 public key. The PDS stores only the compressed public key bytes
38-// and updates the PLC DID document so the key becomes the active
39-// verificationMethods.atproto entry.
40//
41-// The private key is never transmitted to or stored by the PDS.
42-// registrationMessage is the fixed plaintext that the wallet must sign during
43-// key registration. It is prefixed with the Ethereum personal_sign envelope
44-// ("\x19Ethereum Signed Message:\n<len>") by the wallet before signing.
45-const registrationMessage = "Vow key registration"
46-47func (s *Server) handleSupplySigningKey(w http.ResponseWriter, r *http.Request) {
48 ctx := r.Context()
49 logger := s.logger.With("name", "handleSupplySigningKey")
···54 return
55 }
5657- var req ComAtprotoServerSupplySigningKeyRequest
58 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
59 logger.Error("error decoding request", "error", err)
60 helpers.InputError(w, new("could not decode request body"))
···6364 if err := s.validator.Struct(req); err != nil {
65 logger.Error("validation failed", "error", err)
66- helpers.InputError(w, new("walletAddress and signature are required"))
67 return
68 }
6970- // Decode the 65-byte personal_sign signature.
71- sigHex := strings.TrimPrefix(req.Signature, "0x")
72- sig, err := hex.DecodeString(sigHex)
73- if err != nil || len(sig) != 65 {
74- helpers.InputError(w, new("signature must be a 65-byte hex string"))
75- return
76- }
77-78- // personal_sign uses v=27/28; go-ethereum SigToPub expects v=0/1.
79- if sig[64] >= 27 {
80- sig[64] -= 27
81- }
82-83- // Hash the message the same way personal_sign does:
84- // keccak256("\x19Ethereum Signed Message:\n<len><message>")
85- msgHash := gethcrypto.Keccak256(
86- fmt.Appendf(nil, "\x19Ethereum Signed Message:\n%d%s",
87- len(registrationMessage), registrationMessage),
88- )
89-90- // Recover the uncompressed public key.
91- ecPub, err := gethcrypto.SigToPub(msgHash, sig)
92 if err != nil {
93- logger.Warn("public key recovery failed", "error", err)
94- helpers.InputError(w, new("could not recover public key from signature"))
95 return
96 }
9798- // Verify the recovered key matches the claimed wallet address.
99- recoveredAddr := gethcrypto.PubkeyToAddress(*ecPub).Hex()
100- if !strings.EqualFold(recoveredAddr, req.WalletAddress) {
101- logger.Warn("recovered address mismatch",
102- "claimed", req.WalletAddress,
103- "recovered", recoveredAddr,
104- )
105- helpers.InputError(w, new("recovered address does not match walletAddress"))
106- return
107- }
108-109- // Compress the public key (33 bytes).
110- keyBytes := gethcrypto.CompressPubkey(ecPub)
111-112- // Validate the compressed key is accepted by the atproto library.
113- pubKey, err := atcrypto.ParsePublicBytesK256(keyBytes)
114 if err != nil {
115- logger.Error("compressed key rejected by atcrypto", "error", err)
116- helpers.ServerError(w, nil)
117 return
118 }
119120 pubDIDKey := pubKey.DIDKey()
121122- // Update the PLC DID document if this is a did:plc identity so that the
123- // new public key is the active atproto verification method.
124 if strings.HasPrefix(repo.Repo.Did, "did:plc:") {
125 log, err := identity.FetchDidAuditLog(ctx, nil, repo.Repo.Did)
126 if err != nil {
···135 maps.Copy(newVerificationMethods, latest.Operation.VerificationMethods)
136 newVerificationMethods["atproto"] = pubDIDKey
137138- // Replace the PDS rotation key with the user's wallet key. After
139- // this operation the PDS can no longer unilaterally modify the DID
140- // document — only the user's Ethereum wallet can authorise future
141- // PLC operations. This is the moment the identity becomes
142- // user-sovereign.
143 newRotationKeys := []string{pubDIDKey}
144145 op := plc.Operation{
···151 Prev: &latest.Cid,
152 }
153154- // The PLC operation is signed by the PDS rotation key, which still
155- // has authority over the DID at this point. This is the last
156- // operation the PDS will ever be able to sign — it is voluntarily
157- // handing over control to the user's wallet key.
158 if err := s.plcClient.SignOp(&op); err != nil {
159 logger.Error("error signing PLC operation with rotation key", "error", err)
160 helpers.ServerError(w, nil)
···168 }
169 }
170171- // Persist the compressed public key.
172 if err := s.db.Exec(ctx,
173- "UPDATE repos SET public_key = ? WHERE did = ?",
174- nil, keyBytes, repo.Repo.Did,
175 ).Error; err != nil {
176- logger.Error("error updating public key in db", "error", err)
177 helpers.ServerError(w, nil)
178 return
179 }
···183 logger.Warn("error busting DID doc cache", "error", err)
184 }
185186- logger.Info("public signing key registered via BYOK — rotation key transferred to user",
187 "did", repo.Repo.Did,
188 "publicKey", pubDIDKey,
0189 )
190191- s.writeJSON(w, 200, ComAtprotoServerSupplySigningKeyResponse{
192- Did: repo.Repo.Did,
193- PublicKey: pubDIDKey,
0194 })
195}
···1package server
23import (
4+ "encoding/base64"
5 "encoding/json"
06 "maps"
7 "net/http"
8 "strings"
910 "github.com/bluesky-social/indigo/atproto/atcrypto"
011 "pkg.rbrt.fr/vow/identity"
12 "pkg.rbrt.fr/vow/internal/helpers"
13 "pkg.rbrt.fr/vow/models"
14 "pkg.rbrt.fr/vow/plc"
15)
1617+// SupplySigningKeyRequest is sent by the account page to register a WebAuthn
18+// passkey as the account's signing key. The browser calls
19+// navigator.credentials.create() and forwards the raw attestation response
20+// fields here; the server parses the CBOR attestation object, extracts the
21+// P-256 public key, and stores it alongside the credential ID.
22+type SupplySigningKeyRequest struct {
23+ // ClientDataJSON is the base64url-encoded clientDataJSON bytes from the
24+ // AuthenticatorAttestationResponse.
25+ ClientDataJSON string `json:"clientDataJSON" validate:"required"`
26+ // AttestationObject is the base64url-encoded attestationObject CBOR from
27+ // the AuthenticatorAttestationResponse.
28+ AttestationObject string `json:"attestationObject" validate:"required"`
29}
3031+type SupplySigningKeyResponse struct {
32+ Did string `json:"did"`
33+ PublicKey string `json:"publicKey"` // did:key representation
34+ CredentialID string `json:"credentialId"` // base64url
35}
3637+// handleSupplySigningKey registers a WebAuthn passkey for the authenticated
38+// account. The private key never leaves the authenticator; the PDS stores only
39+// the compressed P-256 public key and the credential ID.
040//
41+// On success, the account's PLC DID document is updated so that the passkey's
42+// did:key becomes the active atproto verification method and rotation key.
000043func (s *Server) handleSupplySigningKey(w http.ResponseWriter, r *http.Request) {
44 ctx := r.Context()
45 logger := s.logger.With("name", "handleSupplySigningKey")
···50 return
51 }
5253+ var req SupplySigningKeyRequest
54 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
55 logger.Error("error decoding request", "error", err)
56 helpers.InputError(w, new("could not decode request body"))
···5960 if err := s.validator.Struct(req); err != nil {
61 logger.Error("validation failed", "error", err)
62+ helpers.InputError(w, new("clientDataJSON and attestationObject are required"))
63 return
64 }
6566+ // Parse the attestation object and extract the P-256 public key +
67+ // credential ID. We accept both "none" and self-attestation.
68+ keyBytes, credentialID, err := parseAttestationObject(req.AttestationObject)
000000000000000000069 if err != nil {
70+ logger.Warn("attestation parsing failed", "error", err)
71+ helpers.InputError(w, new("could not parse attestation object"))
72 return
73 }
7475+ // Validate the compressed key is a well-formed P-256 point.
76+ pubKey, err := atcrypto.ParsePublicBytesP256(keyBytes)
0000000000000077 if err != nil {
78+ logger.Error("compressed P-256 key rejected by atcrypto", "error", err)
79+ helpers.InputError(w, new("invalid P-256 public key in attestation"))
80 return
81 }
8283 pubDIDKey := pubKey.DIDKey()
8485+ // Update the PLC DID document so the passkey's did:key becomes the active
86+ // atproto verification method and the sole rotation key.
87 if strings.HasPrefix(repo.Repo.Did, "did:plc:") {
88 log, err := identity.FetchDidAuditLog(ctx, nil, repo.Repo.Did)
89 if err != nil {
···98 maps.Copy(newVerificationMethods, latest.Operation.VerificationMethods)
99 newVerificationMethods["atproto"] = pubDIDKey
100101+ // Replace the PDS rotation key with the passkey's did:key. After this
102+ // operation the PDS can no longer unilaterally modify the DID document
103+ // — only the user's passkey can authorise future PLC operations.
00104 newRotationKeys := []string{pubDIDKey}
105106 op := plc.Operation{
···112 Prev: &latest.Cid,
113 }
114115+ // The PDS rotation key signs this PLC operation — this is the last
116+ // PLC operation the PDS will ever be able to sign on behalf of the
117+ // user. It is voluntarily handing over control to the passkey.
0118 if err := s.plcClient.SignOp(&op); err != nil {
119 logger.Error("error signing PLC operation with rotation key", "error", err)
120 helpers.ServerError(w, nil)
···128 }
129 }
130131+ // Persist the compressed P-256 public key and credential ID.
132 if err := s.db.Exec(ctx,
133+ "UPDATE repos SET public_key = ?, credential_id = ? WHERE did = ?",
134+ nil, keyBytes, credentialID, repo.Repo.Did,
135 ).Error; err != nil {
136+ logger.Error("error updating public key and credential ID in db", "error", err)
137 helpers.ServerError(w, nil)
138 return
139 }
···143 logger.Warn("error busting DID doc cache", "error", err)
144 }
145146+ logger.Info("passkey registered — rotation key transferred to user",
147 "did", repo.Repo.Did,
148 "publicKey", pubDIDKey,
149+ "credentialIDLen", len(credentialID),
150 )
151152+ s.writeJSON(w, 200, SupplySigningKeyResponse{
153+ Did: repo.Repo.Did,
154+ PublicKey: pubDIDKey,
155+ CredentialID: base64.RawURLEncoding.EncodeToString(credentialID),
156 })
157}
+108-19
server/handle_signer_connect.go
···3import (
4 "encoding/base64"
5 "encoding/json"
06 "net/http"
7 "strings"
8 "time"
···35 Type string `json:"type"` // always "sign_request"
36 RequestID string `json:"requestId"` // UUID, echoed back in the response
37 Did string `json:"did"`
38- Payload string `json:"payload"` // base64url-encoded unsigned commit CBOR
39 Ops []PendingWriteOp `json:"ops"` // human-readable summary shown to user
40 ExpiresAt string `json:"expiresAt"` // RFC3339
41}
4243// wsIncoming is used for initial type-sniffing before full decode.
0000044type wsIncoming struct {
45- Type string `json:"type"`
46- RequestID string `json:"requestId"`
47- // sign_response: base64url-encoded signature bytes.
48- Signature string `json:"signature,omitempty"`
049}
5051// handleSignerConnect upgrades the connection to a WebSocket and registers it
···57//
58// 1. When a write handler needs a signature it calls SignerHub.RequestSignature
59// which pushes a signerRequest onto the conn.requests channel.
60-// 2. This goroutine picks it up, writes the sign_request (or pay_request) JSON
61-// frame, and waits for a sign_response / pay_response or their reject
62-// counterparts from the client.
63-// 3. The reply is forwarded back to the waiting write handler via the reply
64-// channel inside the signerRequest.
65//
66// The loop also handles WebSocket ping/pong: the server sends a ping every 20 s
67// and expects a pong within 10 s (gorilla handles pong automatically).
···135 // inbound carries decoded messages from the reader goroutine.
136 inbound := make(chan wsIncoming, 4)
137138- // nextReq carries the next queued request to be sent to the wallet.
139- // The NextRequest goroutine blocks until a request is ready and no other
140- // request is in-flight (serialising wallet prompts automatically).
141 nextReq := make(chan signerRequest, 1)
14200000143 ctx := r.Context()
144145 // Read pump: conn.ReadMessage blocks so it runs in its own goroutine.
···164 }()
165166 // Queue pump: feeds the main loop one request at a time, respecting the
167- // wallet's one-at-a-time constraint enforced inside NextRequest.
168 go func() {
169 for {
170 req, ok := sc.NextRequest(ctx)
···206 case in := <-inbound:
207 switch in.Type {
208 case "sign_response":
209- if in.Signature == "" {
210- logger.Warn("signer: sign_response missing signature", "did", did)
0211 continue
212 }
213- sigBytes, err := base64.RawURLEncoding.DecodeString(in.Signature)
00214 if err != nil {
215- logger.Warn("signer: sign_response bad base64url", "did", did, "error", err)
216 continue
217 }
218- if !s.signerHub.DeliverSignature(did, in.RequestID, sigBytes) {
219 logger.Warn("signer: sign_response for unknown requestId", "did", did, "requestId", in.RequestID)
220 }
221222 case "sign_reject":
0223 if !s.signerHub.DeliverRejection(did, in.RequestID) {
224 logger.Warn("signer: sign_reject for unknown requestId", "did", did, "requestId", in.RequestID)
225 }
···234 logger.Error("signer: failed to write request", "did", did, "error", err)
235 req.reply <- signerReply{err: helpers.ErrSignerNotConnected}
236 return
00000000237 }
238239 logger.Info("signer: request sent", "did", did, "requestId", req.requestID)
···269 Ops: ops,
270 ExpiresAt: expiresAt.UTC().Format(time.RFC3339),
271 })
00000000000000000000000000000000000000000000000000000000000000000000272}
273274// isTokenExpired returns true if the JWT's exp claim is in the past.
···3import (
4 "encoding/base64"
5 "encoding/json"
6+ "log/slog"
7 "net/http"
8 "strings"
9 "time"
···36 Type string `json:"type"` // always "sign_request"
37 RequestID string `json:"requestId"` // UUID, echoed back in the response
38 Did string `json:"did"`
39+ Payload string `json:"payload"` // base64url-encoded unsigned commit CBOR (used as the WebAuthn challenge)
40 Ops []PendingWriteOp `json:"ops"` // human-readable summary shown to user
41 ExpiresAt string `json:"expiresAt"` // RFC3339
42}
4344// wsIncoming is used for initial type-sniffing before full decode.
45+//
46+// sign_response carries the three fields from the WebAuthn AuthenticatorAssertionResponse:
47+// - AuthenticatorData: base64url authenticatorData bytes
48+// - ClientDataJSON: base64url clientDataJSON bytes
49+// - Signature: base64url DER-encoded ECDSA signature
50type wsIncoming struct {
51+ Type string `json:"type"`
52+ RequestID string `json:"requestId"`
53+ AuthenticatorData string `json:"authenticatorData,omitempty"` // base64url
54+ ClientDataJSON string `json:"clientDataJSON,omitempty"` // base64url
55+ Signature string `json:"signature,omitempty"` // base64url DER-encoded ECDSA
56}
5758// handleSignerConnect upgrades the connection to a WebSocket and registers it
···64//
65// 1. When a write handler needs a signature it calls SignerHub.RequestSignature
66// which pushes a signerRequest onto the conn.requests channel.
67+// 2. This goroutine picks it up, writes the sign_request JSON frame, and waits
68+// for a sign_response or sign_reject from the client.
69+// 3. The WebAuthn assertion is verified here; the resulting raw (r‖s) signature
70+// bytes are forwarded to the waiting write handler via DeliverSignature.
071//
72// The loop also handles WebSocket ping/pong: the server sends a ping every 20 s
73// and expects a pong within 10 s (gorilla handles pong automatically).
···141 // inbound carries decoded messages from the reader goroutine.
142 inbound := make(chan wsIncoming, 4)
143144+ // nextReq carries the next queued request to be sent to the signer.
00145 nextReq := make(chan signerRequest, 1)
146147+ // pendingPayloads maps requestID → base64url payload so that when a
148+ // sign_response arrives we can reconstruct the expected WebAuthn challenge
149+ // (the raw bytes that the payload string encodes).
150+ pendingPayloads := make(map[string]string)
151+152 ctx := r.Context()
153154 // Read pump: conn.ReadMessage blocks so it runs in its own goroutine.
···173 }()
174175 // Queue pump: feeds the main loop one request at a time, respecting the
176+ // passkey's one-at-a-time constraint enforced inside NextRequest.
177 go func() {
178 for {
179 req, ok := sc.NextRequest(ctx)
···215 case in := <-inbound:
216 switch in.Type {
217 case "sign_response":
218+ payload, ok := pendingPayloads[in.RequestID]
219+ if !ok {
220+ logger.Warn("signer: sign_response for unknown requestId (no payload)", "did", did, "requestId", in.RequestID)
221 continue
222 }
223+ delete(pendingPayloads, in.RequestID)
224+225+ rawSig, err := verifyWebAuthnSignResponse(repo.PublicKey, payload, in, s.config.Hostname, logger)
226 if err != nil {
227+ logger.Warn("signer: sign_response verification failed", "did", did, "requestId", in.RequestID, "error", err)
228 continue
229 }
230+ if !s.signerHub.DeliverSignature(did, in.RequestID, rawSig) {
231 logger.Warn("signer: sign_response for unknown requestId", "did", did, "requestId", in.RequestID)
232 }
233234 case "sign_reject":
235+ delete(pendingPayloads, in.RequestID)
236 if !s.signerHub.DeliverRejection(did, in.RequestID) {
237 logger.Warn("signer: sign_reject for unknown requestId", "did", did, "requestId", in.RequestID)
238 }
···247 logger.Error("signer: failed to write request", "did", did, "error", err)
248 req.reply <- signerReply{err: helpers.ErrSignerNotConnected}
249 return
250+ }
251+252+ // Record the payload so we can verify the WebAuthn challenge when
253+ // the sign_response arrives.
254+ if payload, err := extractPayloadFromMsg(req.msg); err == nil {
255+ pendingPayloads[req.requestID] = payload
256+ } else {
257+ logger.Warn("signer: could not extract payload from sign_request", "did", did, "error", err)
258 }
259260 logger.Info("signer: request sent", "did", did, "requestId", req.requestID)
···290 Ops: ops,
291 ExpiresAt: expiresAt.UTC().Format(time.RFC3339),
292 })
293+}
294+295+// extractPayloadFromMsg extracts the "payload" field from a sign_request JSON
296+// message without a full re-parse.
297+func extractPayloadFromMsg(msg []byte) (string, error) {
298+ var req struct {
299+ Payload string `json:"payload"`
300+ }
301+ if err := json.Unmarshal(msg, &req); err != nil {
302+ return "", err
303+ }
304+ if req.Payload == "" {
305+ return "", nil
306+ }
307+ return req.Payload, nil
308+}
309+310+// verifyWebAuthnSignResponse decodes the three base64url fields from a
311+// sign_response message, reconstructs the expected challenge from the payload,
312+// verifies the WebAuthn P-256 assertion, and returns the raw 64-byte (r‖s)
313+// signature for use in ATProto commits and JWTs.
314+//
315+// pubKey is the compressed P-256 public key stored in the database.
316+// payloadB64 is the base64url-encoded challenge bytes that were sent in the
317+// sign_request (the raw CBOR bytes of the unsigned commit, or the SHA-256 of
318+// the JWT signing input for service-auth tokens).
319+func verifyWebAuthnSignResponse(
320+ pubKey []byte,
321+ payloadB64 string,
322+ in wsIncoming,
323+ rpID string,
324+ logger *slog.Logger,
325+) ([]byte, error) {
326+ if in.AuthenticatorData == "" || in.ClientDataJSON == "" || in.Signature == "" {
327+ return nil, helpers.ErrSignerNotConnected // reuse a sentinel; caller logs
328+ }
329+330+ // The challenge passed to navigator.credentials.get() was the raw bytes
331+ // decoded from payloadB64. The browser re-encodes them as base64url in
332+ // clientDataJSON.challenge — so the expected challenge is exactly those
333+ // raw bytes.
334+ expectedChallenge, err := base64.RawURLEncoding.DecodeString(payloadB64)
335+ if err != nil {
336+ return nil, err
337+ }
338+339+ clientDataJSONBytes, err := base64.RawURLEncoding.DecodeString(in.ClientDataJSON)
340+ if err != nil {
341+ return nil, err
342+ }
343+344+ authenticatorDataBytes, err := base64.RawURLEncoding.DecodeString(in.AuthenticatorData)
345+ if err != nil {
346+ return nil, err
347+ }
348+349+ signatureDER, err := base64.RawURLEncoding.DecodeString(in.Signature)
350+ if err != nil {
351+ return nil, err
352+ }
353+354+ rawSig, err := verifyAssertion(pubKey, expectedChallenge, clientDataJSONBytes, authenticatorDataBytes, signatureDER, rpID)
355+ if err != nil {
356+ logger.With("rpID", rpID).Debug("verifyAssertion detail", "error", err)
357+ return nil, err
358+ }
359+360+ return rawSig, nil
361}
362363// isTokenExpired returns true if the JWT's exp claim is in the past.
+13-10
server/middleware.go
···155 repo = maybeRepo
156 }
157158- if token.Header["alg"] != "ES256K" {
00000159 token, err = new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) {
160 if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
161 return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"])
162 }
163- return s.privateKey.Public(), nil
164 })
165 if err != nil {
166 logger.Error("error parsing jwt", "error", err)
···191 if repo == nil {
192 sub, ok := claims["sub"].(string)
193 if !ok {
194- s.logger.Error("no sub claim in ES256K token and repo not set")
195 helpers.InvalidTokenError(w)
196 return
197 }
198 maybeRepo, err := s.getRepoActorByDid(ctx, sub)
199 if err != nil {
200- s.logger.Error("error fetching repo for ES256K verification", "error", err)
201 helpers.ServerError(w, nil)
202 return
203 }
···205 did = sub
206 }
207208- // The PDS never holds a private key. Verify the ES256K JWT
209- // signature using the compressed public key stored in PublicKey.
210 if len(repo.PublicKey) == 0 {
211 logger.Error("no public key registered for account", "did", repo.Repo.Did)
212 helpers.ServerError(w, nil)
213 return
214 }
215216- pubKey, err := atcrypto.ParsePublicBytesK256(repo.PublicKey)
217 if err != nil {
218 logger.Error("can't parse stored public key", "error", err)
219 helpers.ServerError(w, nil)
220 return
221 }
222223- // sigBytes is already the compact (r||s) 64-byte form. Verify
224- // using HashAndVerifyLenient which hashes signingInput internally.
225 if err := pubKey.HashAndVerifyLenient([]byte(signingInput), sigBytes); err != nil {
226- logger.Error("ES256K signature verification failed", "error", err)
227 helpers.ServerError(w, nil)
228 return
229 }
···155 repo = maybeRepo
156 }
157158+ // isUserSignedToken is true for service-auth JWTs signed by the user's
159+ // passkey (ES256 with an lxm claim). Regular access tokens use ES256
160+ // too but are signed by the PDS private key and carry no lxm claim.
161+ isUserSignedToken := token.Header["alg"] == "ES256" && hasLxm
162+163+ if !isUserSignedToken {
164 token, err = new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) {
165 if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
166 return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"])
167 }
168+ return &s.privateKey.PublicKey, nil
169 })
170 if err != nil {
171 logger.Error("error parsing jwt", "error", err)
···196 if repo == nil {
197 sub, ok := claims["sub"].(string)
198 if !ok {
199+ s.logger.Error("no sub claim in user-signed token and repo not set")
200 helpers.InvalidTokenError(w)
201 return
202 }
203 maybeRepo, err := s.getRepoActorByDid(ctx, sub)
204 if err != nil {
205+ s.logger.Error("error fetching repo for user-signed token verification", "error", err)
206 helpers.ServerError(w, nil)
207 return
208 }
···210 did = sub
211 }
212213+ // The PDS never holds the user's private key. Verify the JWT
214+ // signature using the compressed P-256 public key stored in the DB.
215 if len(repo.PublicKey) == 0 {
216 logger.Error("no public key registered for account", "did", repo.Repo.Did)
217 helpers.ServerError(w, nil)
218 return
219 }
220221+ pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey)
222 if err != nil {
223 logger.Error("can't parse stored public key", "error", err)
224 helpers.ServerError(w, nil)
225 return
226 }
22700228 if err := pubKey.HashAndVerifyLenient([]byte(signingInput), sigBytes); err != nil {
229+ logger.Error("user-signed JWT verification failed", "error", err)
230 helpers.ServerError(w, nil)
231 return
232 }
+3-3
server/repo.go
···219// provided raw signature bytes, reserialises the commit, writes the commit
220// block to the blockstore, and returns the commit CID.
221//
222-// sig must be the raw secp256k1 signature (compact or DER) over uc.cbor as
223-// produced by an Ethereum wallet's personal_sign / eth_sign call.
224func finaliseCommit(ctx context.Context, bs blockstore.Blockstore, uc *unsignedCommit, sig []byte) (cid.Cid, error) {
225 // Decode the unsigned commit so we can attach the signature field.
226 var commit atp.Commit
···599 return nil, fmt.Errorf("no public key registered for account %s", urepo.Did)
600 }
601602- pubKey, err := atcrypto.ParsePublicBytesK256(urepo.PublicKey)
603 if err != nil {
604 return nil, fmt.Errorf("parsing stored public key: %w", err)
605 }
···219// provided raw signature bytes, reserialises the commit, writes the commit
220// block to the blockstore, and returns the commit CID.
221//
222+// sig must be the raw 64-byte (r‖s) P-256 ECDSA signature over uc.cbor as
223+// produced by the passkey WebAuthn assertion and verified by the WS handler.
224func finaliseCommit(ctx context.Context, bs blockstore.Blockstore, uc *unsignedCommit, sig []byte) (cid.Cid, error) {
225 // Decode the unsigned commit so we can attach the signature field.
226 var commit atp.Commit
···599 return nil, fmt.Errorf("no public key registered for account %s", urepo.Did)
600 }
601602+ pubKey, err := atcrypto.ParsePublicBytesP256(urepo.PublicKey)
603 if err != nil {
604 return nil, fmt.Errorf("parsing stored public key: %w", err)
605 }
···3132// signerConn represents one active signer WebSocket connection for a DID.
33// It owns an unbounded queue of pending requests and a map of in-flight
34-// requests waiting for a reply from the wallet.
35type signerConn struct {
36 // mu protects pending and inflight.
37 mu sync.Mutex
3839 // pending is an ordered queue of requests that have not yet been sent to
40- // the wallet. The WS goroutine drains it one at a time: it pops the head,
41 // sends the sign_request frame, moves the request into inflight, and only
42- // pops the next one after a reply arrives. This serialises wallet prompts
43 // (the user must confirm each one before the next appears) while allowing
44 // any number of callers to enqueue work concurrently.
45 pending []signerRequest
4647- // inflight holds the single request that has been sent to the wallet and
48 // is awaiting a sign_response / sign_reject. Keyed by requestID.
49 inflight map[string]signerRequest
50···127}
128129// RequestSignature enqueues a signing request for did and blocks until one of:
130-// - The wallet sends sign_response → returns the signature bytes.
131-// - The wallet sends sign_reject → returns helpers.ErrSignerRejected.
132// - The WebSocket disconnects → returns helpers.ErrSignerNotConnected.
133// - ctx is cancelled or times out → returns helpers.ErrSignerTimeout or ctx.Err().
134//
135// Multiple callers for the same DID are all queued and processed sequentially
136-// (the wallet sees one prompt at a time). Callers for different DIDs are
137// independent.
138func (h *SignerHub) RequestSignature(ctx context.Context, did string, requestID string, msg []byte) ([]byte, error) {
139 h.mu.Lock()
···243 return true
244}
245246-// NextRequest blocks until a pending request is ready to be sent to the wallet
247-// and no other request is currently in-flight (wallets handle one prompt at a
248-// time). It moves the request from pending into inflight before returning, so
249-// the caller just needs to write it to the WebSocket.
250//
251// Returns (request, true) on success, or (zero, false) if the connection is
252// going away (done closed or ctx cancelled).
253func (conn *signerConn) NextRequest(ctx context.Context) (signerRequest, bool) {
254 for {
255 conn.mu.Lock()
256- // Only dequeue when nothing is in-flight (wallet is free).
257 if len(conn.pending) > 0 && len(conn.inflight) == 0 {
258 req := conn.pending[0]
259 conn.pending = conn.pending[1:]
···3132// signerConn represents one active signer WebSocket connection for a DID.
33// It owns an unbounded queue of pending requests and a map of in-flight
34+// requests waiting for a reply from the passkey.
35type signerConn struct {
36 // mu protects pending and inflight.
37 mu sync.Mutex
3839 // pending is an ordered queue of requests that have not yet been sent to
40+ // the passkey. The WS goroutine drains it one at a time: it pops the head,
41 // sends the sign_request frame, moves the request into inflight, and only
42+ // pops the next one after a reply arrives. This serialises passkey prompts
43 // (the user must confirm each one before the next appears) while allowing
44 // any number of callers to enqueue work concurrently.
45 pending []signerRequest
4647+ // inflight holds the single request that has been sent to the passkey and
48 // is awaiting a sign_response / sign_reject. Keyed by requestID.
49 inflight map[string]signerRequest
50···127}
128129// RequestSignature enqueues a signing request for did and blocks until one of:
130+// - The signer sends sign_response → returns the signature bytes.
131+// - The signer sends sign_reject → returns helpers.ErrSignerRejected.
132// - The WebSocket disconnects → returns helpers.ErrSignerNotConnected.
133// - ctx is cancelled or times out → returns helpers.ErrSignerTimeout or ctx.Err().
134//
135// Multiple callers for the same DID are all queued and processed sequentially
136+// (the passkey sees one prompt at a time). Callers for different DIDs are
137// independent.
138func (h *SignerHub) RequestSignature(ctx context.Context, did string, requestID string, msg []byte) ([]byte, error) {
139 h.mu.Lock()
···243 return true
244}
245246+// NextRequest blocks until a pending request is ready to be sent to the signer
247+// and no other request is currently in-flight (the passkey handles one prompt
248+// at a time). It moves the request from pending into inflight before returning,
249+// so the caller just needs to write it to the WebSocket.
250//
251// Returns (request, true) on success, or (zero, false) if the connection is
252// going away (done closed or ctx cancelled).
253func (conn *signerConn) NextRequest(ctx context.Context) (signerRequest, bool) {
254 for {
255 conn.mu.Lock()
256+ // Only dequeue when nothing is in-flight (passkey is free).
257 if len(conn.pending) > 0 && len(conn.inflight) == 0 {
258 req := conn.pending[0]
259 conn.pending = conn.pending[1:]