BYOK Personal Data Server (PDS) written in Go
ipfs vow atproto pds go
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix: properly update passkey on user plc

+84 -139
+84 -19
server/handle_server_supply_signing_key.go
··· 1 1 package server 2 2 3 3 import ( 4 + "context" 4 5 "encoding/base64" 5 6 "encoding/json" 6 7 "maps" 7 8 "net/http" 9 + "slices" 8 10 "strings" 11 + "time" 9 12 10 13 "github.com/bluesky-social/indigo/atproto/atcrypto" 14 + "github.com/google/uuid" 11 15 "pkg.rbrt.fr/vow/identity" 12 16 "pkg.rbrt.fr/vow/internal/helpers" 13 17 "pkg.rbrt.fr/vow/models" ··· 26 30 // AttestationObject is the base64url-encoded attestationObject CBOR from 27 31 // the AuthenticatorAttestationResponse. 28 32 AttestationObject string `json:"attestationObject" validate:"required"` 33 + // RotationKeys, if provided, sets the rotation keys for the PLC operation. 34 + // When recreating a passkey, the client must include the current rotation 35 + // keys from the signed PLC operation returned by signPlcOperation. 36 + RotationKeys []string `json:"rotationKeys"` 29 37 } 30 38 31 39 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 40 + Did string `json:"did"` 41 + PublicKey string `json:"publicKey"` // did:key for atproto (commit signing, passkey) 42 + ServiceKey string `json:"serviceKey"` // did:key for atproto_service (service-auth, PDS server key) 43 + CredentialID string `json:"credentialId"` // base64url credential ID 44 + RotationKeys []string `json:"rotationKeys"` // new rotation keys after the operation 45 + SignedOperation *plc.Operation `json:"signedOperation,omitempty"` // signed PLC operation (when passkey signs) 36 46 } 37 47 38 48 // handleSupplySigningKey registers a WebAuthn passkey for the authenticated ··· 108 118 } 109 119 110 120 // Update the PLC DID document with the two-key structure. 121 + var signedOp *plc.Operation 122 + var newRotationKeys []string 111 123 if strings.HasPrefix(repo.Repo.Did, "did:plc:") { 112 124 log, err := identity.FetchDidAuditLog(ctx, nil, repo.Repo.Did) 113 125 if err != nil { ··· 129 141 // signed by this key; others fall back to #atproto (known limitation). 130 142 newVerificationMethods["atproto_service"] = pdsDIDKey 131 143 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 134 - // — only the user's passkey can authorise future PLC operations. 135 - newRotationKeys := []string{pubDIDKey} 144 + // Determine whether the PDS rotation key still has authority over 145 + // this DID. After the first passkey registration, the rotation key 146 + // belongs to the user's passkey and the PDS can no longer sign. 147 + pdsRotationDIDKey := s.plcClient.RotationDIDKey() 148 + pdsCanSign := slices.Contains(latest.Operation.RotationKeys, pdsRotationDIDKey) 149 + 150 + // Use client-provided rotation keys when recreating passkey (PDS can't sign), 151 + // otherwise replace the PDS rotation key with the passkey's did:key. 152 + if !pdsCanSign && len(req.RotationKeys) > 0 { 153 + newRotationKeys = req.RotationKeys 154 + } else { 155 + newRotationKeys = []string{pubDIDKey} 156 + } 136 157 137 158 op := plc.Operation{ 138 159 Type: "plc_operation", ··· 143 164 Prev: &latest.Cid, 144 165 } 145 166 146 - // The PDS rotation key signs this PLC operation — this is the last 147 - // PLC operation the PDS will ever be able to sign on behalf of the 148 - // user. It is voluntarily handing over control to the passkey. 149 - if err := s.plcClient.SignOp(&op); err != nil { 150 - logger.Error("error signing PLC operation with rotation key", "error", err) 151 - helpers.ServerError(w, nil) 152 - return 167 + if pdsCanSign { 168 + // PDS still holds authority — sign directly. This is the last 169 + // PLC operation the PDS will ever be able to sign on behalf of the 170 + // user. It is voluntarily handing over control to the passkey. 171 + if err := s.plcClient.SignOp(&op); err != nil { 172 + logger.Error("error signing PLC operation with rotation key", "error", err) 173 + helpers.ServerError(w, nil) 174 + return 175 + } 176 + } else { 177 + // Rotation key belongs to the user's existing passkey. Request 178 + // signature via SignerHub, same as handleIdentityUpdateHandle. 179 + opCBOR, err := op.MarshalCBOR() 180 + if err != nil { 181 + logger.Error("error marshalling PLC op to CBOR", "error", err) 182 + helpers.ServerError(w, nil) 183 + return 184 + } 185 + 186 + requestID := uuid.NewString() 187 + expiresAt := time.Now().Add(signerRequestTimeout) 188 + 189 + pendingOps := []PendingWriteOp{ 190 + { 191 + Type: "plc_operation", 192 + Collection: "identity", 193 + Rkey: "supply_signing_key", 194 + }, 195 + } 196 + 197 + payloadB64 := base64.RawURLEncoding.EncodeToString(opCBOR) 198 + msgBytes, err := buildSignRequestMsg(requestID, repo.Repo.Did, payloadB64, pendingOps, expiresAt) 199 + if err != nil { 200 + logger.Error("error building sign request message", "error", err) 201 + helpers.ServerError(w, nil) 202 + return 203 + } 204 + 205 + signCtx, cancel := context.WithDeadline(r.Context(), expiresAt) 206 + defer cancel() 207 + 208 + sigBytes, err := s.signerHub.RequestSignature(signCtx, repo.Repo.Did, requestID, msgBytes) 209 + if helpers.HandleSignerError(w, err) { 210 + logger.Error("signer error during passkey recreation", "did", repo.Repo.Did, "error", err) 211 + return 212 + } 213 + 214 + op.Sig = base64.RawURLEncoding.EncodeToString(sigBytes) 215 + signedOp = &op 153 216 } 154 217 155 218 if err := s.plcClient.SendOperation(ctx, repo.Repo.Did, &op); err != nil { ··· 182 245 ) 183 246 184 247 s.writeJSON(w, 200, SupplySigningKeyResponse{ 185 - Did: repo.Repo.Did, 186 - PublicKey: pubDIDKey, 187 - ServiceKey: pdsDIDKey, 188 - CredentialID: base64.RawURLEncoding.EncodeToString(credentialID), 248 + Did: repo.Repo.Did, 249 + PublicKey: pubDIDKey, 250 + ServiceKey: pdsDIDKey, 251 + CredentialID: base64.RawURLEncoding.EncodeToString(credentialID), 252 + RotationKeys: newRotationKeys, 253 + SignedOperation: signedOp, 189 254 }) 190 255 }
-120
test.go
··· 1 - package main 2 - 3 - import ( 4 - "bytes" 5 - "context" 6 - "fmt" 7 - "log/slog" 8 - "net/http" 9 - "net/url" 10 - "strings" 11 - 12 - "github.com/bluesky-social/indigo/api/atproto" 13 - atp "github.com/bluesky-social/indigo/atproto/repo" 14 - "github.com/bluesky-social/indigo/atproto/syntax" 15 - "github.com/bluesky-social/indigo/events" 16 - "github.com/bluesky-social/indigo/events/schedulers/parallel" 17 - lexutil "github.com/bluesky-social/indigo/lex/util" 18 - "github.com/bluesky-social/indigo/repomgr" 19 - "github.com/gorilla/websocket" 20 - ) 21 - 22 - func main() { 23 - if err := runFirehoseConsumer("ws://localhost:8080"); err != nil { 24 - panic(err) 25 - } 26 - } 27 - 28 - func runFirehoseConsumer(relayHost string) error { 29 - dialer := websocket.DefaultDialer 30 - u, err := url.Parse("wss://vowpds.srv.rbrt.fr") 31 - if err != nil { 32 - return fmt.Errorf("invalid relayHost: %w", err) 33 - } 34 - 35 - u.Path = "xrpc/com.atproto.sync.subscribeRepos" 36 - conn, _, err := dialer.Dial(u.String(), http.Header{ 37 - "User-Agent": []string{"vow-test/0.0.0"}, 38 - }) 39 - if err != nil { 40 - return fmt.Errorf("subscribing to firehose failed (dialing): %w", err) 41 - } 42 - 43 - rsc := &events.RepoStreamCallbacks{ 44 - RepoCommit: func(evt *atproto.SyncSubscribeRepos_Commit) error { 45 - fmt.Println(evt.Repo) 46 - return handleRepoCommit(evt) 47 - }, 48 - RepoIdentity: func(evt *atproto.SyncSubscribeRepos_Identity) error { 49 - fmt.Println(evt.Did, evt.Handle) 50 - return nil 51 - }, 52 - } 53 - 54 - var scheduler events.Scheduler 55 - parallelism := 700 56 - scheduler = parallel.NewScheduler(parallelism, 1000, relayHost, rsc.EventHandler) 57 - 58 - return events.HandleRepoStream(context.TODO(), conn, scheduler, slog.Default()) 59 - } 60 - 61 - func splitRepoPath(path string) (syntax.NSID, syntax.RecordKey, error) { 62 - parts := strings.SplitN(path, "/", 3) 63 - if len(parts) != 2 { 64 - return "", "", fmt.Errorf("invalid record path: %s", path) 65 - } 66 - collection, err := syntax.ParseNSID(parts[0]) 67 - if err != nil { 68 - return "", "", err 69 - } 70 - rkey, err := syntax.ParseRecordKey(parts[1]) 71 - if err != nil { 72 - return "", "", err 73 - } 74 - return collection, rkey, nil 75 - } 76 - 77 - func handleRepoCommit(evt *atproto.SyncSubscribeRepos_Commit) error { 78 - if evt.TooBig { 79 - return nil 80 - } 81 - 82 - did, err := syntax.ParseDID(evt.Repo) 83 - if err != nil { 84 - panic(err) 85 - } 86 - 87 - _, rr, err := atp.LoadRepoFromCAR(context.TODO(), bytes.NewReader(evt.Blocks)) 88 - if err != nil { 89 - panic(err) 90 - } 91 - 92 - for _, op := range evt.Ops { 93 - collection, rkey, err := splitRepoPath(op.Path) 94 - if err != nil { 95 - panic(err) 96 - } 97 - 98 - ek := repomgr.EventKind(op.Action) 99 - 100 - go func() { 101 - switch ek { 102 - case repomgr.EvtKindCreateRecord, repomgr.EvtKindUpdateRecord: 103 - recordCBOR, rc, err := rr.GetRecordBytes(context.TODO(), collection, rkey) 104 - if err != nil { 105 - panic(err) 106 - } 107 - 108 - if op.Cid == nil || rc == nil || lexutil.LexLink(*rc) != *op.Cid { 109 - panic("nocid") 110 - } 111 - 112 - _ = recordCBOR 113 - _ = did 114 - 115 - } 116 - }() 117 - } 118 - 119 - return nil 120 - }