···4> This is highly experimental software. Use with caution, especially during account migration.
56> [!IMPORTANT]
7-> **Vow cannot fully interoperate with the ATProto network (Bluesky, AppViews, relays) in its current form.**
8>
9-> ATProto uses a single DID document key (`verificationMethods.atproto`) for two distinct purposes: signing repo commits _and_ signing service-auth JWTs. A keyless PDS like Vow cannot satisfy both at once:
10>
11-> - **Commit signing** works correctly. The user's passkey signs each write; the signature is stored in the commit and broadcast to relays.
12-> - **Service-auth JWT signing** is broken. Every proxied request (loading feeds, notifications, any AppView call) requires a fresh JWT signed by `verificationMethods.atproto`. Under the current spec that means a passkey gesture per background request — an unacceptable UX — or the PDS signs with a different key and AppViews reject it with `BadJwtSignature`.
13>
14-> There is no correct workaround within the current ATProto specification. A protocol change is required. A formal RFC proposing an optional `#atproto_service` verification method to separate the two signing responsibilities has been drafted [here](https://tangled.org/strings/did:plc:7kpq3n7brenbgyp2gx36hl6x/3mgqmwxzvlu22). Until that or an equivalent change lands upstream, Vow accounts will experience broken feeds, notifications, and proxied reads on standard ATProto clients.
001516Vow is a Go PDS (Personal Data Server) for the AT Protocol.
17···191- **Repo writes** — `createRecord`, `putRecord`, `deleteRecord`, `applyWrites`
192- **Identity operations** — PLC operations and handle updates, **once the user's passkey is the rotation key**. Before that, the PDS rotation key signs them directly.
193194-Read-only operations (browsing feeds, loading profiles, fetching notifications, etc.) do **not** prompt the passkey. Service-auth JWTs for proxied requests (`getServiceAuth`, ATProto proxy) are also signed by the user's passkey via the signer WebSocket — the signer tab must be open for these to succeed.
195196#### WebSocket connection
197···251252### Browser-Based Signer
253254-The signer runs entirely in the PDS account page. No browser extension or extra software is needed. The user keeps the page open (a pinned tab works well) and signing happens automatically via the platform's passkey prompt (biometric, PIN, or security key).
255256## Identity & DID Sovereignty
257···271272### What the user gets
273274-| Property | Before key registration | After key registration |
275-| --------------------------- | -------------------------- | ------------------------------------------------- |
276-| Who signs commits | Nobody (no key registered) | User's passkey |
277-| Who controls the DID | PDS rotation key | User's passkey-derived key |
278-| PDS can hijack identity | Yes | **No** |
279-| User can migrate to new PDS | No (PDS must cooperate) | **Yes** (sign a PLC op to update serviceEndpoint) |
280-| Federation compatibility | Full | Full (unchanged) |
00281282### Verifiability
283···292- **No browser extension required** — passkeys are built into every modern browser and OS.
293- **Hardware-backed security** — the private key lives in a secure enclave (TPM, Secure Enclave, or a roaming authenticator like a YubiKey). It never leaves the device.
294- **Familiar UX** — users authenticate with a fingerprint, face scan, or PIN instead of confirming a cryptographic message in a wallet popup.
295-- **Correct signature format** — the passkey signs with P-256 ECDSA. Commit signatures are stored and broadcast in the raw (r‖s) format ATProto expects. Service-auth JWT signatures, however, cannot be produced in a standards-compatible way by a passkey without a protocol-level change (see the limitation notice at the top of this document).
296297## Management Commands
298
···4> This is highly experimental software. Use with caution, especially during account migration.
56> [!IMPORTANT]
7+> **Vow implements a two-key model for signing that requires a pending ATProto protocol change to be fully interoperable.**
8>
9+> ATProto uses a single DID document key (`verificationMethods.atproto`) for two distinct purposes: signing repo commits _and_ signing service-auth JWTs. Vow splits these into two separate keys:
10>
11+> - **`verificationMethods.atproto`** → the user's passkey. Signs every repo commit. Requires passkey usage, which is acceptable because commits are user-initiated.
12+> - **`verificationMethods.atproto_service`** → the PDS server key. Signs service-auth JWTs for background requests (feed loading, notifications, proxied reads) without ever prompting the passkey.
13>
14+> [did-method-plc#101](https://github.com/did-method-plc/did-method-plc/pull/101) (merged June 2025) relaxed PLC directory constraints so DID documents can carry keys under arbitrary fragment names. Vow already writes both keys into the DID document during `supplySigningKey`.
15+>
16+> **What remains blocked:** AppViews and relays in the reference implementation ([indigo](https://github.com/bluesky-social/indigo)) still call `identity.PublicKey()` → `GetPublicKey("atproto")` when verifying service-auth JWTs, so they will reject tokens signed by the PDS key with `BadJwtSignature`. A spec change adding an `#atproto_service` fallback is required. The RFC is [here](https://github.com/bluesky-social/atproto/discussions/4739). Until it lands in indigo and Bluesky's infrastructure, feeds, notifications, and proxied reads on standard ATProto clients will not work.
1718Vow is a Go PDS (Personal Data Server) for the AT Protocol.
19···193- **Repo writes** — `createRecord`, `putRecord`, `deleteRecord`, `applyWrites`
194- **Identity operations** — PLC operations and handle updates, **once the user's passkey is the rotation key**. Before that, the PDS rotation key signs them directly.
195196+Read-only operations (browsing feeds, loading profiles, fetching notifications, etc.) do **not** prompt the passkey. Service-auth JWTs for proxied requests (`getServiceAuth`, ATProto proxy) are signed by the PDS server key (`#atproto_service`) and require no passkey: the signer tab does not need to be open for these.
197198#### WebSocket connection
199···253254### Browser-Based Signer
255256+The signer runs entirely in the PDS account page. No browser extension or extra software is needed. The user keeps the page open (a pinned tab works well) and signing happens automatically when the passkey is used.
257258## Identity & DID Sovereignty
259···273274### What the user gets
275276+| Property | Before key registration | After key registration |
277+| ------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
278+| Who signs commits | Nobody (no key registered) | User's passkey (`#atproto`) |
279+| Who signs service-auth JWTs | PDS server key | PDS server key (`#atproto_service`) |
280+| Who controls the DID | PDS rotation key | User's passkey-derived key |
281+| PDS can hijack identity | Yes | **No** |
282+| User can migrate to new PDS | Yes (If rotation key was set) & No (PDS must cooperate) | **Yes** (sign a PLC op to update serviceEndpoint) |
283+| Commit federation compatibility | Full | Full (unchanged) |
284+| Service-auth federation compat. | Full | Partial — requires [`#atproto_service` RFC](https://github.com/bluesky-social/atproto/discussions/4739) to land in AppViews |
285286### Verifiability
287···296- **No browser extension required** — passkeys are built into every modern browser and OS.
297- **Hardware-backed security** — the private key lives in a secure enclave (TPM, Secure Enclave, or a roaming authenticator like a YubiKey). It never leaves the device.
298- **Familiar UX** — users authenticate with a fingerprint, face scan, or PIN instead of confirming a cryptographic message in a wallet popup.
299+- **Correct signature format** — the passkey signs repo commits with P-256 ECDSA in the raw (r‖s) format ATProto expects. Service-auth JWTs are signed by the PDS server key (`#atproto_service`) so they are standard ES256 and require no passkey. See the limitation notice at the top of this document for the remaining interoperability gap.
300301## Management Commands
302
···3031 // Start with the PDS-generated credentials (verification methods, services,
32 // alsoKnownAs). These are always correct regardless of rotation key state.
0033 creds, err := s.plcClient.CreateDidCredentialsFromPublicKey(pubKey, "", repo.Handle)
34 if err != nil {
35 logger.Error("error creating did credentials", "error", err)
36 helpers.ServerError(w, nil)
37 return
38 }
00000000000003940 // If this is a did:plc identity, fetch the actual rotation keys from the
41 // current PLC document. After supplySigningKey transfers the rotation key
···3031 // Start with the PDS-generated credentials (verification methods, services,
32 // alsoKnownAs). These are always correct regardless of rotation key state.
33+ // CreateDidCredentialsFromPublicKey sets verificationMethods["atproto"] to
34+ // the passkey's did:key (commit signing).
35 creds, err := s.plcClient.CreateDidCredentialsFromPublicKey(pubKey, "", repo.Handle)
36 if err != nil {
37 logger.Error("error creating did credentials", "error", err)
38 helpers.ServerError(w, nil)
39 return
40 }
41+42+ // Add the PDS server key as the atproto_service verification method.
43+ // Service-auth JWTs are signed by this key so background requests (feed
44+ // loading, notifications, proxied reads) never require a passkey.
45+ // AppViews that implement the atproto_service RFC will verify tokens against
46+ // this key; others fall back to #atproto (known limitation until spec lands).
47+ pdsDIDKey, err := s.pdsDIDKey()
48+ if err != nil {
49+ logger.Error("error deriving PDS did:key for atproto_service", "error", err)
50+ helpers.ServerError(w, nil)
51+ return
52+ }
53+ creds.VerificationMethods["atproto_service"] = pdsDIDKey
5455 // If this is a did:plc identity, fetch the actual rotation keys from the
56 // current PLC document. After supplySigningKey transfers the rotation key
+46-73
server/handle_server_get_service_auth.go
···23import (
4 "context"
5- "crypto/sha256"
6- "encoding/base64"
7- "encoding/json"
8 "fmt"
9 "net/http"
10- "strings"
11 "time"
120013 "github.com/google/uuid"
14 "pkg.rbrt.fr/vow/internal/helpers"
15 "pkg.rbrt.fr/vow/models"
···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.
86//
87-// The returned string is a fully formed "header.payload.signature" JWT ready to
88-// be placed in an Authorization: Bearer header.
000089//
90// lxm may be empty, in which case no "lxm" claim is included.
91func (s *Server) signServiceAuthJWT(
···95 lxm string,
96 exp int64,
97) (string, error) {
98- if len(repo.PublicKey) == 0 {
99- return "", fmt.Errorf("no public key registered for account %s", repo.Repo.Did)
100- }
101102 did := repo.Repo.Did
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)
111- if err != nil {
112- return "", fmt.Errorf("marshaling JWT header: %w", err)
113- }
114- encHeader := strings.TrimRight(base64.RawURLEncoding.EncodeToString(hj), "=")
115-116 now := time.Now().Unix()
117- var expiresAt time.Time
118 if exp == 0 {
119- expiresAt = time.Now().Add(5 * time.Minute)
120- exp = expiresAt.Unix()
121- } else {
122- expiresAt = time.Unix(exp, 0)
123 }
124125- claims := map[string]any{
126 "iss": did,
127 "aud": aud,
128 "jti": uuid.NewString(),
···133 claims["lxm"] = lxm
134 }
135136- pj, err := json.Marshal(claims)
137- if err != nil {
138- return "", fmt.Errorf("marshaling JWT payload: %w", err)
139- }
140- encPayload := strings.TrimRight(base64.RawURLEncoding.EncodeToString(pj), "=")
141142- // signingInput is what the JWT spec calls the "message to be signed":
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.
151- hash := sha256.Sum256([]byte(signingInput))
152- payloadB64 := base64.RawURLEncoding.EncodeToString(hash[:])
153154- requestID := uuid.NewString()
155- signerDeadline := time.Now().Add(signerRequestTimeout)
156157- ops := []PendingWriteOp{
158- {
159- Type: "service_auth",
160- Collection: aud,
161- Rkey: lxm,
162- },
163 }
164-165- msgBytes, err := buildSignRequestMsg(requestID, did, payloadB64, ops, signerDeadline)
166 if err != nil {
167- return "", fmt.Errorf("building sign request message: %w", err)
168 }
00169170- signCtx, cancel := context.WithDeadline(ctx, signerDeadline)
171- defer cancel()
172-173- sigBytes, err := s.signerHub.RequestSignature(signCtx, did, requestID, msgBytes)
174 if err != nil {
175- return "", err
176 }
000000177178- // 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]
00183 }
184- if len(sigBytes) != 64 {
185- return "", fmt.Errorf("unexpected signature length %d (want 64)", len(sigBytes))
186- }
187-188- encSig := strings.TrimRight(base64.RawURLEncoding.EncodeToString(sigBytes), "=")
189- token := signingInput + "." + encSig
190-191- return token, nil
192}
···23import (
4 "context"
0005 "fmt"
6 "net/http"
07 "time"
89+ "github.com/bluesky-social/indigo/atproto/atcrypto"
10+ "github.com/golang-jwt/jwt/v4"
11 "github.com/google/uuid"
12 "pkg.rbrt.fr/vow/internal/helpers"
13 "pkg.rbrt.fr/vow/models"
···79}
8081// signServiceAuthJWT returns a signed ES256 service-auth JWT for the given
82+// (aud, lxm) pair.
083//
84+// Service-auth JWTs are signed by the PDS server key stored in the atproto_service
85+// slot of the user's DID document. This is a standard ES256 signature over
86+// SHA-256(header.payload), which external AppViews and relays can verify without
87+// any passkey interaction. The passkey (atproto slot) is reserved for repo commit
88+// signing only — operations that are user-initiated and can tolerate passkey usage.
89+// Background infrastructure requests like feed loading must not require one.
90//
91// lxm may be empty, in which case no "lxm" claim is included.
92func (s *Server) signServiceAuthJWT(
···96 lxm string,
97 exp int64,
98) (string, error) {
00099100 did := repo.Repo.Did
101000000000000102 now := time.Now().Unix()
0103 if exp == 0 {
104+ exp = now + int64(5*time.Minute/time.Second)
000105 }
106107+ claims := jwt.MapClaims{
108 "iss": did,
109 "aud": aud,
110 "jti": uuid.NewString(),
···115 claims["lxm"] = lxm
116 }
117118+ // Register a custom ES256 signing method that delegates to atcrypto so the
119+ // signature is always low-S normalised, as the ATProto spec requires.
120+ token := jwt.NewWithClaims(newES256AtpSigningMethod(), claims)
121+ return token.SignedString(s.privateKeyATP)
122+}
123124+// es256AtpSigningMethod is a jwt.SigningMethod that uses atcrypto.PrivateKeyP256
125+// to produce low-S normalised ES256 signatures, satisfying the ATProto spec.
126+type es256AtpSigningMethod struct{}
127128+func newES256AtpSigningMethod() *es256AtpSigningMethod { return &es256AtpSigningMethod{} }
000000129130+func (m *es256AtpSigningMethod) Alg() string { return "ES256" }
0131132+func (m *es256AtpSigningMethod) Sign(signingString string, key any) (string, error) {
133+ priv, ok := key.(*atcrypto.PrivateKeyP256)
134+ if !ok {
135+ return "", fmt.Errorf("es256AtpSigningMethod: expected *atcrypto.PrivateKeyP256, got %T", key)
00136 }
137+ sig, err := priv.HashAndSign([]byte(signingString))
0138 if err != nil {
139+ return "", fmt.Errorf("es256AtpSigningMethod: signing failed: %w", err)
140 }
141+ return jwt.EncodeSegment(sig), nil //nolint:staticcheck
142+}
143144+func (m *es256AtpSigningMethod) Verify(signingString string, signature string, key any) error {
145+ sigBytes, err := jwt.DecodeSegment(signature) //nolint:staticcheck
00146 if err != nil {
147+ return err
148 }
149+ pub, ok := key.(atcrypto.PublicKey)
150+ if !ok {
151+ return fmt.Errorf("es256AtpSigningMethod: expected atcrypto.PublicKey, got %T", key)
152+ }
153+ return pub.HashAndVerifyLenient([]byte(signingString), sigBytes)
154+}
155156+// pdsDIDKey returns the PDS server's P-256 public key encoded as a did:key
157+// string. This is what gets written into verificationMethods["atproto_service"]
158+// of the user's DID document during supplySigningKey.
159+func (s *Server) pdsDIDKey() (string, error) {
160+ pub, err := s.privateKeyATP.PublicKey()
161+ if err != nil {
162+ return "", fmt.Errorf("getting PDS public key: %w", err)
163 }
164+ return pub.DIDKey(), nil
0000000165}
+39-6
server/handle_server_supply_signing_key.go
···3031type SupplySigningKeyResponse struct {
32 Did string `json:"did"`
33- PublicKey string `json:"publicKey"` // did:key representation
34- CredentialID string `json:"credentialId"` // base64url
035}
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.
40//
41-// On success, the account's PLC DID document is updated so that the passkey's
42-// did:key becomes the active atproto verification method and rotation key.
000000000000000043func (s *Server) handleSupplySigningKey(w http.ResponseWriter, r *http.Request) {
44 ctx := r.Context()
45 logger := s.logger.With("name", "handleSupplySigningKey")
···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.
000000087 if strings.HasPrefix(repo.Repo.Did, "did:plc:") {
88 log, err := identity.FetchDidAuditLog(ctx, nil, repo.Repo.Did)
89 if err != nil {
···9697 newVerificationMethods := make(map[string]string)
98 maps.Copy(newVerificationMethods, latest.Operation.VerificationMethods)
0099 newVerificationMethods["atproto"] = pubDIDKey
00000100101 // 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
···146 logger.Info("passkey registered — rotation key transferred to user",
147 "did", repo.Repo.Did,
148 "publicKey", pubDIDKey,
0149 "credentialIDLen", len(credentialID),
150 )
151152 s.writeJSON(w, 200, SupplySigningKeyResponse{
153 Did: repo.Repo.Did,
154 PublicKey: pubDIDKey,
0155 CredentialID: base64.RawURLEncoding.EncodeToString(credentialID),
156 })
157}
···3031type SupplySigningKeyResponse struct {
32 Did string `json:"did"`
33+ PublicKey string `json:"publicKey"` // did:key for atproto (commit signing, passkey)
34+ ServiceKey string `json:"serviceKey"` // did:key for atproto_service (service-auth, PDS server key)
35+ CredentialID string `json:"credentialId"` // base64url credential ID
36}
3738// handleSupplySigningKey registers a WebAuthn passkey for the authenticated
39// account. The private key never leaves the authenticator; the PDS stores only
40// the compressed P-256 public key and the credential ID.
41//
42+// On success the account's PLC DID document is updated with two changes:
43+//
44+// 1. verificationMethods["atproto"] = passkey did:key
45+// The passkey becomes the commit-signing key. Every repo write requires a
46+// user-presence gesture from this point on.
47+//
48+// 2. verificationMethods["atproto_service"] = PDS server did:key
49+// The PDS server key is registered for service-auth JWT signing. This lets
50+// the PDS issue service-auth tokens for background requests (feed loading,
51+// notifications, proxied reads) without prompting the passkey each time.
52+// AppViews that implement the atproto_service fallback (per the RFC at
53+// https://tangled.org/strings/did:plc:7kpq3n7brenbgyp2gx36hl6x/3mgqmwxzvlu22)
54+// will accept these tokens. Older verifiers fall back to #atproto and will
55+// reject them — that is the known limitation until the spec change lands.
56+//
57+// 3. rotationKeys = [passkey did:key]
58+// The PDS rotation key is removed. Only the user's passkey can authorise
59+// future PLC operations.
60func (s *Server) handleSupplySigningKey(w http.ResponseWriter, r *http.Request) {
61 ctx := r.Context()
62 logger := s.logger.With("name", "handleSupplySigningKey")
···99100 pubDIDKey := pubKey.DIDKey()
101102+ // Derive the PDS server did:key for the atproto_service slot.
103+ pdsDIDKey, err := s.pdsDIDKey()
104+ if err != nil {
105+ logger.Error("error deriving PDS did:key", "error", err)
106+ helpers.ServerError(w, nil)
107+ return
108+ }
109+110+ // Update the PLC DID document with the two-key structure.
111 if strings.HasPrefix(repo.Repo.Did, "did:plc:") {
112 log, err := identity.FetchDidAuditLog(ctx, nil, repo.Repo.Did)
113 if err != nil {
···120121 newVerificationMethods := make(map[string]string)
122 maps.Copy(newVerificationMethods, latest.Operation.VerificationMethods)
123+ // Commit-signing key: the user's passkey. Every repo write requires
124+ // passkey usage.
125 newVerificationMethods["atproto"] = pubDIDKey
126+ // Service-auth signing key: the PDS server key. Used for background
127+ // infrastructure requests that must not require a passkey.
128+ // Verifiers that implement the atproto_service RFC will accept tokens
129+ // signed by this key; others fall back to #atproto (known limitation).
130+ newVerificationMethods["atproto_service"] = pdsDIDKey
131132 // Replace the PDS rotation key with the passkey's did:key. After this
133 // operation the PDS can no longer unilaterally modify the DID document
···177 logger.Info("passkey registered — rotation key transferred to user",
178 "did", repo.Repo.Did,
179 "publicKey", pubDIDKey,
180+ "serviceKey", pdsDIDKey,
181 "credentialIDLen", len(credentialID),
182 )
183184 s.writeJSON(w, 200, SupplySigningKeyResponse{
185 Did: repo.Repo.Did,
186 PublicKey: pubDIDKey,
187+ ServiceKey: pdsDIDKey,
188 CredentialID: base64.RawURLEncoding.EncodeToString(credentialID),
189 })
190}
+19-75
server/middleware.go
···23import (
4 "context"
5- "encoding/base64"
6 "errors"
7 "fmt"
8 "net/http"
9 "strings"
10 "time"
1112- "github.com/bluesky-social/indigo/atproto/atcrypto"
13 "github.com/golang-jwt/jwt/v4"
14 "gorm.io/gorm"
15 "pkg.rbrt.fr/vow/internal/helpers"
···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)
172- helpers.ExpiredTokenError(w)
173- return
174- }
175-176- if !token.Valid {
177- helpers.InvalidTokenError(w)
178- return
179- }
180- } else {
181- kpts := strings.Split(tokenstr, ".")
182- signingInput := kpts[0] + "." + kpts[1]
183- sigBytes, err := base64.RawURLEncoding.DecodeString(kpts[2])
184- if err != nil {
185- logger.Error("error decoding signature bytes", "error", err)
186- helpers.ServerError(w, nil)
187- return
188- }
189-190- if len(sigBytes) != 64 {
191- logger.Error("incorrect sigbytes length", "length", len(sigBytes))
192- helpers.ServerError(w, nil)
193- return
194- }
195-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- }
209- repo = maybeRepo
210- did = sub
211- }
212-213- // The PDS never holds the user's private key. Verify the JWT
214- // signature using the compressed P-256 public key stored in the DB.
215- if len(repo.PublicKey) == 0 {
216- logger.Error("no public key registered for account", "did", repo.Repo.Did)
217- helpers.ServerError(w, nil)
218- return
219- }
220-221- pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey)
222- if err != nil {
223- logger.Error("can't parse stored public key", "error", err)
224- helpers.ServerError(w, nil)
225- return
226 }
0000000227228- 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- }
233 }
234235 isRefresh := r.URL.Path == "/xrpc/com.atproto.server.refreshSession"
···23import (
4 "context"
05 "errors"
6 "fmt"
7 "net/http"
8 "strings"
9 "time"
10011 "github.com/golang-jwt/jwt/v4"
12 "gorm.io/gorm"
13 "pkg.rbrt.fr/vow/internal/helpers"
···153 repo = maybeRepo
154 }
155156+ // All ES256 tokens issued by this PDS — both regular access/refresh
157+ // tokens and service-auth tokens (lxm claim) — are signed by the PDS
158+ // server key. Service-auth tokens were previously routed through the
159+ // passkey WebSocket, but since the atproto_service split-key model was
160+ // adopted (see RFC), they are now signed server-side so that background
161+ // requests never require passkey usage.
162+ token, err = new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) {
163+ if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
164+ return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"])
00000000000000000000000000000000000000000000000000000000000165 }
166+ return &s.privateKey.PublicKey, nil
167+ })
168+ if err != nil {
169+ logger.Error("error parsing jwt", "error", err)
170+ helpers.ExpiredTokenError(w)
171+ return
172+ }
173174+ if !token.Valid {
175+ helpers.InvalidTokenError(w)
176+ return
00177 }
178179 isRefresh := r.URL.Path == "/xrpc/com.atproto.server.refreshSession"