···44> This is highly experimental software. Use with caution, especially during account migration.
5566> [!IMPORTANT]
77-> **Vow cannot fully interoperate with the ATProto network (Bluesky, AppViews, relays) in its current form.**
77+> **Vow implements a two-key model for signing that requires a pending ATProto protocol change to be fully interoperable.**
88>
99-> 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:
99+> 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:
1010>
1111-> - **Commit signing** works correctly. The user's passkey signs each write; the signature is stored in the commit and broadcast to relays.
1212-> - **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`.
1111+> - **`verificationMethods.atproto`** → the user's passkey. Signs every repo commit. Requires passkey usage, which is acceptable because commits are user-initiated.
1212+> - **`verificationMethods.atproto_service`** → the PDS server key. Signs service-auth JWTs for background requests (feed loading, notifications, proxied reads) without ever prompting the passkey.
1313>
1414-> 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.
1414+> [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`.
1515+>
1616+> **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.
15171618Vow is a Go PDS (Personal Data Server) for the AT Protocol.
1719···191193- **Repo writes** — `createRecord`, `putRecord`, `deleteRecord`, `applyWrites`
192194- **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.
193195194194-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.
196196+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.
195197196198#### WebSocket connection
197199···251253252254### Browser-Based Signer
253255254254-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).
256256+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.
255257256258## Identity & DID Sovereignty
257259···271273272274### What the user gets
273275274274-| Property | Before key registration | After key registration |
275275-| --------------------------- | -------------------------- | ------------------------------------------------- |
276276-| Who signs commits | Nobody (no key registered) | User's passkey |
277277-| Who controls the DID | PDS rotation key | User's passkey-derived key |
278278-| PDS can hijack identity | Yes | **No** |
279279-| User can migrate to new PDS | No (PDS must cooperate) | **Yes** (sign a PLC op to update serviceEndpoint) |
280280-| Federation compatibility | Full | Full (unchanged) |
276276+| Property | Before key registration | After key registration |
277277+| ------------------------------- | ------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
278278+| Who signs commits | Nobody (no key registered) | User's passkey (`#atproto`) |
279279+| Who signs service-auth JWTs | PDS server key | PDS server key (`#atproto_service`) |
280280+| Who controls the DID | PDS rotation key | User's passkey-derived key |
281281+| PDS can hijack identity | Yes | **No** |
282282+| User can migrate to new PDS | Yes (If rotation key was set) & No (PDS must cooperate) | **Yes** (sign a PLC op to update serviceEndpoint) |
283283+| Commit federation compatibility | Full | Full (unchanged) |
284284+| Service-auth federation compat. | Full | Partial — requires [`#atproto_service` RFC](https://github.com/bluesky-social/atproto/discussions/4739) to land in AppViews |
281285282286### Verifiability
283287···292296- **No browser extension required** — passkeys are built into every modern browser and OS.
293297- **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.
294298- **Familiar UX** — users authenticate with a fingerprint, face scan, or PIN instead of confirming a cryptographic message in a wallet popup.
295295-- **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).
299299+- **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.
296300297301## Management Commands
298302
···30303131 // Start with the PDS-generated credentials (verification methods, services,
3232 // alsoKnownAs). These are always correct regardless of rotation key state.
3333+ // CreateDidCredentialsFromPublicKey sets verificationMethods["atproto"] to
3434+ // the passkey's did:key (commit signing).
3335 creds, err := s.plcClient.CreateDidCredentialsFromPublicKey(pubKey, "", repo.Handle)
3436 if err != nil {
3537 logger.Error("error creating did credentials", "error", err)
3638 helpers.ServerError(w, nil)
3739 return
3840 }
4141+4242+ // Add the PDS server key as the atproto_service verification method.
4343+ // Service-auth JWTs are signed by this key so background requests (feed
4444+ // loading, notifications, proxied reads) never require a passkey.
4545+ // AppViews that implement the atproto_service RFC will verify tokens against
4646+ // this key; others fall back to #atproto (known limitation until spec lands).
4747+ pdsDIDKey, err := s.pdsDIDKey()
4848+ if err != nil {
4949+ logger.Error("error deriving PDS did:key for atproto_service", "error", err)
5050+ helpers.ServerError(w, nil)
5151+ return
5252+ }
5353+ creds.VerificationMethods["atproto_service"] = pdsDIDKey
39544055 // If this is a did:plc identity, fetch the actual rotation keys from the
4156 // current PLC document. After supplySigningKey transfers the rotation key
+46-73
server/handle_server_get_service_auth.go
···2233import (
44 "context"
55- "crypto/sha256"
66- "encoding/base64"
77- "encoding/json"
85 "fmt"
96 "net/http"
1010- "strings"
117 "time"
12899+ "github.com/bluesky-social/indigo/atproto/atcrypto"
1010+ "github.com/golang-jwt/jwt/v4"
1311 "github.com/google/uuid"
1412 "pkg.rbrt.fr/vow/internal/helpers"
1513 "pkg.rbrt.fr/vow/models"
···8179}
82808381// 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.
8282+// (aud, lxm) pair.
8683//
8787-// The returned string is a fully formed "header.payload.signature" JWT ready to
8888-// be placed in an Authorization: Bearer header.
8484+// Service-auth JWTs are signed by the PDS server key stored in the atproto_service
8585+// slot of the user's DID document. This is a standard ES256 signature over
8686+// SHA-256(header.payload), which external AppViews and relays can verify without
8787+// any passkey interaction. The passkey (atproto slot) is reserved for repo commit
8888+// signing only — operations that are user-initiated and can tolerate passkey usage.
8989+// Background infrastructure requests like feed loading must not require one.
8990//
9091// lxm may be empty, in which case no "lxm" claim is included.
9192func (s *Server) signServiceAuthJWT(
···9596 lxm string,
9697 exp int64,
9798) (string, error) {
9898- if len(repo.PublicKey) == 0 {
9999- return "", fmt.Errorf("no public key registered for account %s", repo.Repo.Did)
100100- }
10199102100 did := repo.Repo.Did
103101104104- // ── Build header + payload ────────────────────────────────────────────
105105- header := map[string]string{
106106- "alg": "ES256",
107107- "crv": "P-256",
108108- "typ": "JWT",
109109- }
110110- hj, err := json.Marshal(header)
111111- if err != nil {
112112- return "", fmt.Errorf("marshaling JWT header: %w", err)
113113- }
114114- encHeader := strings.TrimRight(base64.RawURLEncoding.EncodeToString(hj), "=")
115115-116102 now := time.Now().Unix()
117117- var expiresAt time.Time
118103 if exp == 0 {
119119- expiresAt = time.Now().Add(5 * time.Minute)
120120- exp = expiresAt.Unix()
121121- } else {
122122- expiresAt = time.Unix(exp, 0)
104104+ exp = now + int64(5*time.Minute/time.Second)
123105 }
124106125125- claims := map[string]any{
107107+ claims := jwt.MapClaims{
126108 "iss": did,
127109 "aud": aud,
128110 "jti": uuid.NewString(),
···133115 claims["lxm"] = lxm
134116 }
135117136136- pj, err := json.Marshal(claims)
137137- if err != nil {
138138- return "", fmt.Errorf("marshaling JWT payload: %w", err)
139139- }
140140- encPayload := strings.TrimRight(base64.RawURLEncoding.EncodeToString(pj), "=")
118118+ // Register a custom ES256 signing method that delegates to atcrypto so the
119119+ // signature is always low-S normalised, as the ATProto spec requires.
120120+ token := jwt.NewWithClaims(newES256AtpSigningMethod(), claims)
121121+ return token.SignedString(s.privateKeyATP)
122122+}
141123142142- // signingInput is what the JWT spec calls the "message to be signed":
143143- // base64url(header) + "." + base64url(payload).
144144- signingInput := encHeader + "." + encPayload
124124+// es256AtpSigningMethod is a jwt.SigningMethod that uses atcrypto.PrivateKeyP256
125125+// to produce low-S normalised ES256 signatures, satisfying the ATProto spec.
126126+type es256AtpSigningMethod struct{}
145127146146- // 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.
151151- hash := sha256.Sum256([]byte(signingInput))
152152- payloadB64 := base64.RawURLEncoding.EncodeToString(hash[:])
128128+func newES256AtpSigningMethod() *es256AtpSigningMethod { return &es256AtpSigningMethod{} }
153129154154- requestID := uuid.NewString()
155155- signerDeadline := time.Now().Add(signerRequestTimeout)
130130+func (m *es256AtpSigningMethod) Alg() string { return "ES256" }
156131157157- ops := []PendingWriteOp{
158158- {
159159- Type: "service_auth",
160160- Collection: aud,
161161- Rkey: lxm,
162162- },
132132+func (m *es256AtpSigningMethod) Sign(signingString string, key any) (string, error) {
133133+ priv, ok := key.(*atcrypto.PrivateKeyP256)
134134+ if !ok {
135135+ return "", fmt.Errorf("es256AtpSigningMethod: expected *atcrypto.PrivateKeyP256, got %T", key)
163136 }
164164-165165- msgBytes, err := buildSignRequestMsg(requestID, did, payloadB64, ops, signerDeadline)
137137+ sig, err := priv.HashAndSign([]byte(signingString))
166138 if err != nil {
167167- return "", fmt.Errorf("building sign request message: %w", err)
139139+ return "", fmt.Errorf("es256AtpSigningMethod: signing failed: %w", err)
168140 }
141141+ return jwt.EncodeSegment(sig), nil //nolint:staticcheck
142142+}
169143170170- signCtx, cancel := context.WithDeadline(ctx, signerDeadline)
171171- defer cancel()
172172-173173- sigBytes, err := s.signerHub.RequestSignature(signCtx, did, requestID, msgBytes)
144144+func (m *es256AtpSigningMethod) Verify(signingString string, signature string, key any) error {
145145+ sigBytes, err := jwt.DecodeSegment(signature) //nolint:staticcheck
174146 if err != nil {
175175- return "", err
147147+ return err
176148 }
149149+ pub, ok := key.(atcrypto.PublicKey)
150150+ if !ok {
151151+ return fmt.Errorf("es256AtpSigningMethod: expected atcrypto.PublicKey, got %T", key)
152152+ }
153153+ return pub.HashAndVerifyLenient([]byte(signingString), sigBytes)
154154+}
177155178178- // 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.
181181- if len(sigBytes) == 65 {
182182- sigBytes = sigBytes[:64]
156156+// pdsDIDKey returns the PDS server's P-256 public key encoded as a did:key
157157+// string. This is what gets written into verificationMethods["atproto_service"]
158158+// of the user's DID document during supplySigningKey.
159159+func (s *Server) pdsDIDKey() (string, error) {
160160+ pub, err := s.privateKeyATP.PublicKey()
161161+ if err != nil {
162162+ return "", fmt.Errorf("getting PDS public key: %w", err)
183163 }
184184- if len(sigBytes) != 64 {
185185- return "", fmt.Errorf("unexpected signature length %d (want 64)", len(sigBytes))
186186- }
187187-188188- encSig := strings.TrimRight(base64.RawURLEncoding.EncodeToString(sigBytes), "=")
189189- token := signingInput + "." + encSig
190190-191191- return token, nil
164164+ return pub.DIDKey(), nil
192165}
+39-6
server/handle_server_supply_signing_key.go
···30303131type SupplySigningKeyResponse struct {
3232 Did string `json:"did"`
3333- PublicKey string `json:"publicKey"` // did:key representation
3434- CredentialID string `json:"credentialId"` // base64url
3333+ PublicKey string `json:"publicKey"` // did:key for atproto (commit signing, passkey)
3434+ ServiceKey string `json:"serviceKey"` // did:key for atproto_service (service-auth, PDS server key)
3535+ CredentialID string `json:"credentialId"` // base64url credential ID
3536}
36373738// handleSupplySigningKey registers a WebAuthn passkey for the authenticated
3839// account. The private key never leaves the authenticator; the PDS stores only
3940// the compressed P-256 public key and the credential ID.
4041//
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.
4242+// On success the account's PLC DID document is updated with two changes:
4343+//
4444+// 1. verificationMethods["atproto"] = passkey did:key
4545+// The passkey becomes the commit-signing key. Every repo write requires a
4646+// user-presence gesture from this point on.
4747+//
4848+// 2. verificationMethods["atproto_service"] = PDS server did:key
4949+// The PDS server key is registered for service-auth JWT signing. This lets
5050+// the PDS issue service-auth tokens for background requests (feed loading,
5151+// notifications, proxied reads) without prompting the passkey each time.
5252+// AppViews that implement the atproto_service fallback (per the RFC at
5353+// https://tangled.org/strings/did:plc:7kpq3n7brenbgyp2gx36hl6x/3mgqmwxzvlu22)
5454+// will accept these tokens. Older verifiers fall back to #atproto and will
5555+// reject them — that is the known limitation until the spec change lands.
5656+//
5757+// 3. rotationKeys = [passkey did:key]
5858+// The PDS rotation key is removed. Only the user's passkey can authorise
5959+// future PLC operations.
4360func (s *Server) handleSupplySigningKey(w http.ResponseWriter, r *http.Request) {
4461 ctx := r.Context()
4562 logger := s.logger.With("name", "handleSupplySigningKey")
···829983100 pubDIDKey := pubKey.DIDKey()
841018585- // Update the PLC DID document so the passkey's did:key becomes the active
8686- // atproto verification method and the sole rotation key.
102102+ // Derive the PDS server did:key for the atproto_service slot.
103103+ pdsDIDKey, err := s.pdsDIDKey()
104104+ if err != nil {
105105+ logger.Error("error deriving PDS did:key", "error", err)
106106+ helpers.ServerError(w, nil)
107107+ return
108108+ }
109109+110110+ // Update the PLC DID document with the two-key structure.
87111 if strings.HasPrefix(repo.Repo.Did, "did:plc:") {
88112 log, err := identity.FetchDidAuditLog(ctx, nil, repo.Repo.Did)
89113 if err != nil {
···9612097121 newVerificationMethods := make(map[string]string)
98122 maps.Copy(newVerificationMethods, latest.Operation.VerificationMethods)
123123+ // Commit-signing key: the user's passkey. Every repo write requires
124124+ // passkey usage.
99125 newVerificationMethods["atproto"] = pubDIDKey
126126+ // Service-auth signing key: the PDS server key. Used for background
127127+ // infrastructure requests that must not require a passkey.
128128+ // Verifiers that implement the atproto_service RFC will accept tokens
129129+ // signed by this key; others fall back to #atproto (known limitation).
130130+ newVerificationMethods["atproto_service"] = pdsDIDKey
100131101132 // Replace the PDS rotation key with the passkey's did:key. After this
102133 // operation the PDS can no longer unilaterally modify the DID document
···146177 logger.Info("passkey registered — rotation key transferred to user",
147178 "did", repo.Repo.Did,
148179 "publicKey", pubDIDKey,
180180+ "serviceKey", pdsDIDKey,
149181 "credentialIDLen", len(credentialID),
150182 )
151183152184 s.writeJSON(w, 200, SupplySigningKeyResponse{
153185 Did: repo.Repo.Did,
154186 PublicKey: pubDIDKey,
187187+ ServiceKey: pdsDIDKey,
155188 CredentialID: base64.RawURLEncoding.EncodeToString(credentialID),
156189 })
157190}
+19-75
server/middleware.go
···2233import (
44 "context"
55- "encoding/base64"
65 "errors"
76 "fmt"
87 "net/http"
98 "strings"
109 "time"
11101212- "github.com/bluesky-social/indigo/atproto/atcrypto"
1311 "github.com/golang-jwt/jwt/v4"
1412 "gorm.io/gorm"
1513 "pkg.rbrt.fr/vow/internal/helpers"
···155153 repo = maybeRepo
156154 }
157155158158- // 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 {
164164- token, err = new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) {
165165- if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
166166- return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"])
167167- }
168168- return &s.privateKey.PublicKey, nil
169169- })
170170- if err != nil {
171171- logger.Error("error parsing jwt", "error", err)
172172- helpers.ExpiredTokenError(w)
173173- return
174174- }
175175-176176- if !token.Valid {
177177- helpers.InvalidTokenError(w)
178178- return
179179- }
180180- } else {
181181- kpts := strings.Split(tokenstr, ".")
182182- signingInput := kpts[0] + "." + kpts[1]
183183- sigBytes, err := base64.RawURLEncoding.DecodeString(kpts[2])
184184- if err != nil {
185185- logger.Error("error decoding signature bytes", "error", err)
186186- helpers.ServerError(w, nil)
187187- return
188188- }
189189-190190- if len(sigBytes) != 64 {
191191- logger.Error("incorrect sigbytes length", "length", len(sigBytes))
192192- helpers.ServerError(w, nil)
193193- return
194194- }
195195-196196- if repo == nil {
197197- sub, ok := claims["sub"].(string)
198198- if !ok {
199199- s.logger.Error("no sub claim in user-signed token and repo not set")
200200- helpers.InvalidTokenError(w)
201201- return
202202- }
203203- maybeRepo, err := s.getRepoActorByDid(ctx, sub)
204204- if err != nil {
205205- s.logger.Error("error fetching repo for user-signed token verification", "error", err)
206206- helpers.ServerError(w, nil)
207207- return
208208- }
209209- repo = maybeRepo
210210- did = sub
211211- }
212212-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.
215215- if len(repo.PublicKey) == 0 {
216216- logger.Error("no public key registered for account", "did", repo.Repo.Did)
217217- helpers.ServerError(w, nil)
218218- return
219219- }
220220-221221- pubKey, err := atcrypto.ParsePublicBytesP256(repo.PublicKey)
222222- if err != nil {
223223- logger.Error("can't parse stored public key", "error", err)
224224- helpers.ServerError(w, nil)
225225- return
156156+ // All ES256 tokens issued by this PDS — both regular access/refresh
157157+ // tokens and service-auth tokens (lxm claim) — are signed by the PDS
158158+ // server key. Service-auth tokens were previously routed through the
159159+ // passkey WebSocket, but since the atproto_service split-key model was
160160+ // adopted (see RFC), they are now signed server-side so that background
161161+ // requests never require passkey usage.
162162+ token, err = new(jwt.Parser).Parse(tokenstr, func(t *jwt.Token) (any, error) {
163163+ if _, ok := t.Method.(*jwt.SigningMethodECDSA); !ok {
164164+ return nil, fmt.Errorf("unsupported signing method: %v", t.Header["alg"])
226165 }
166166+ return &s.privateKey.PublicKey, nil
167167+ })
168168+ if err != nil {
169169+ logger.Error("error parsing jwt", "error", err)
170170+ helpers.ExpiredTokenError(w)
171171+ return
172172+ }
227173228228- if err := pubKey.HashAndVerifyLenient([]byte(signingInput), sigBytes); err != nil {
229229- logger.Error("user-signed JWT verification failed", "error", err)
230230- helpers.ServerError(w, nil)
231231- return
232232- }
174174+ if !token.Valid {
175175+ helpers.InvalidTokenError(w)
176176+ return
233177 }
234178235179 isRefresh := r.URL.Path == "/xrpc/com.atproto.server.refreshSession"
+34-19
server/server.go
···1818 "time"
19192020 "github.com/bluesky-social/indigo/api/atproto"
2121+ "github.com/bluesky-social/indigo/atproto/atcrypto"
2122 "github.com/bluesky-social/indigo/atproto/syntax"
2223 "github.com/bluesky-social/indigo/events"
2324 "github.com/bluesky-social/indigo/util"
···6364}
64656566type Server struct {
6666- http *http.Client
6767- httpd *http.Server
6868- mail *mailyak.MailYak
6969- mailLk *sync.Mutex
7070- router *chi.Mux
7171- db *db.DB
7272- plcClient *plc.Client
7373- logger *slog.Logger
7474- config *config
7575- privateKey *ecdsa.PrivateKey
6767+ http *http.Client
6868+ httpd *http.Server
6969+ mail *mailyak.MailYak
7070+ mailLk *sync.Mutex
7171+ router *chi.Mux
7272+ db *db.DB
7373+ plcClient *plc.Client
7474+ logger *slog.Logger
7575+ config *config
7676+ privateKey *ecdsa.PrivateKey
7777+ // privateKeyATP is the same JWK key wrapped as an atcrypto.PrivateKeyP256
7878+ // so it can be passed directly to atcrypto signing functions. Used to sign
7979+ // service-auth JWTs on behalf of the user (atproto_service slot in DID doc).
8080+ privateKeyATP *atcrypto.PrivateKeyP256
7681 repoman *RepoMan
7782 oauthProvider *provider.Provider
7883 evtman *events.EventManager
···377382378383 cookieStore := sessions.NewCookieStore([]byte(args.SessionSecret))
379384385385+ // Wrap the JWK private key as an atcrypto.PrivateKeyP256 for ATProto-compatible
386386+ // signing. atcrypto.ParsePrivateBytesP256 expects the raw 32-byte D scalar.
387387+ dBytes := make([]byte, 32)
388388+ pkey.D.FillBytes(dBytes)
389389+ atpKey, err := atcrypto.ParsePrivateBytesP256(dBytes)
390390+ if err != nil {
391391+ return nil, fmt.Errorf("wrapping JWK private key for atcrypto: %w", err)
392392+ }
393393+380394 s := &Server{
381381- http: h,
382382- httpd: httpd,
383383- router: r,
384384- logger: args.Logger,
385385- db: dbw,
386386- plcClient: plcClient,
387387- privateKey: &pkey,
388388- sessions: cookieStore,
389389- validator: vdtor,
395395+ http: h,
396396+ httpd: httpd,
397397+ router: r,
398398+ logger: args.Logger,
399399+ db: dbw,
400400+ plcClient: plcClient,
401401+ privateKey: &pkey,
402402+ privateKeyATP: atpKey,
403403+ sessions: cookieStore,
404404+ validator: vdtor,
390405 config: &config{
391406 Version: args.Version,
392407 Did: args.Did,
+7-2
server/service_auth.go
···7272 Service: services,
7373 })
74747575- key, err := parsedIdentity.PublicKey()
7575+ // Prefer the dedicated service-auth key (atproto_service) when present.
7676+ // ref: https://github.com/bluesky-social/atproto/discussions/4739
7777+ key, err := parsedIdentity.GetPublicKey("atproto_service")
7678 if err != nil {
7777- return nil, fmt.Errorf("signing key not found for did %s: %s", did, err)
7979+ key, err = parsedIdentity.PublicKey() // fallback to #atproto
8080+ if err != nil {
8181+ return nil, fmt.Errorf("signing key not found for did %s: %s", did, err)
8282+ }
7883 }
7984 return key, nil
8085 })