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 295 lines 8.9 kB view raw
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 }