Vow, uncensorable PDS written in Go

feat: implement atproto_service verification method

+179 -190
+19 -15
readme.md
··· 4 > This is highly experimental software. Use with caution, especially during account migration. 5 6 > [!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. 15 16 Vow 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. 193 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. 195 196 #### WebSocket connection 197 ··· 251 252 ### Browser-Based Signer 253 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). 255 256 ## Identity & DID Sovereignty 257 ··· 271 272 ### What the user gets 273 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) | 281 282 ### 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). 296 297 ## Management Commands 298
··· 4 > This is highly experimental software. Use with caution, especially during account migration. 5 6 > [!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. 17 18 Vow 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. 195 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. 197 198 #### WebSocket connection 199 ··· 253 254 ### Browser-Based Signer 255 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. 257 258 ## Identity & DID Sovereignty 259 ··· 273 274 ### What the user gets 275 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 | 285 286 ### 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. 300 301 ## Management Commands 302
+46 -73
server/handle_server_get_service_auth.go
··· 2 3 import ( 4 "context" 5 - "crypto/sha256" 6 - "encoding/base64" 7 - "encoding/json" 8 "fmt" 9 "net/http" 10 - "strings" 11 "time" 12 13 "github.com/google/uuid" 14 "pkg.rbrt.fr/vow/internal/helpers" 15 "pkg.rbrt.fr/vow/models" ··· 81 } 82 83 // 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. 89 // 90 // lxm may be empty, in which case no "lxm" claim is included. 91 func (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 - } 101 102 did := repo.Repo.Did 103 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 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 } 124 125 - claims := map[string]any{ 126 "iss": did, 127 "aud": aud, 128 "jti": uuid.NewString(), ··· 133 claims["lxm"] = lxm 134 } 135 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), "=") 141 142 - // signingInput is what the JWT spec calls the "message to be signed": 143 - // base64url(header) + "." + base64url(payload). 144 - signingInput := encHeader + "." + encPayload 145 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[:]) 153 154 - requestID := uuid.NewString() 155 - signerDeadline := time.Now().Add(signerRequestTimeout) 156 157 - 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 } 169 170 - 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 } 177 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] 183 } 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 }
··· 2 3 import ( 4 "context" 5 "fmt" 6 "net/http" 7 "time" 8 9 + "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 } 80 81 // signServiceAuthJWT returns a signed ES256 service-auth JWT for the given 82 + // (aud, lxm) pair. 83 // 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. 92 func (s *Server) signServiceAuthJWT( ··· 96 lxm string, 97 exp int64, 98 ) (string, error) { 99 100 did := repo.Repo.Did 101 102 now := time.Now().Unix() 103 if exp == 0 { 104 + exp = now + int64(5*time.Minute/time.Second) 105 } 106 107 + claims := jwt.MapClaims{ 108 "iss": did, 109 "aud": aud, 110 "jti": uuid.NewString(), ··· 115 claims["lxm"] = lxm 116 } 117 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 + } 123 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{} 127 128 + func newES256AtpSigningMethod() *es256AtpSigningMethod { return &es256AtpSigningMethod{} } 129 130 + func (m *es256AtpSigningMethod) Alg() string { return "ES256" } 131 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) 136 } 137 + sig, err := priv.HashAndSign([]byte(signingString)) 138 if err != nil { 139 + return "", fmt.Errorf("es256AtpSigningMethod: signing failed: %w", err) 140 } 141 + return jwt.EncodeSegment(sig), nil //nolint:staticcheck 142 + } 143 144 + func (m *es256AtpSigningMethod) Verify(signingString string, signature string, key any) error { 145 + sigBytes, err := jwt.DecodeSegment(signature) //nolint:staticcheck 146 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 + } 155 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) 163 } 164 + return pub.DIDKey(), nil 165 }
+39 -6
server/handle_server_supply_signing_key.go
··· 30 31 type SupplySigningKeyResponse struct { 32 Did string `json:"did"` 33 - PublicKey string `json:"publicKey"` // did:key representation 34 - CredentialID string `json:"credentialId"` // base64url 35 } 36 37 // 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. 43 func (s *Server) handleSupplySigningKey(w http.ResponseWriter, r *http.Request) { 44 ctx := r.Context() 45 logger := s.logger.With("name", "handleSupplySigningKey") ··· 82 83 pubDIDKey := pubKey.DIDKey() 84 85 - // 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 { ··· 96 97 newVerificationMethods := make(map[string]string) 98 maps.Copy(newVerificationMethods, latest.Operation.VerificationMethods) 99 newVerificationMethods["atproto"] = pubDIDKey 100 101 // 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, 149 "credentialIDLen", len(credentialID), 150 ) 151 152 s.writeJSON(w, 200, SupplySigningKeyResponse{ 153 Did: repo.Repo.Did, 154 PublicKey: pubDIDKey, 155 CredentialID: base64.RawURLEncoding.EncodeToString(credentialID), 156 }) 157 }
··· 30 31 type 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 } 37 38 // 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. 60 func (s *Server) handleSupplySigningKey(w http.ResponseWriter, r *http.Request) { 61 ctx := r.Context() 62 logger := s.logger.With("name", "handleSupplySigningKey") ··· 99 100 pubDIDKey := pubKey.DIDKey() 101 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. 111 if strings.HasPrefix(repo.Repo.Did, "did:plc:") { 112 log, err := identity.FetchDidAuditLog(ctx, nil, repo.Repo.Did) 113 if err != nil { ··· 120 121 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 131 132 // 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 ) 183 184 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
··· 2 3 import ( 4 "context" 5 - "encoding/base64" 6 "errors" 7 "fmt" 8 "net/http" 9 "strings" 10 "time" 11 12 - "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 } 157 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 226 } 227 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 - } 233 } 234 235 isRefresh := r.URL.Path == "/xrpc/com.atproto.server.refreshSession"
··· 2 3 import ( 4 "context" 5 "errors" 6 "fmt" 7 "net/http" 8 "strings" 9 "time" 10 11 "github.com/golang-jwt/jwt/v4" 12 "gorm.io/gorm" 13 "pkg.rbrt.fr/vow/internal/helpers" ··· 153 repo = maybeRepo 154 } 155 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"]) 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 + } 173 174 + if !token.Valid { 175 + helpers.InvalidTokenError(w) 176 + return 177 } 178 179 isRefresh := r.URL.Path == "/xrpc/com.atproto.server.refreshSession"
+34 -19
server/server.go
··· 18 "time" 19 20 "github.com/bluesky-social/indigo/api/atproto" 21 "github.com/bluesky-social/indigo/atproto/syntax" 22 "github.com/bluesky-social/indigo/events" 23 "github.com/bluesky-social/indigo/util" ··· 63 } 64 65 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 76 repoman *RepoMan 77 oauthProvider *provider.Provider 78 evtman *events.EventManager ··· 377 378 cookieStore := sessions.NewCookieStore([]byte(args.SessionSecret)) 379 380 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, 390 config: &config{ 391 Version: args.Version, 392 Did: args.Did,
··· 18 "time" 19 20 "github.com/bluesky-social/indigo/api/atproto" 21 + "github.com/bluesky-social/indigo/atproto/atcrypto" 22 "github.com/bluesky-social/indigo/atproto/syntax" 23 "github.com/bluesky-social/indigo/events" 24 "github.com/bluesky-social/indigo/util" ··· 64 } 65 66 type Server struct { 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 81 repoman *RepoMan 82 oauthProvider *provider.Provider 83 evtman *events.EventManager ··· 382 383 cookieStore := sessions.NewCookieStore([]byte(args.SessionSecret)) 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 + 394 s := &Server{ 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, 405 config: &config{ 406 Version: args.Version, 407 Did: args.Did,
+7 -2
server/service_auth.go
··· 72 Service: services, 73 }) 74 75 - key, err := parsedIdentity.PublicKey() 76 if err != nil { 77 - return nil, fmt.Errorf("signing key not found for did %s: %s", did, err) 78 } 79 return key, nil 80 })
··· 72 Service: services, 73 }) 74 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") 78 if err != nil { 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 + } 83 } 84 return key, nil 85 })