forked from
baileytownsend.dev/pds-moover
Client side atproto account migrator in your web browser, along with services for backups and adversarial migrations.
1import {defs, normalizeOp} from '@atcute/did-plc';
2import {P256PrivateKey, parsePrivateMultikey, Secp256k1PrivateKey, Secp256k1PrivateKeyExportable} from '@atcute/crypto';
3import * as CBOR from '@atcute/cbor';
4import {fromBase16, toBase64Url} from '@atcute/multibase';
5
6//NOTES
7// Don't forget disputes can check https://github.dev/mary-ext/boat/blob/trunk/src/views/identity/plc-applicator/steps/step1_handle-input.tsx
8//This is if a previous operation should be disputed, it will be a check box on recovery
9
10const PLC_DIRECTORY_URL = 'https://plc.directory';
11
12
13// Helper to base64url-encode JSON
14const jsonToB64Url = (obj) => {
15 const enc = new TextEncoder();
16 const json = JSON.stringify(obj);
17 return toBase64Url(enc.encode(json));
18};
19
20class PlcOps {
21 constructor() {
22
23 }
24
25 //TODO ui
26 // For unvaldiating a record in 72hr window add an advance option input for that cid
27 //that will be the easiest way at launch and just help ppl
28
29 //NEEDS
30 // Function to get current rotation keys
31 // function to create a new key
32 // function to add a new key and save and submit
33 // Can use that same function for signing a recover
34
35 async testSignAServiceAuthToken(did) {
36 //
37 const testPrivateKey = 'z3vLhP9c6gwUUwA4jkyYE9SXifE7wD1f3rVMMLmqBq8TaT2B';
38 let signingKeypair = await this.getKeyPair(testPrivateKey);
39 let test = await this.createANewServiceAuthToken(did, 'did:web:dev.pdsmoover.com', signingKeypair.keypair, 'com.pdsmoover.backup.requestBackup');
40 console.log(test);
41 }
42
43
44 async exampleOfSigningAPLCOPManuallyThatWorks() {
45 //dev keys
46 // New Rotation Key: did:key:zQ3shXuksWLbyTTbWrSJ41qZvR2eyNFGTdbjjG3b2MWRo5cSx
47 // New Private Key: z3vLhP9c6gwUUwA4jkyYE9SXifE7wD1f3rVMMLmqBq8TaT2B
48 const testPrivateKey = 'z3vLhP9c6gwUUwA4jkyYE9SXifE7wD1f3rVMMLmqBq8TaT2B';
49 let signingKeypair = await this.getKeyPair(testPrivateKey);
50 let {lastOperation, base} = await this.getLastPlcOpFromPlc(did);
51 console.log(lastOperation);
52 let {privateKey, publicKey} = await this.createANewSecp256k1();
53 console.log('New Rotation Key:', publicKey);
54 console.log('New Private Key:', privateKey);
55 let newRotationKeys = lastOperation.rotationKeys || [];
56 //Adds the new one to the top
57 newRotationKeys.unshift(publicKey);
58 await this.signAndPublishNewOp(did, signingKeypair.keypair, lastOperation.alsoKnownAs, newRotationKeys, lastOperation.services.atproto_pds.endpoint, lastOperation.verificationMethods.atproto, base.cid);
59
60 }
61
62 async getCurrentRotationKeysForUser(did) {
63 const logs = await this.getPlcAuditLogs(did);
64 const {rotationKeys} = this.getLastPlcOp(logs);
65 return rotationKeys;
66 }
67
68 async getLastPlcOpFromPlc(did) {
69 const logs = await this.getPlcAuditLogs(did);
70 return this.getLastPlcOp(logs);
71 }
72
73 getLastPlcOp(logs) {
74 const lastOp = logs.at(-1);
75 return {lastOperation: normalizeOp(lastOp.operation), base: lastOp};
76 }
77
78
79 async getPlcAuditLogs(did) {
80 const response = await fetch(`${PLC_DIRECTORY_URL}/${did}/log/audit`);
81 if (!response.ok) {
82 throw new Error(`got resposne ${response.status}`);
83 }
84
85 const json = await response.json();
86 return defs.indexedEntryLog.parse(json);
87 }
88
89 /**
90 * Creates a new secp256k1 key that can be used for either rotation or verification key
91 * @returns {Promise<{privateKey: string, publicKey: `did:key:${string}`}>}
92 */
93
94 async createANewSecp256k1() {
95 let keypair = await Secp256k1PrivateKeyExportable.createKeypair();
96 let publicKey = await keypair.exportPublicKey('did');
97 let privateKey = await keypair.exportPrivateKey('multikey');
98 return {
99 privateKey,
100 publicKey
101 };
102 }
103
104
105 /**
106 * Signs a new operation with the provided signing key and submits it
107 * @param did
108 * @param signingRotationKey
109 * @param alsoKnownAs
110 * @param rotationKeys
111 * @param pds
112 * @param verificationKey
113 * @returns {Promise<void>}
114 */
115 async signAndPublishNewOp(did, signingRotationKey, alsoKnownAs, rotationKeys, pds, verificationKey, prev) {
116 const operation = {
117 type: 'plc_operation',
118 // prev: prev!.cid,
119 prev,
120 alsoKnownAs,
121 rotationKeys,
122 services: {
123 atproto_pds: {
124 type: 'AtprotoPersonalDataServer',
125 endpoint: pds
126 }
127 },
128 verificationMethods: {
129 atproto: verificationKey
130 }
131 };
132 const opBytes = CBOR.encode(operation);
133 const sigBytes = await signingRotationKey.sign(opBytes);
134
135 const signature = toBase64Url(sigBytes);
136
137 const signedOperation = {
138 ...operation,
139 sig: signature,
140 };
141
142 await this.pushPlcOperation(did, signedOperation);
143 }
144
145 /**
146 * Takes a multi or hexbased private key and returns a keypair
147 * @param privateKeyString
148 * @param type
149 * @returns {Promise<{type: string, didPublicKey: `did:key:${string}`, keypair: P256PrivateKey|Secp256k1PrivateKey}>}
150 */
151 async getKeyPair(privateKeyString, type = 'p256') {
152 const HEX_REGEX = /^[0-9a-f]+$/i;
153 const MULTIKEY_REGEX = /^z[a-km-zA-HJ-NP-Z1-9]+$/;
154 let keypair = undefined;
155
156 if (HEX_REGEX.test(privateKeyString)) {
157 const privateKeyBytes = fromBase16(privateKeyString);
158
159 switch (type) {
160 case 'p256': {
161 keypair = await P256PrivateKey.importRaw(privateKeyBytes);
162 break;
163 }
164 case 'secp256k1': {
165 keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes);
166 break;
167 }
168 default: {
169 throw new Error(`unsupported "${type}" type`);
170 }
171 }
172 } else if (MULTIKEY_REGEX.test(privateKeyString)) {
173
174 const match = parsePrivateMultikey(privateKeyString);
175 console.log(match);
176 const privateKeyBytes = match.privateKeyBytes;
177
178 switch (match.type) {
179 case 'p256': {
180 keypair = await P256PrivateKey.importRaw(privateKeyBytes);
181 console.log(keypair);
182 break;
183 }
184 case 'secp256k1': {
185 keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes);
186 break;
187 }
188 default: {
189 throw new Error(`unsupported "${type}" type`);
190 }
191 }
192 } else {
193 throw new Error('unknown input format');
194 }
195
196 return {
197 type: 'private_key',
198 didPublicKey: await keypair.exportPublicKey('did'),
199 keypair: keypair,
200 };
201 }
202
203 async pushPlcOperation(did, operation) {
204 const response = await fetch(`${PLC_DIRECTORY_URL}/${did}`, {
205 method: 'post',
206 headers: {
207 'content-type': 'application/json',
208 },
209 body: JSON.stringify(operation),
210 });
211
212 const headers = response.headers;
213 if (!response.ok) {
214 const type = headers.get('content-type');
215
216 if (type?.includes('application/json')) {
217 const json = await response.json();
218 if (typeof json === 'object' && json !== null && typeof json.message === 'string') {
219 throw new Error(json.message);
220 }
221 }
222
223 throw new Error(`got http ${response.status} from plc`);
224 }
225 };
226
227
228 /**
229 *
230 * @param iss The user's did
231 * @param aud The did:web, if it's a PDS it's usually from /xrpc/com.atproto.server.describeServer
232 * @param keypair The keypair to sign with only supporting ES256K atm
233 * @param lxm The lxm which is usually com.atproto.server.createAccount for creating a new account
234 * @returns {Promise<string>}
235 */
236 async createANewServiceAuthToken(iss, aud, keypair, lxm) {
237
238
239 // Compute iat/exp defaults (60s window like reference: MINUTE/1e3)
240 const iat = Math.floor(Date.now() / 1e3);
241 const exp = iat + 60;
242
243 // Generate a 16-byte hex jti
244 const jti = (() => {
245 const bytes = new Uint8Array(16);
246 // crypto in browser or node; fall back safely
247 (globalThis.crypto || window.crypto).getRandomValues(bytes);
248 return Array.from(bytes).map(b => b.toString(16).padStart(2, '0')).join('');
249 })();
250
251
252 // Build header and payload (omit undefined fields)
253 // Just defaulting to ES256K since p256 was not importing on firefox
254 const header = {typ: 'JWT', alg: 'ES256K'};
255 const payload = {};
256 payload.iat = iat;
257 payload.iss = iss;
258 payload.aud = aud;
259 payload.exp = exp;
260 payload.lxm = lxm;
261 payload.jti = jti;
262
263 const headerB64 = jsonToB64Url(header);
264 const payloadB64 = jsonToB64Url(payload);
265 const toSignStr = `${headerB64}.${payloadB64}`;
266
267 // Sign
268 const toSignBytes = new TextEncoder().encode(toSignStr);
269 const sigBytes = await keypair.sign(toSignBytes);
270
271 // Return compact JWS
272 const sigB64 = toBase64Url(sigBytes);
273 return `${toSignStr}.${sigB64}`;
274 }
275
276
277}
278
279
280export {PlcOps};