Client side atproto account migrator in your web browser, along with services for backups and adversarial migrations.
at next 254 lines 9.4 kB view raw
1import {handleAndPDSResolver} from './atprotoUtils.js'; 2import {PlcOps} from './plc-ops.js'; 3import {normalizeOp, Operation} from '@atcute/did-plc'; 4import {AtpAgent} from '@atproto/api'; 5import {Secp256k1PrivateKeyExportable} from '@atcute/crypto'; 6import * as CBOR from '@atcute/cbor'; 7import {toBase64Url} from '@atcute/multibase'; 8 9class Restore { 10 11 constructor() { 12 /** @type {PlcOps} */ 13 this.plcOps = new PlcOps(); 14 this.tempVerificationKeypair = null; 15 /** @type {AtpAgent} */ 16 this.atpAgent = null; 17 this.recoveryRotationKeyPair = null; 18 //Feature flags 19 this.justRestoreFiles = false; 20 } 21 22 async recover( 23 rotationKey, 24 currentHandle, 25 newPDS, 26 newHandle, 27 newPassword, 28 newEmail, 29 inviteCode, 30 cidToRestoreTo = null, 31 onStatus = null) { 32 33 34 if (onStatus) onStatus('Resolving your handle...'); 35 36 let {usersDid} = await handleAndPDSResolver(currentHandle); 37 38 if (onStatus) onStatus('Checking that the new PDS is an actual PDS (if the url is wrong, this takes a while to error out)'); 39 this.atpAgent = new AtpAgent({service: newPDS}); 40 const newHostDesc = await this.atpAgent.com.atproto.server.describeServer(); 41 42 43 //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 44 try { 45 await this.atpAgent.com.atproto.repo.describeRepo({repo: usersDid.toString()}); 46 //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 47 this.justRestoreFiles = true; 48 49 } catch (error) { 50 console.error(error); 51 let parsedError = error.error; 52 if (parsedError === 'RepoDeactivated') { 53 //Ideally should mean they already have a repo on the new PDS and we just need to restore the files 54 this.justRestoreFiles = true; 55 } 56 //This is the error we want to see, anything else throw 57 if (parsedError !== 'RepoNotFound') { 58 throw error; 59 } 60 } 61 62 //We need to double check that the new handle has not been taken, if it has we need to throw an error 63 //We care a bit more because we do not want any unnecessary plc ops to be created 64 try { 65 let resolveHandle = await this.atpAgent.com.atproto.identity.resolveHandle({handle: newHandle}); 66 if (resolveHandle.data.did === usersDid.toString()) { 67 //Ideally shouldn't get here without the checks above. But we do not need to create a new account or do plc ops. It should already be there 68 this.justRestoreFiles = true; 69 } else { 70 //There is a repo with that name and it's not the users did, 71 throw new Error('The new handle is already taken, please select a different handle'); 72 } 73 } catch (error) { 74 // Going to silently log this and just assume the handle has not been taken. 75 console.error(error); 76 if (error.message.startsWith('The new handle')) { 77 //it's not our custom error, so we can just throw it 78 throw error; 79 } 80 81 } 82 83 if (!this.justRestoreFiles) { 84 85 if (onStatus) onStatus('Validating your private rotation key is in the correct format...'); 86 this.recoveryRotationKeyPair = await this.plcOps.getKeyPair(rotationKey); 87 88 89 if (onStatus) onStatus('Resolving PlC operation logs...'); 90 91 /** @type {Operation} */ 92 let baseOpForSigning = null; 93 let opPrevCid = null; 94 95 //This is for reversals against a rogue plc op and you want to restore to a specific cid in the audit log 96 if (cidToRestoreTo) { 97 let auditLogs = await this.plcOps.getPlcAuditLogs(usersDid); 98 for (const log of auditLogs) { 99 if (log.cid === cidToRestoreTo) { 100 baseOpForSigning = normalizeOp(log.operation); 101 opPrevCid = log.cid; 102 break; 103 } 104 } 105 if (!baseOpForSigning) { 106 throw new Error('Could not find the cid in the audit logs'); 107 } 108 } else { 109 let {lastOperation, base} = await this.plcOps.getLastPlcOpFromPlc(usersDid); 110 opPrevCid = base.cid; 111 baseOpForSigning = lastOperation; 112 } 113 114 if (onStatus) onStatus('Preparing to switch to a temp atproto key...'); 115 if (this.tempVerificationKeypair == null) { 116 if (onStatus) onStatus('Creating a new temp atproto key...'); 117 this.tempVerificationKeypair = await Secp256k1PrivateKeyExportable.createKeypair(); 118 } 119 //Just defaulting to the user's recovery key for now. Advance cases will be something else 120 //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 121 let tempRotationKeys = [this.recoveryRotationKeyPair.didPublicKey]; 122 123 if (onStatus) onStatus('Modifying the PLC OP for recovery...'); 124 //A temp plc op for control of the atproto key to create a serviceAuth and new account on the new PDS 125 await this.plcOps.signAndPublishNewOp( 126 usersDid, 127 this.recoveryRotationKeyPair.keypair, 128 baseOpForSigning.alsoKnownAs, 129 tempRotationKeys, 130 newPDS, 131 await this.tempVerificationKeypair.exportPublicKey('did'), 132 opPrevCid); 133 134 135 if (onStatus) onStatus('Creating your new account on the new PDS...'); 136 let serviceAuthToken = await this.plcOps.createANewServiceAuthToken(usersDid, newHostDesc.data.did, this.tempVerificationKeypair, 'com.atproto.server.createAccount'); 137 138 let createAccountRequest = { 139 did: usersDid, 140 handle: newHandle, 141 email: newEmail, 142 password: newPassword, 143 }; 144 if (inviteCode) { 145 createAccountRequest.inviteCode = inviteCode; 146 } 147 const _ = await this.atpAgent.com.atproto.server.createAccount( 148 createAccountRequest, 149 { 150 headers: {authorization: `Bearer ${serviceAuthToken}`}, 151 encoding: 'application/json', 152 }); 153 } 154 155 await this.atpAgent.login({ 156 identifier: usersDid, 157 password: newPassword, 158 }); 159 160 if (!this.justRestoreFiles) { 161 //Moving the user offically to the new PDS 162 if (onStatus) onStatus('Signing the papers...'); 163 let {base} = await this.plcOps.getLastPlcOpFromPlc(usersDid); 164 await this.signRestorePlcOperation(usersDid, [this.recoveryRotationKeyPair.didPublicKey], base.cid); 165 } 166 167 if (onStatus) onStatus('Success! Restoring your repo...'); 168 const pdsMoover = new AtpAgent({service: window.location.origin}); 169 const repoRes = await pdsMoover.com.atproto.sync.getRepo({did: usersDid}); 170 await this.atpAgent.com.atproto.repo.importRepo(repoRes.data, { 171 encoding: 'application/vnd.ipld.car', 172 }); 173 174 if (onStatus) onStatus('Restoring your blobs...'); 175 176 //Using the missing endpoint to findout what's missing then the PDS MOOver endpoint to restore 177 let totalMissingBlobs = 0; 178 let missingBlobCursor = undefined; 179 let missingUploadedBlobs = 0; 180 181 do { 182 183 const missingBlobs = await this.atpAgent.com.atproto.repo.listMissingBlobs({ 184 cursor: missingBlobCursor, 185 limit: 1000, 186 }); 187 totalMissingBlobs += missingBlobs.data.blobs.length; 188 189 for (const recordBlob of missingBlobs.data.blobs) { 190 try { 191 192 const blobRes = await pdsMoover.com.atproto.sync.getBlob({ 193 did: usersDid, 194 cid: recordBlob.cid, 195 }); 196 let result = await this.atpAgent.com.atproto.repo.uploadBlob(blobRes.data, { 197 encoding: blobRes.headers['content-type'], 198 }); 199 200 201 if (missingUploadedBlobs % 2 === 0) { 202 if (onStatus) onStatus(`Migrating blobs: ${missingUploadedBlobs}/${totalMissingBlobs} (The total may increase as we find more)`); 203 } 204 missingUploadedBlobs++; 205 } catch (error) { 206 console.error(error); 207 } 208 } 209 missingBlobCursor = missingBlobs.data.cursor; 210 } while (missingBlobCursor); 211 212 const accountStatus = await this.atpAgent.com.atproto.server.checkAccountStatus(); 213 if (!accountStatus.data.activated) { 214 if (onStatus) onStatus('Activating your account...'); 215 await this.atpAgent.com.atproto.server.activateAccount(); 216 } 217 } 218 219 async signRestorePlcOperation(usersDid, additionalRotationKeysToAdd = [], prevCid) { 220 const getDidCredentials = 221 await this.atpAgent.com.atproto.identity.getRecommendedDidCredentials(); 222 console.log(getDidCredentials); 223 224 const pdsProvidedRotationKeys = getDidCredentials.data.rotationKeys ?? []; 225 // Prepend any additional rotation keys (e.g., user-added keys, newly created key) so they appear above the new PDS rotation key 226 const rotationKeys = [...(additionalRotationKeysToAdd || []), ...pdsProvidedRotationKeys]; 227 if (!rotationKeys) { 228 throw new Error('No rotation key provided from the new PDS'); 229 } 230 const plcOpToSubmit = { 231 type: 'plc_operation', 232 ...getDidCredentials.data, 233 prev: prevCid, 234 rotationKeys: rotationKeys, 235 }; 236 237 238 const opBytes = CBOR.encode(plcOpToSubmit); 239 const sigBytes = await this.recoveryRotationKeyPair.keypair.sign(opBytes); 240 241 const signature = toBase64Url(sigBytes); 242 243 const signedOperation = { 244 ...plcOpToSubmit, 245 sig: signature, 246 }; 247 248 await this.plcOps.pushPlcOperation(usersDid, signedOperation); 249 await this.atpAgent.com.atproto.server.activateAccount(); 250 251 } 252} 253 254export {Restore};