Vow, uncensorable PDS written in Go

feat: implement atproto_service verification method

+179 -190
+19 -15
readme.md
··· 4 4 > This is highly experimental software. Use with caution, especially during account migration. 5 5 6 6 > [!IMPORTANT] 7 - > **Vow cannot fully interoperate with the ATProto network (Bluesky, AppViews, relays) in its current form.** 7 + > **Vow implements a two-key model for signing that requires a pending ATProto protocol change to be fully interoperable.** 8 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: 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 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`. 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 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. 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. 15 17 16 18 Vow is a Go PDS (Personal Data Server) for the AT Protocol. 17 19 ··· 191 193 - **Repo writes** — `createRecord`, `putRecord`, `deleteRecord`, `applyWrites` 192 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. 193 195 194 - 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. 196 + 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. 195 197 196 198 #### WebSocket connection 197 199 ··· 251 253 252 254 ### Browser-Based Signer 253 255 254 - 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). 256 + 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. 255 257 256 258 ## Identity & DID Sovereignty 257 259 ··· 271 273 272 274 ### What the user gets 273 275 274 - | 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) | 276 + | 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 | 281 285 282 286 ### Verifiability 283 287 ··· 292 296 - **No browser extension required** — passkeys are built into every modern browser and OS. 293 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. 294 298 - **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). 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. 296 300 297 301 ## Management Commands 298 302
+46 -73
server/handle_server_get_service_auth.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "crypto/sha256" 6 - "encoding/base64" 7 - "encoding/json" 8 5 "fmt" 9 6 "net/http" 10 - "strings" 11 7 "time" 12 8 9 + "github.com/bluesky-social/indigo/atproto/atcrypto" 10 + "github.com/golang-jwt/jwt/v4" 13 11 "github.com/google/uuid" 14 12 "pkg.rbrt.fr/vow/internal/helpers" 15 13 "pkg.rbrt.fr/vow/models" ··· 81 79 } 82 80 83 81 // 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. 82 + // (aud, lxm) pair. 86 83 // 87 - // The returned string is a fully formed "header.payload.signature" JWT ready to 88 - // be placed in an Authorization: Bearer header. 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. 89 90 // 90 91 // lxm may be empty, in which case no "lxm" claim is included. 91 92 func (s *Server) signServiceAuthJWT( ··· 95 96 lxm string, 96 97 exp int64, 97 98 ) (string, error) { 98 - if len(repo.PublicKey) == 0 { 99 - return "", fmt.Errorf("no public key registered for account %s", repo.Repo.Did) 100 - } 101 99 102 100 did := repo.Repo.Did 103 101 104 - // ── 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 102 now := time.Now().Unix() 117 - var expiresAt time.Time 118 103 if exp == 0 { 119 - expiresAt = time.Now().Add(5 * time.Minute) 120 - exp = expiresAt.Unix() 121 - } else { 122 - expiresAt = time.Unix(exp, 0) 104 + exp = now + int64(5*time.Minute/time.Second) 123 105 } 124 106 125 - claims := map[string]any{ 107 + claims := jwt.MapClaims{ 126 108 "iss": did, 127 109 "aud": aud, 128 110 "jti": uuid.NewString(), ··· 133 115 claims["lxm"] = lxm 134 116 } 135 117 136 - 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), "=") 118 + // 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 + } 141 123 142 - // signingInput is what the JWT spec calls the "message to be signed": 143 - // base64url(header) + "." + base64url(payload). 144 - signingInput := encHeader + "." + encPayload 124 + // 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{} 145 127 146 - // ES256 requires signing the SHA-256 hash of the signing input. We send 147 - // the hash as the WebAuthn challenge (the passkey will sign 148 - // authenticatorData ‖ SHA-256(clientDataJSON) where clientDataJSON.challenge 149 - // = base64url(hash)). The WS handler verifies the full assertion and 150 - // delivers the raw (r‖s) signature bytes back to this function. 151 - hash := sha256.Sum256([]byte(signingInput)) 152 - payloadB64 := base64.RawURLEncoding.EncodeToString(hash[:]) 128 + func newES256AtpSigningMethod() *es256AtpSigningMethod { return &es256AtpSigningMethod{} } 153 129 154 - requestID := uuid.NewString() 155 - signerDeadline := time.Now().Add(signerRequestTimeout) 130 + func (m *es256AtpSigningMethod) Alg() string { return "ES256" } 156 131 157 - ops := []PendingWriteOp{ 158 - { 159 - Type: "service_auth", 160 - Collection: aud, 161 - Rkey: lxm, 162 - }, 132 + 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) 163 136 } 164 - 165 - msgBytes, err := buildSignRequestMsg(requestID, did, payloadB64, ops, signerDeadline) 137 + sig, err := priv.HashAndSign([]byte(signingString)) 166 138 if err != nil { 167 - return "", fmt.Errorf("building sign request message: %w", err) 139 + return "", fmt.Errorf("es256AtpSigningMethod: signing failed: %w", err) 168 140 } 141 + return jwt.EncodeSegment(sig), nil //nolint:staticcheck 142 + } 169 143 170 - signCtx, cancel := context.WithDeadline(ctx, signerDeadline) 171 - defer cancel() 172 - 173 - sigBytes, err := s.signerHub.RequestSignature(signCtx, did, requestID, msgBytes) 144 + func (m *es256AtpSigningMethod) Verify(signingString string, signature string, key any) error { 145 + sigBytes, err := jwt.DecodeSegment(signature) //nolint:staticcheck 174 146 if err != nil { 175 - return "", err 147 + return err 176 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 + } 177 155 178 - // sigBytes is the raw 64-byte (r‖s) P-256 signature delivered by the WS 179 - // handler after WebAuthn assertion verification. Trim to 64 bytes just in 180 - // case an old client appended a recovery byte. 181 - if len(sigBytes) == 65 { 182 - sigBytes = sigBytes[:64] 156 + // 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) 183 163 } 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 164 + return pub.DIDKey(), nil 192 165 }
+39 -6
server/handle_server_supply_signing_key.go
··· 30 30 31 31 type SupplySigningKeyResponse struct { 32 32 Did string `json:"did"` 33 - PublicKey string `json:"publicKey"` // did:key representation 34 - CredentialID string `json:"credentialId"` // base64url 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 35 36 } 36 37 37 38 // handleSupplySigningKey registers a WebAuthn passkey for the authenticated 38 39 // account. The private key never leaves the authenticator; the PDS stores only 39 40 // the compressed P-256 public key and the credential ID. 40 41 // 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. 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. 43 60 func (s *Server) handleSupplySigningKey(w http.ResponseWriter, r *http.Request) { 44 61 ctx := r.Context() 45 62 logger := s.logger.With("name", "handleSupplySigningKey") ··· 82 99 83 100 pubDIDKey := pubKey.DIDKey() 84 101 85 - // Update the PLC DID document so the passkey's did:key becomes the active 86 - // atproto verification method and the sole rotation key. 102 + // 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. 87 111 if strings.HasPrefix(repo.Repo.Did, "did:plc:") { 88 112 log, err := identity.FetchDidAuditLog(ctx, nil, repo.Repo.Did) 89 113 if err != nil { ··· 96 120 97 121 newVerificationMethods := make(map[string]string) 98 122 maps.Copy(newVerificationMethods, latest.Operation.VerificationMethods) 123 + // Commit-signing key: the user's passkey. Every repo write requires 124 + // passkey usage. 99 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 100 131 101 132 // Replace the PDS rotation key with the passkey's did:key. After this 102 133 // operation the PDS can no longer unilaterally modify the DID document ··· 146 177 logger.Info("passkey registered — rotation key transferred to user", 147 178 "did", repo.Repo.Did, 148 179 "publicKey", pubDIDKey, 180 + "serviceKey", pdsDIDKey, 149 181 "credentialIDLen", len(credentialID), 150 182 ) 151 183 152 184 s.writeJSON(w, 200, SupplySigningKeyResponse{ 153 185 Did: repo.Repo.Did, 154 186 PublicKey: pubDIDKey, 187 + ServiceKey: pdsDIDKey, 155 188 CredentialID: base64.RawURLEncoding.EncodeToString(credentialID), 156 189 }) 157 190 }
+19 -75
server/middleware.go
··· 2 2 3 3 import ( 4 4 "context" 5 - "encoding/base64" 6 5 "errors" 7 6 "fmt" 8 7 "net/http" 9 8 "strings" 10 9 "time" 11 10 12 - "github.com/bluesky-social/indigo/atproto/atcrypto" 13 11 "github.com/golang-jwt/jwt/v4" 14 12 "gorm.io/gorm" 15 13 "pkg.rbrt.fr/vow/internal/helpers" ··· 155 153 repo = maybeRepo 156 154 } 157 155 158 - // isUserSignedToken is true for service-auth JWTs signed by the user's 159 - // passkey (ES256 with an lxm claim). Regular access tokens use ES256 160 - // too but are signed by the PDS private key and carry no lxm claim. 161 - isUserSignedToken := token.Header["alg"] == "ES256" && hasLxm 162 - 163 - if !isUserSignedToken { 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 156 + // 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"]) 226 165 } 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 + } 227 173 228 - 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 - } 174 + if !token.Valid { 175 + helpers.InvalidTokenError(w) 176 + return 233 177 } 234 178 235 179 isRefresh := r.URL.Path == "/xrpc/com.atproto.server.refreshSession"
+34 -19
server/server.go
··· 18 18 "time" 19 19 20 20 "github.com/bluesky-social/indigo/api/atproto" 21 + "github.com/bluesky-social/indigo/atproto/atcrypto" 21 22 "github.com/bluesky-social/indigo/atproto/syntax" 22 23 "github.com/bluesky-social/indigo/events" 23 24 "github.com/bluesky-social/indigo/util" ··· 63 64 } 64 65 65 66 type Server struct { 66 - http *http.Client 67 - httpd *http.Server 68 - mail *mailyak.MailYak 69 - mailLk *sync.Mutex 70 - router *chi.Mux 71 - db *db.DB 72 - plcClient *plc.Client 73 - logger *slog.Logger 74 - config *config 75 - privateKey *ecdsa.PrivateKey 67 + http *http.Client 68 + httpd *http.Server 69 + mail *mailyak.MailYak 70 + mailLk *sync.Mutex 71 + router *chi.Mux 72 + db *db.DB 73 + plcClient *plc.Client 74 + logger *slog.Logger 75 + config *config 76 + privateKey *ecdsa.PrivateKey 77 + // privateKeyATP is the same JWK key wrapped as an atcrypto.PrivateKeyP256 78 + // so it can be passed directly to atcrypto signing functions. Used to sign 79 + // service-auth JWTs on behalf of the user (atproto_service slot in DID doc). 80 + privateKeyATP *atcrypto.PrivateKeyP256 76 81 repoman *RepoMan 77 82 oauthProvider *provider.Provider 78 83 evtman *events.EventManager ··· 377 382 378 383 cookieStore := sessions.NewCookieStore([]byte(args.SessionSecret)) 379 384 385 + // Wrap the JWK private key as an atcrypto.PrivateKeyP256 for ATProto-compatible 386 + // signing. atcrypto.ParsePrivateBytesP256 expects the raw 32-byte D scalar. 387 + dBytes := make([]byte, 32) 388 + pkey.D.FillBytes(dBytes) 389 + atpKey, err := atcrypto.ParsePrivateBytesP256(dBytes) 390 + if err != nil { 391 + return nil, fmt.Errorf("wrapping JWK private key for atcrypto: %w", err) 392 + } 393 + 380 394 s := &Server{ 381 - http: h, 382 - httpd: httpd, 383 - router: r, 384 - logger: args.Logger, 385 - db: dbw, 386 - plcClient: plcClient, 387 - privateKey: &pkey, 388 - sessions: cookieStore, 389 - validator: vdtor, 395 + http: h, 396 + httpd: httpd, 397 + router: r, 398 + logger: args.Logger, 399 + db: dbw, 400 + plcClient: plcClient, 401 + privateKey: &pkey, 402 + privateKeyATP: atpKey, 403 + sessions: cookieStore, 404 + validator: vdtor, 390 405 config: &config{ 391 406 Version: args.Version, 392 407 Did: args.Did,
+7 -2
server/service_auth.go
··· 72 72 Service: services, 73 73 }) 74 74 75 - key, err := parsedIdentity.PublicKey() 75 + // Prefer the dedicated service-auth key (atproto_service) when present. 76 + // ref: https://github.com/bluesky-social/atproto/discussions/4739 77 + key, err := parsedIdentity.GetPublicKey("atproto_service") 76 78 if err != nil { 77 - return nil, fmt.Errorf("signing key not found for did %s: %s", did, err) 79 + key, err = parsedIdentity.PublicKey() // fallback to #atproto 80 + if err != nil { 81 + return nil, fmt.Errorf("signing key not found for did %s: %s", did, err) 82 + } 78 83 } 79 84 return key, nil 80 85 })