forked from
baileytownsend.dev/pds-moover
Client side atproto account migrator in your web browser, along with services for backups and adversarial migrations.
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};