Client side atproto account migrator in your web browser, along with services for backups and adversarial migrations.
pdsmoover.com
pds
atproto
migrations
moo
cow
1/**
2 * @typedef {import('@atcute/did-plc').Operation} Operation
3 */
4import { P256PrivateKey, Secp256k1PrivateKey } from '@atcute/crypto'
5import { handleAndPDSResolver } from './atprotoUtils.js'
6import { PlcOps } from './plc-ops.js'
7import { normalizeOp } from '@atcute/did-plc'
8import { AtpAgent } from '@atproto/api'
9import { Secp256k1PrivateKeyExportable } from '@atcute/crypto'
10import * as CBOR from '@atcute/cbor'
11import { toBase64Url } from '@atcute/multibase'
12
13class Restore {
14 /**
15 *
16 * @param pdsMooverInstance {string} - The url of the pds moover instance to restore from. Defaults to https://pdsmover.com
17 */
18 constructor(pdsMooverInstance = 'https://pdsmover.com') {
19 /**
20 * If you want to use a different plc directory create your own instance of the plc ops class and pass it in here
21 * @type {PlcOps} */
22 this.plcOps = new PlcOps()
23
24 /**
25 * This is the base url for the pds moover instance used to restore the files from a backup.
26 * @type {string}
27 */
28 this.pdsMooverInstance = pdsMooverInstance
29
30 /**
31 * To keep it simple, only uses secp256k for the temp verification key that is used to create the new account on the new PDS
32 * and is temporarily assigned to the user's account on PLC
33 * @type {null|Secp256k1PrivateKeyExportable}
34 */
35 this.tempVerificationKeypair = null
36
37 /** @type {AtpAgent} */
38 this.atpAgent = null
39
40 /**
41 * The keypair that is used to sign the plc operation
42 * @type {null|{type: string, didPublicKey: `did:key:${string}`, keypair: P256PrivateKey|Secp256k1PrivateKey}}
43 */
44 this.recoveryRotationKeyPair = null
45
46 /**
47 * If this is true we are just restoring the repo and blobs. Ideally for rerunning a restore process after account recovery
48 * @type {boolean}
49 */
50 this.RestoreFromBackup = true
51
52 /**
53 * If set to true then it will do the account recovery. Writes a temp key to the did doc,
54 * create a new account on the new pds, and then submit a new plc op for the pds to have control (finishes the migration, can always restore the backup later)
55 * @type {boolean}
56 */
57 this.AccountRecovery = true
58 }
59
60 /**
61 * Recovers an account with the users rotation key and restores the repo from a PDS MOOver backup
62 * This method can fail, and the account was still recovered, it's best to check the PLC logs to see where an account stands before reruns
63 * @param rotationKey {string} - The users private rotation key, can be a multi key or hex key
64 * @param rotationKeyType {string} - The type of the key, secp256k1 or p256. Required if the key is in hex format, defaults to secp256k1
65 * @param currentHandleOrDid {string} - The users current handle or did, if they don't have a DNS record it will have to be their did for success
66 * @param newPDS {string} - The new PDS url, like https://coolnewpds.com
67 * @param newHandle {string} - Can be the users DNS handle if it is already setup with their did, if not it's bob.mypds.com
68 * @param newPassword {string} - The new password for the new account
69 * @param newEmail {string} - The new email for the new account
70 * @param inviteCode {string|null} - The invite code for the new PDS if it requires one
71 * @param cidToRestoreTo {string|null} - The cid of the plc op to restore to, used mostly to revert a fraudulent plc op. Want to give it the last valid operations cid
72 * @param onStatus {function|null} - A function that takes a string used to update the UI. Like (status) => console.log(status)
73 * @returns {Promise<void>} If there is a failure during restoring the back up (after the status Success! Restoring your repo...) then your account is most likely
74 * recovered and future runs need to have the RestoreFromBackup flag set to true and AccountRecovery set to false.
75 */
76 async recover(
77 rotationKey,
78 rotationKeyType = 'secp256k1',
79 currentHandleOrDid,
80 newPDS,
81 newHandle,
82 newPassword,
83 newEmail,
84 inviteCode,
85 cidToRestoreTo = null,
86 onStatus = null,
87 ) {
88 if (onStatus) onStatus('Resolving your handle...')
89
90 let { usersDid } = await handleAndPDSResolver(currentHandleOrDid)
91
92 if (onStatus)
93 onStatus(
94 'Checking that the new PDS is an actual PDS (if the url is wrong, this takes a while to error out)',
95 )
96 this.atpAgent = new AtpAgent({ service: newPDS })
97 const newHostDesc = await this.atpAgent.com.atproto.server.describeServer()
98
99 //Check to see if the user already has a repo on the new PDS, if they do no reason to try and restore via the plc operations
100 try {
101 await this.atpAgent.com.atproto.repo.describeRepo({ repo: usersDid.toString() })
102 //If we got this far and there is a repo on the new PDS with the users did, we can just move on and restore the files.
103 //We do not want to mess with the plc ops if we dont have to
104 this.AccountRecovery = false
105 } catch (error) {
106 console.error(error)
107 let parsedError = error.error
108 if (parsedError === 'RepoDeactivated') {
109 //Ideally should mean they already have a repo on the new PDS and we just need to restore the files
110 this.AccountRecovery = false
111 }
112 //This is the error we want to see, anything else throw
113 if (parsedError !== 'RepoNotFound') {
114 throw error
115 }
116 }
117
118 //We need to double check that the new handle has not been taken, if it has we need to throw an error
119 //We care a bit more because we do not want any unnecessary plc ops to be created
120 try {
121 let resolveHandle = await this.atpAgent.com.atproto.identity.resolveHandle({
122 handle: newHandle,
123 })
124 if (resolveHandle.data.did === usersDid.toString()) {
125 //This was originally setting the AccountRecovery to false, which works if it is resolved via .well-known, but not dns
126 //The idea was to check and see if the handle has been taken. just leaving for now since it does that check and if the user owns the handle
127 //their did should be set anyhow
128 } else {
129 //There is a repo with that name and it's not the users did,
130 throw new Error('The new handle is already taken, please select a different handle')
131 }
132 } catch (error) {
133 // Going to silently log this and just assume the handle has not been taken.
134 console.error(error)
135 if (error.message.startsWith('The new handle')) {
136 //it's not our custom error, so we can just throw it
137 throw error
138 }
139 }
140
141 if (this.AccountRecovery) {
142 if (onStatus) onStatus('Validating your private rotation key is in the correct format...')
143
144 this.recoveryRotationKeyPair = await this.plcOps.getKeyPair(rotationKey, rotationKeyType)
145
146 if (onStatus) onStatus('Resolving PlC operation logs...')
147
148 /** @type {Operation} */
149 let baseOpForSigning = null
150 let opPrevCid = null
151
152 //This is for reversals against a rogue plc op and you want to restore to a specific cid in the audit log
153 if (cidToRestoreTo) {
154 let auditLogs = await this.plcOps.getPlcAuditLogs(usersDid)
155 for (const log of auditLogs) {
156 if (log.cid === cidToRestoreTo) {
157 baseOpForSigning = normalizeOp(log.operation)
158 opPrevCid = log.cid
159 break
160 }
161 }
162 if (!baseOpForSigning) {
163 throw new Error('Could not find the cid in the audit logs')
164 }
165 } else {
166 let { lastOperation, base } = await this.plcOps.getLastPlcOpFromPlc(usersDid)
167 opPrevCid = base.cid
168 baseOpForSigning = lastOperation
169 }
170
171 if (onStatus) onStatus('Preparing to switch to a temp atproto key...')
172 if (this.tempVerificationKeypair == null) {
173 if (onStatus) onStatus('Creating a new temp atproto key...')
174 this.tempVerificationKeypair = await Secp256k1PrivateKeyExportable.createKeypair()
175 }
176 //Just defaulting to the user's recovery key for now. Advance cases will be something else
177 //Maybe just a new ui to edit the PLC doc in a limited capacity, but sinc ethis is a temp plc op i don't think it's needed
178 let tempRotationKeys = [this.recoveryRotationKeyPair.didPublicKey]
179
180 if (onStatus) onStatus('Modifying the PLC OP for recovery...')
181 //A temp plc op for control of the atproto key to create a serviceAuth and new account on the new PDS
182 await this.plcOps.signAndPublishNewOp(
183 usersDid,
184 this.recoveryRotationKeyPair.keypair,
185 baseOpForSigning.alsoKnownAs,
186 tempRotationKeys,
187 newPDS,
188 await this.tempVerificationKeypair.exportPublicKey('did'),
189 opPrevCid,
190 )
191
192 if (onStatus) onStatus('Creating your new account on the new PDS...')
193 let serviceAuthToken = await this.plcOps.createANewServiceAuthToken(
194 usersDid,
195 newHostDesc.data.did,
196 this.tempVerificationKeypair,
197 'com.atproto.server.createAccount',
198 )
199
200 let createAccountRequest = {
201 did: usersDid,
202 handle: newHandle,
203 email: newEmail,
204 password: newPassword,
205 }
206 if (inviteCode) {
207 createAccountRequest.inviteCode = inviteCode
208 }
209 const _ = await this.atpAgent.com.atproto.server.createAccount(createAccountRequest, {
210 headers: { authorization: `Bearer ${serviceAuthToken}` },
211 encoding: 'application/json',
212 })
213 }
214
215 await this.atpAgent.login({
216 identifier: usersDid,
217 password: newPassword,
218 })
219
220 if (this.AccountRecovery) {
221 //Moving the user offically to the new PDS
222 if (onStatus) onStatus('Signing the papers...')
223 let { base } = await this.plcOps.getLastPlcOpFromPlc(usersDid)
224 await this.signRestorePlcOperation(
225 usersDid,
226 [this.recoveryRotationKeyPair.didPublicKey],
227 base.cid,
228 )
229 }
230
231 if (this.RestoreFromBackup) {
232 if (onStatus) onStatus('Success! Restoring your repo...')
233 const pdsMoover = new AtpAgent({ service: this.pdsMooverInstance })
234 const repoRes = await pdsMoover.com.atproto.sync.getRepo({ did: usersDid })
235 await this.atpAgent.com.atproto.repo.importRepo(repoRes.data, {
236 encoding: 'application/vnd.ipld.car',
237 })
238
239 if (onStatus) onStatus('Restoring your blobs...')
240
241 //Using the missing endpoint to findout what's missing then the PDS MOOver endpoint to restore
242 let totalMissingBlobs = 0
243 let missingBlobCursor = undefined
244 let missingUploadedBlobs = 0
245
246 do {
247 const missingBlobs = await this.atpAgent.com.atproto.repo.listMissingBlobs({
248 cursor: missingBlobCursor,
249 limit: 1000,
250 })
251 totalMissingBlobs += missingBlobs.data.blobs.length
252
253 for (const recordBlob of missingBlobs.data.blobs) {
254 try {
255 const blobRes = await pdsMoover.com.atproto.sync.getBlob({
256 did: usersDid,
257 cid: recordBlob.cid,
258 })
259 let result = await this.atpAgent.com.atproto.repo.uploadBlob(blobRes.data, {
260 encoding: blobRes.headers['content-type'],
261 })
262
263 if (missingUploadedBlobs % 2 === 0) {
264 if (onStatus)
265 onStatus(
266 `Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs} (The total may increase as we find more)`,
267 )
268 }
269 missingUploadedBlobs++
270 } catch (error) {
271 console.error(error)
272 }
273 }
274 missingBlobCursor = missingBlobs.data.cursor
275 } while (missingBlobCursor)
276 }
277 const accountStatus = await this.atpAgent.com.atproto.server.checkAccountStatus()
278 if (!accountStatus.data.activated) {
279 if (onStatus) onStatus('Activating your account...')
280 await this.atpAgent.com.atproto.server.activateAccount()
281 }
282 }
283
284 /**
285 * This method signs the plc operation over to the new PDS and activates the account
286 * Assumes you have already created a new account during the recovery process and logged in
287 * Uses the recommended did doc from the PDS as a base and adds the users rotation key to the rotation keys array
288 *
289 * @param usersDid
290 * @param additionalRotationKeysToAdd
291 * @param prevCid
292 * @returns {Promise<void>}
293 */
294 async signRestorePlcOperation(usersDid, additionalRotationKeysToAdd = [], prevCid) {
295 const getDidCredentials =
296 await this.atpAgent.com.atproto.identity.getRecommendedDidCredentials()
297
298 const pdsProvidedRotationKeys = getDidCredentials.data.rotationKeys ?? []
299 //Puts the provided rotation keys above the pds pro
300 const rotationKeys = [
301 ...new Set([...(additionalRotationKeysToAdd || []), ...pdsProvidedRotationKeys]),
302 ]
303 if (!rotationKeys) {
304 throw new Error('No rotation keys were found to be added to the PLC')
305 }
306
307 if (rotationKeys.length > 5) {
308 throw new Error('You can only add up to 5 rotation keys to the PLC')
309 }
310
311 const plcOpToSubmit = {
312 type: 'plc_operation',
313 ...getDidCredentials.data,
314 prev: prevCid,
315 rotationKeys: rotationKeys,
316 }
317
318 const opBytes = CBOR.encode(plcOpToSubmit)
319 const sigBytes = await this.recoveryRotationKeyPair.keypair.sign(opBytes)
320
321 const signature = toBase64Url(sigBytes)
322
323 const signedOperation = {
324 ...plcOpToSubmit,
325 sig: signature,
326 }
327
328 await this.plcOps.pushPlcOperation(usersDid, signedOperation)
329 await this.atpAgent.com.atproto.server.activateAccount()
330 }
331}
332
333export { Restore }