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 * JSDoc type-only import to avoid runtime import errors in the browser.
3 * @typedef {import('@atcute/did-plc').defs} defs
4 * @typedef {import('@atcute/did-plc').normalizeOp} normalizeOp
5 * @typedef {import('@atcute/did-plc').Operation} Operation
6 * @typedef {import('@atcute/did-plc').CompatibleOperation} CompatibleOperation
7 * @typedef {import('@atcute/did-plc').IndexedEntryLog} IndexedEntryLog
8 * @typedef {import('@atcute/did-plc').IndexedEntry} IndexedEntry
9 */
10
11import { defs, normalizeOp } from '@atcute/did-plc'
12import {
13 P256PrivateKey,
14 parsePrivateMultikey,
15 Secp256k1PrivateKey,
16 Secp256k1PrivateKeyExportable,
17} from '@atcute/crypto'
18import * as CBOR from '@atcute/cbor'
19import { fromBase16, toBase64Url } from '@atcute/multibase'
20
21// Helper to base64url-encode JSON
22const jsonToB64Url = obj => {
23 const enc = new TextEncoder()
24 const json = JSON.stringify(obj)
25 return toBase64Url(enc.encode(json))
26}
27
28/**
29 * Class to help with various PLC operations
30 */
31class PlcOps {
32 /**
33 *
34 * @param plcDirectoryUrl {string} - The url of the plc directory, defaults to https://plc.directory
35 */
36 constructor(plcDirectoryUrl = 'https://plc.directory') {
37 /**
38 * The url of the plc directory
39 * @type {string}
40 */
41 this.plcDirectoryUrl = plcDirectoryUrl
42 }
43
44 /**
45 * Gets the current rotation keys for a user via their last PlC operation
46 * @param did
47 * @returns {Promise<string[]>}
48 */
49 async getCurrentRotationKeysForUser(did) {
50 const logs = await this.getPlcAuditLogs(did)
51 const { lastOperation } = this.getLastPlcOp(logs)
52 return lastOperation.rotationKeys || []
53 }
54
55 /**
56 * Gets the last PlC operation for a user from the plc directory
57 * @param did
58 * @returns {Promise<{lastOperation: Operation, base: any}>}
59 */
60 async getLastPlcOpFromPlc(did) {
61 const logs = await this.getPlcAuditLogs(did)
62 return this.getLastPlcOp(logs)
63 }
64
65 /**
66 *
67 * @param logs {IndexedEntryLog}
68 * @returns {{lastOperation: Operation, base: IndexedEntry}}
69 */
70 getLastPlcOp(logs) {
71 const lastOp = logs.at(-1)
72 return { lastOperation: normalizeOp(lastOp.operation), base: lastOp }
73 }
74
75 /**
76 * Gets the plc audit logs for a user from the plc directory
77 * @param did
78 * @returns {Promise<IndexedEntryLog>}
79 */
80 async getPlcAuditLogs(did) {
81 const response = await fetch(`${this.plcDirectoryUrl}/${did}/log/audit`)
82 if (!response.ok) {
83 throw new Error(`got response ${response.status}`)
84 }
85
86 const json = await response.json()
87 return defs.indexedEntryLog.parse(json)
88 }
89
90 /**
91 * Creates a new secp256k1 key that can be used for either rotation or verification key
92 * @returns {Promise<{privateKey: string, publicKey: `did:key:${string}`}>}
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 * Signs a new operation with the provided signing key, and information and submits it to the plc directory
106 * @param did {string} - The user's did
107 * @param signingRotationKey { P256PrivateKey|Secp256k1PrivateKey} - The keypair to sign the op with
108 * @param alsoKnownAs {string[]}
109 * @param rotationKeys {string[]}
110 * @param pds {string}
111 * @param verificationKey {string} - The public verification key
112 * @param prev {string} - The previous valid operation's cid.
113 * @returns {Promise<void>}
114 */
115 async signAndPublishNewOp(
116 did,
117 signingRotationKey,
118 alsoKnownAs,
119 rotationKeys,
120 pds,
121 verificationKey,
122 prev,
123 ) {
124 const rotationKeysToUse = [...new Set(rotationKeys)]
125 if (!rotationKeysToUse) {
126 throw new Error('No rotation keys were found to be added to the PLC')
127 }
128
129 if (rotationKeysToUse.length > 5) {
130 throw new Error('You can only add up to 5 rotation keys to the PLC')
131 }
132
133 const operation = {
134 type: 'plc_operation',
135 prev,
136 alsoKnownAs,
137 rotationKeys: rotationKeysToUse,
138 services: {
139 atproto_pds: {
140 type: 'AtprotoPersonalDataServer',
141 endpoint: pds,
142 },
143 },
144 verificationMethods: {
145 atproto: verificationKey,
146 },
147 }
148 const opBytes = CBOR.encode(operation)
149 const sigBytes = await signingRotationKey.sign(opBytes)
150
151 const signature = toBase64Url(sigBytes)
152
153 const signedOperation = {
154 ...operation,
155 sig: signature,
156 }
157
158 await this.pushPlcOperation(did, signedOperation)
159 }
160
161 /**
162 * Takes a multi or hex based private key and returns a keypair
163 * @param privateKeyString {string}
164 * @param type {string} - secp256k1 or p256, needed if the private key is hex based, can be assumed if it's a multikey
165 * @returns {Promise<{type: string, didPublicKey: `did:key:${string}`, keypair: P256PrivateKey|Secp256k1PrivateKey}>}
166 */
167 async getKeyPair(privateKeyString, type = 'secp256k1') {
168 const HEX_REGEX = /^[0-9a-f]+$/i
169 const MULTIKEY_REGEX = /^z[a-km-zA-HJ-NP-Z1-9]+$/
170 let keypair = undefined
171
172 if (HEX_REGEX.test(privateKeyString)) {
173 const privateKeyBytes = fromBase16(privateKeyString)
174
175 switch (type) {
176 case 'p256': {
177 keypair = await P256PrivateKey.importRaw(privateKeyBytes)
178 break
179 }
180 case 'secp256k1': {
181 keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes)
182 break
183 }
184 default: {
185 throw new Error(`unsupported "${type}" type`)
186 }
187 }
188 } else if (MULTIKEY_REGEX.test(privateKeyString)) {
189 const match = parsePrivateMultikey(privateKeyString)
190 const privateKeyBytes = match.privateKeyBytes
191
192 switch (match.type) {
193 case 'p256': {
194 keypair = await P256PrivateKey.importRaw(privateKeyBytes)
195 console.log(keypair)
196 break
197 }
198 case 'secp256k1': {
199 keypair = await Secp256k1PrivateKey.importRaw(privateKeyBytes)
200 break
201 }
202 default: {
203 throw new Error(`unsupported "${type}" type`)
204 }
205 }
206 } else {
207 throw new Error('unknown input format')
208 }
209 return {
210 type: 'private_key',
211 didPublicKey: await keypair.exportPublicKey('did'),
212 keypair: keypair,
213 }
214 }
215
216 /**
217 * Submits a new operation to the plc directory
218 * @param did {string} - The user's did
219 * @param operation
220 * @returns {Promise<void>}
221 */
222 async pushPlcOperation(did, operation) {
223 const response = await fetch(`${this.plcDirectoryUrl}/${did}`, {
224 method: 'post',
225 headers: {
226 'content-type': 'application/json',
227 },
228 body: JSON.stringify(operation),
229 })
230
231 const headers = response.headers
232 if (!response.ok) {
233 const type = headers.get('content-type')
234
235 if (type?.includes('application/json')) {
236 const json = await response.json()
237 if (typeof json === 'object' && json !== null && typeof json.message === 'string') {
238 throw new Error(json.message)
239 }
240 }
241
242 throw new Error(`got http ${response.status} from plc`)
243 }
244 }
245
246 /**
247 * Creates a new service auth token for a user. This is what is used to create a new account on a PDS for your did
248 *
249 * @param iss The user's did
250 * @param aud The did:web, if it's a PDS it's usually from /xrpc/com.atproto.server.describeServer
251 * @param keypair The keypair to sign with only supporting ES256K atm
252 * @param lxm The lxm which is usually com.atproto.server.createAccount for creating a new account
253 * @returns {Promise<string>}
254 */
255 async createANewServiceAuthToken(iss, aud, keypair, lxm) {
256 // Compute iat/exp defaults (60s window like reference: MINUTE/1e3)
257 const iat = Math.floor(Date.now() / 1e3)
258 const exp = iat + 60
259
260 // Generate a 16-byte hex jti
261 const jti = (() => {
262 const bytes = new Uint8Array(16)
263 // crypto in browser or node; fall back safely
264 ;(globalThis.crypto || window.crypto).getRandomValues(bytes)
265 return Array.from(bytes)
266 .map(b => b.toString(16).padStart(2, '0'))
267 .join('')
268 })()
269
270 // Build header and payload (omit undefined fields)
271 // Just defaulting to ES256K since p256 was not importing on firefox
272 const header = { typ: 'JWT', alg: 'ES256K' }
273 const payload = {}
274 payload.iat = iat
275 payload.iss = iss
276 payload.aud = aud
277 payload.exp = exp
278 payload.lxm = lxm
279 payload.jti = jti
280
281 const headerB64 = jsonToB64Url(header)
282 const payloadB64 = jsonToB64Url(payload)
283 const toSignStr = `${headerB64}.${payloadB64}`
284
285 // Sign
286 const toSignBytes = new TextEncoder().encode(toSignStr)
287 const sigBytes = await keypair.sign(toSignBytes)
288
289 // Return compact JWS
290 const sigB64 = toBase64Url(sigBytes)
291 return `${toSignStr}.${sigB64}`
292 }
293}
294
295export { PlcOps }