chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
#!/usr/bin/env node
/**
* Update DID handle and PDS endpoint
*
* Usage: node scripts/update-did.js --credentials <file> --new-handle <handle> --new-pds <url>
*/
import { webcrypto } from 'node:crypto';
import { readFileSync, writeFileSync } from 'node:fs';
// === ARGUMENT PARSING ===
function parseArgs() {
const args = process.argv.slice(2);
const opts = {
credentials: null,
newHandle: null,
newPds: null,
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] === '--new-handle' && args[i + 1]) {
opts.newHandle = args[++i];
} else if (args[i] === '--new-pds' && args[i + 1]) {
opts.newPds = args[++i];
} else if (args[i] === '--plc-url' && args[i + 1]) {
opts.plcUrl = args[++i];
}
}
if (!opts.credentials || !opts.newHandle || !opts.newPds) {
console.error(
'Usage: node scripts/update-did.js --credentials <file> --new-handle <handle> --new-pds <url>',
);
process.exit(1);
}
return opts;
}
// === CRYPTO HELPERS ===
function hexToBytes(hex) {
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < hex.length; i += 2) {
bytes[i / 2] = parseInt(hex.substr(i, 2), 16);
}
return bytes;
}
function bytesToHex(bytes) {
return Array.from(bytes)
.map((b) => b.toString(16).padStart(2, '0'))
.join('');
}
async function importPrivateKey(privateKeyBytes) {
const pkcs8Prefix = new Uint8Array([
0x30, 0x41, 0x02, 0x01, 0x00, 0x30, 0x13, 0x06, 0x07, 0x2a, 0x86, 0x48,
0xce, 0x3d, 0x02, 0x01, 0x06, 0x08, 0x2a, 0x86, 0x48, 0xce, 0x3d, 0x03,
0x01, 0x07, 0x04, 0x27, 0x30, 0x25, 0x02, 0x01, 0x01, 0x04, 0x20,
]);
const pkcs8 = new Uint8Array(pkcs8Prefix.length + 32);
pkcs8.set(pkcs8Prefix);
pkcs8.set(privateKeyBytes, pkcs8Prefix.length);
return webcrypto.subtle.importKey(
'pkcs8',
pkcs8,
{ name: 'ECDSA', namedCurve: 'P-256' },
false,
['sign'],
);
}
// === CBOR ENCODING ===
function cborEncodeKey(key) {
const bytes = new TextEncoder().encode(key);
const parts = [];
const mt = 3 << 5;
if (bytes.length < 24) {
parts.push(mt | bytes.length);
} else if (bytes.length < 256) {
parts.push(mt | 24, bytes.length);
}
parts.push(...bytes);
return new Uint8Array(parts);
}
function compareBytes(a, b) {
const minLen = Math.min(a.length, b.length);
for (let i = 0; i < minLen; i++) {
if (a[i] !== b[i]) return a[i] - b[i];
}
return a.length - b.length;
}
function cborEncode(value) {
const parts = [];
function encode(val) {
if (val === null) {
parts.push(0xf6);
} else if (typeof val === 'string') {
const bytes = new TextEncoder().encode(val);
encodeHead(3, bytes.length);
parts.push(...bytes);
} else if (typeof val === 'number') {
if (Number.isInteger(val) && val >= 0) {
encodeHead(0, val);
}
} else if (val instanceof Uint8Array) {
encodeHead(2, val.length);
parts.push(...val);
} else if (Array.isArray(val)) {
encodeHead(4, val.length);
for (const item of val) encode(item);
} else if (typeof val === 'object') {
const keys = Object.keys(val);
const keysSorted = keys.sort((a, b) =>
compareBytes(cborEncodeKey(a), cborEncodeKey(b)),
);
encodeHead(5, keysSorted.length);
for (const key of keysSorted) {
encode(key);
encode(val[key]);
}
}
}
function encodeHead(majorType, length) {
const mt = majorType << 5;
if (length < 24) {
parts.push(mt | length);
} else if (length < 256) {
parts.push(mt | 24, length);
} else if (length < 65536) {
parts.push(mt | 25, length >> 8, length & 0xff);
}
}
encode(value);
return new Uint8Array(parts);
}
// === SIGNING ===
const P256_N = BigInt(
'0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551',
);
function ensureLowS(sig) {
const halfN = P256_N / 2n;
const r = sig.slice(0, 32);
const s = sig.slice(32, 64);
let sInt = BigInt(`0x${bytesToHex(s)}`);
if (sInt > halfN) {
sInt = P256_N - sInt;
const newS = hexToBytes(sInt.toString(16).padStart(64, '0'));
const result = new Uint8Array(64);
result.set(r);
result.set(newS, 32);
return result;
}
return sig;
}
function base64UrlEncode(bytes) {
const binary = String.fromCharCode(...bytes);
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
async function signPlcOperation(operation, privateKey) {
const { sig, ...opWithoutSig } = operation;
const encoded = cborEncode(opWithoutSig);
const signature = await webcrypto.subtle.sign(
{ name: 'ECDSA', hash: 'SHA-256' },
privateKey,
encoded,
);
const sigBytes = ensureLowS(new Uint8Array(signature));
return base64UrlEncode(sigBytes);
}
// === MAIN ===
async function main() {
const opts = parseArgs();
// Load credentials
const creds = JSON.parse(readFileSync(opts.credentials, 'utf-8'));
console.log(`Updating DID: ${creds.did}`);
console.log(` Old handle: ${creds.handle}`);
console.log(` New handle: ${opts.newHandle}`);
console.log(` New PDS: ${opts.newPds}`);
console.log('');
// Fetch current operation log
console.log('Fetching current PLC operation log...');
const logRes = await fetch(`${opts.plcUrl}/${creds.did}/log/audit`);
if (!logRes.ok) {
throw new Error(`Failed to fetch PLC log: ${logRes.status}`);
}
const log = await logRes.json();
const lastOp = log[log.length - 1];
console.log(` Found ${log.length} operations`);
console.log(` Last CID: ${lastOp.cid}`);
console.log('');
// Import private key
const privateKey = await importPrivateKey(hexToBytes(creds.privateKeyHex));
// Create new operation
const newOp = {
type: 'plc_operation',
rotationKeys: lastOp.operation.rotationKeys,
verificationMethods: lastOp.operation.verificationMethods,
alsoKnownAs: [`at://${opts.newHandle}`],
services: {
atproto_pds: {
type: 'AtprotoPersonalDataServer',
endpoint: opts.newPds,
},
},
prev: lastOp.cid,
};
// Sign the operation
console.log('Signing new operation...');
newOp.sig = await signPlcOperation(newOp, privateKey);
// Submit to PLC
console.log('Submitting to PLC directory...');
const submitRes = await fetch(`${opts.plcUrl}/${creds.did}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(newOp),
});
if (!submitRes.ok) {
const text = await submitRes.text();
throw new Error(`PLC update failed: ${submitRes.status} ${text}`);
}
console.log(' Updated successfully!');
console.log('');
// Update credentials file
creds.handle = opts.newHandle;
creds.pdsUrl = opts.newPds;
writeFileSync(opts.credentials, JSON.stringify(creds, null, 2));
console.log(`Updated credentials file: ${opts.credentials}`);
// Verify
console.log('');
console.log('Verifying...');
const verifyRes = await fetch(`${opts.plcUrl}/${creds.did}`);
const didDoc = await verifyRes.json();
console.log(` alsoKnownAs: ${didDoc.alsoKnownAs}`);
console.log(` PDS endpoint: ${didDoc.service[0].serviceEndpoint}`);
}
main().catch((err) => {
console.error('Error:', err.message);
process.exit(1);
});