chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
#!/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 <file> [--recovery <file>] [--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);
});