Client side atproto account migrator in your web browser, along with services for backups and adversarial migrations. pdsmoover.com
pds atproto migrations moo cow
at main 333 lines 14 kB view raw
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 }