#!/usr/bin/env node /** * Rotate an account's signing key onto a new curve. * * Two writes, in this order. The PLC directory learns the new key first, * signed by the rotation key the genesis operation named, which is the key * being replaced. The PDS adopts it second, through /init. Doing it the other * way would leave the PDS signing with a key the directory does not publish, * so nothing could verify its commits until the directory caught up. * * Usage: * PDS_PASSWORD=... node scripts/rotate-key.js \ * --credentials ./credentials-alice.example.com.json \ * [--recovery ./recovery-alice.example.com.json] [--curve secp256k1] * * The new key is written to disk before the directory hears about it. An * account whose genesis named only one rotation key has no way back from a * key lost between those two writes, so the order is the safety. * * The credentials file is rewritten, and the old key kept beside it. */ import { copyFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { bytesToHex, importPrivateKey } from '@pdsjs/core/crypto'; import { computePlcOperationCid, publicKeyToDidKey, signPlcOperationObject, } from '@pdsjs/core/plc'; import { k256Signer } from '@pdsjs/node'; function parseArgs() { const args = process.argv.slice(2); const opts = { credentials: null, recovery: null, curve: 'secp256k1', plcUrl: 'https://plc.directory', }; for (let i = 0; i < args.length; i++) { if (args[i] === '--credentials' && args[i + 1]) opts.credentials = args[++i]; else if (args[i] === '--recovery' && args[i + 1]) opts.recovery = args[++i]; else if (args[i] === '--curve' && args[i + 1]) opts.curve = args[++i]; else if (args[i] === '--plc-url' && args[i + 1]) opts.plcUrl = args[++i]; } if (!opts.credentials) { console.error( 'Usage: node scripts/rotate-key.js --credentials [--recovery ] [--curve secp256k1|p256]', ); console.error(''); console.error( " --recovery The account's recovery key. It outranks the signing key,", ); console.error( ' so it can rotate past an operation the signing key cannot.', ); console.error(''); console.error('Reads PDS_PASSWORD from the environment to authenticate.'); process.exit(1); } if (opts.curve !== 'secp256k1' && opts.curve !== 'p256') { console.error('--curve must be secp256k1 or p256'); process.exit(1); } return opts; } /** @param {string} hex */ function hexToBytes(hex) { const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < hex.length; i += 2) { bytes[i / 2] = Number.parseInt(hex.slice(i, i + 2), 16); } return bytes; } /** * The signer for a stored key, on whichever curve it was made. * @param {Uint8Array} privateKey * @param {'secp256k1'|'p256'} curve */ async function signerFor(privateKey, curve) { if (curve !== 'secp256k1') return importPrivateKey(privateKey); return { curve, sign: (/** @type {Uint8Array} */ data) => k256Signer.sign(privateKey, data), publicKey: async () => k256Signer.getPublicKey(privateKey, true), }; } /** @param {'secp256k1'|'p256'} curve */ async function generateAccountKey(curve) { if (curve === 'secp256k1') { const privateKey = k256Signer.generatePrivateKey(); return { privateKey, publicKey: k256Signer.getPublicKey(privateKey, true) }; } const { generateKeyPair } = await import('@pdsjs/core/crypto'); const keyPair = await generateKeyPair(); return { privateKey: keyPair.privateKey, publicKey: keyPair.publicKey }; } /** * The operation the new one builds on: the most recent that names the key we * hold as a rotation key. * * Normally that is the head. When it is not, an earlier rotation published a * key that never reached the PDS, and building on the last operation we can * still sign nullifies it. PLC accepts that for 72 hours, because the key * signing it has equal authority to the one that signed what it replaces. * @param {string} plcUrl * @param {string} did * @param {string} ourDidKey */ async function fetchPlcState(plcUrl, did, ourDidKey) { const res = await fetch(`${plcUrl}/${encodeURIComponent(did)}/log/audit`); if (!res.ok) { throw new Error(`PLC audit log unavailable: ${res.status}`); } const log = await res.json(); const live = log.filter( (/** @type {{nullified: boolean}} */ e) => !e.nullified, ); if (live.length === 0) throw new Error('PLC audit log is empty'); for (let i = live.length - 1; i >= 0; i--) { if (live[i].operation.rotationKeys.includes(ourDidKey)) { return { operation: live[i].operation, cid: live[i].cid, nullifies: live.length - 1 - i, }; } } throw new Error( `no operation in the PLC log names ${ourDidKey} as a rotation key, so this key cannot rotate the account`, ); } async function main() { const opts = parseArgs(); const creds = JSON.parse(readFileSync(opts.credentials, 'utf8')); const oldCurve = creds.curve === 'secp256k1' ? 'secp256k1' : 'p256'; console.log('Signing key rotation'); console.log('===================='); console.log(`DID: ${creds.did}`); console.log(`PDS: ${creds.pdsUrl}`); console.log(`Curve: ${oldCurve} -> ${opts.curve}`); console.log(''); if (oldCurve === opts.curve) { console.log(`Already on ${opts.curve}. Nothing to do.`); return; } const password = process.env.PDS_PASSWORD; if (!password) { console.error('PDS_PASSWORD must be set to authenticate the /init write.'); process.exit(1); } console.log(`Generating ${opts.curve} keypair...`); const next = await generateAccountKey(opts.curve); const nextDidKey = publicKeyToDidKey(next.publicKey, opts.curve); console.log(` did:key: ${nextDidKey}`); // The key reaches disk before the directory hears about it. Publishing // first and saving after loses the account's rotation key to any failure // in between, and the key that could recover it is the one being replaced. const pendingFile = `${opts.credentials}.pending`; writeFileSync( pendingFile, `${JSON.stringify( { did: creds.did, curve: opts.curve, privateKeyHex: bytesToHex(next.privateKey), didKey: nextDidKey, note: 'Generated for a rotation that had not finished. Safe to delete once the credentials file names this key.', }, null, 2, )}\n`, { mode: 0o600 }, ); console.log(` held at: ${pendingFile}`); console.log(''); // The recovery key outranks the signing key, so it can build on an // operation the signing key can no longer sign for. const recovery = opts.recovery ? JSON.parse(readFileSync(opts.recovery, 'utf8')) : null; const signerCreds = recovery ?? creds; const signerCurve = signerCreds.curve === 'secp256k1' ? 'secp256k1' : 'p256'; if (recovery) console.log(`Signing with the recovery key ${recovery.didKey}`); console.log('Reading the current PLC state...'); const state = await fetchPlcState(opts.plcUrl, creds.did, signerCreds.didKey); console.log(` prev: ${state.cid}`); if (state.nullifies > 0) { console.log( ` nullifies ${state.nullifies} later operation(s) this key cannot sign for`, ); } console.log(''); // The new key takes both roles the old one held: it verifies the account's // commits, and it signs the next rotation. const operation = { type: 'plc_operation', // The recovery key stays, and stays first. A rotation that dropped it // would leave the new signing key as the only thing that can rotate // again, which is the state this script exists to get out of. rotationKeys: recovery ? [recovery.didKey, nextDidKey] : state.operation.rotationKeys .filter((/** @type {string} */ key) => key !== creds.didKey) .concat(nextDidKey), // Every other verification method, the KEM key among them, stays. verificationMethods: { ...state.operation.verificationMethods, atproto: nextDidKey, }, alsoKnownAs: state.operation.alsoKnownAs, services: state.operation.services, prev: state.cid, }; console.log('Signing the operation with the current rotation key...'); const signer = await signerFor( hexToBytes(signerCreds.privateKeyHex), signerCurve, ); const signed = await signPlcOperationObject(operation, signer); console.log(` cid: ${await computePlcOperationCid(signed)}`); console.log(''); console.log(`Submitting to ${opts.plcUrl}...`); const plcRes = await fetch( `${opts.plcUrl}/${encodeURIComponent(creds.did)}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(signed), }, ); if (!plcRes.ok) { throw new Error( `PLC rejected the operation: ${plcRes.status} ${await plcRes.text()}`, ); } console.log(' Accepted.'); console.log(''); console.log(`Handing the key to ${creds.pdsUrl}...`); // An initialized server takes the account's own session here. The password // in the body only gates the first bootstrap, when no account exists to // authenticate as. const sessionRes = await fetch( `${creds.pdsUrl}/xrpc/com.atproto.server.createSession`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ identifier: creds.handle, password }), }, ); if (!sessionRes.ok) { throw new Error( `could not sign in to the PDS: ${sessionRes.status} ${await sessionRes.text()}`, ); } const { accessJwt } = await sessionRes.json(); const initRes = await fetch(`${creds.pdsUrl}/init`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${accessJwt}`, }, body: JSON.stringify({ did: creds.did, privateKey: bytesToHex(next.privateKey), handle: creds.handle, curve: opts.curve, }), }); if (!initRes.ok) { throw new Error( `PDS refused the key: ${initRes.status} ${await initRes.text()}`, ); } console.log(' Adopted.'); console.log(''); copyFileSync(opts.credentials, `${opts.credentials}.${oldCurve}.bak`); writeFileSync( opts.credentials, `${JSON.stringify( { ...creds, curve: opts.curve, privateKeyHex: bytesToHex(next.privateKey), didKey: nextDidKey, rotatedAt: new Date().toISOString(), }, null, 2, )}\n`, ); rmSync(pendingFile, { force: true }); console.log('Rotation complete.'); console.log(` Credentials rewritten: ${opts.credentials}`); console.log(` Previous key kept at: ${opts.credentials}.${oldCurve}.bak`); } main().catch((err) => { console.error(`fatal: ${err instanceof Error ? err.message : err}`); process.exit(1); });