diff --git a/scripts/setup.js b/scripts/setup.js index e335f04..e558ff4 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -35,12 +35,12 @@ function parseArgs() { } } - if (!opts.handle || !opts.pds) { - console.error('Usage: node scripts/setup.js --handle --pds ') + if (!opts.pds) { + console.error('Usage: node scripts/setup.js --pds [--handle ]') console.error('') console.error('Options:') - console.error(' --handle Subdomain handle (e.g., "alice")') console.error(' --pds PDS URL (e.g., "https://atproto-pds.chad-53c.workers.dev")') + console.error(' --handle Subdomain handle (e.g., "alice") - optional, uses bare hostname if omitted') console.error(' --plc-url PLC directory URL (default: https://plc.directory)') console.error(' --relay-url Relay URL (default: https://bsky.network)') process.exit(1) @@ -289,9 +289,9 @@ function base64UrlEncode(bytes) { async function createGenesisOperation(opts) { const { didKey, handle, pdsUrl, cryptoKey } = opts - // Build full handle: subdomain.pds-hostname + // Build full handle: subdomain.pds-hostname, or just pds-hostname if no subdomain const pdsHost = new URL(pdsUrl).host - const fullHandle = `${handle}.${pdsHost}` + const fullHandle = handle ? `${handle}.${pdsHost}` : pdsHost const operation = { type: 'plc_operation', @@ -486,11 +486,13 @@ async function main() { console.log(' PDS initialized!') console.log('') - // Step 4b: Register handle -> DID mapping - console.log(`Registering handle mapping...`) - await registerHandle(opts.pds, opts.handle, did) - console.log(` Handle ${opts.handle} -> ${did}`) - console.log('') + // Step 4b: Register handle -> DID mapping (only for subdomain handles) + if (opts.handle) { + console.log(`Registering handle mapping...`) + await registerHandle(opts.pds, opts.handle, did) + console.log(` Handle ${opts.handle} -> ${did}`) + console.log('') + } // Step 5: Notify relay const pdsHostname = new URL(opts.pds).host @@ -511,7 +513,7 @@ async function main() { createdAt: new Date().toISOString() } - const credentialsFile = `./credentials-${opts.handle}.json` + const credentialsFile = `./credentials-${opts.handle || new URL(opts.pds).host}.json` saveCredentials(credentialsFile, credentials) // Final output diff --git a/scripts/update-did.js b/scripts/update-did.js new file mode 100644 index 0000000..0cdc0c1 --- /dev/null +++ b/scripts/update-did.js @@ -0,0 +1,267 @@ +#!/usr/bin/env node + +/** + * Update DID handle and PDS endpoint + * + * Usage: node scripts/update-did.js --credentials --new-handle --new-pds + */ + +import { webcrypto } from 'crypto' +import { readFileSync, writeFileSync } from '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 --new-handle --new-pds ') + 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) +}) diff --git a/src/pds.js b/src/pds.js index 01da807..8b5ebf9 100644 --- a/src/pds.js +++ b/src/pds.js @@ -1585,17 +1585,15 @@ async function handleRequest(request, env) { const url = new URL(request.url) const subdomain = getSubdomain(url.hostname) - // Handle resolution via subdomain + // Handle resolution via subdomain or bare domain if (url.pathname === '/.well-known/atproto-did') { - if (!subdomain) { - // Bare domain - no user here - return new Response('No user at bare domain. Use a subdomain like alice.' + url.hostname, { status: 404 }) - } // Look up handle -> DID in default DO + // Use subdomain if present, otherwise try bare hostname as handle + const handleToResolve = subdomain || url.hostname const defaultId = env.PDS.idFromName('default') const defaultPds = env.PDS.get(defaultId) const resolveRes = await defaultPds.fetch( - new Request(`http://internal/resolve-handle?handle=${encodeURIComponent(subdomain)}`) + new Request(`http://internal/resolve-handle?handle=${encodeURIComponent(handleToResolve)}`) ) if (!resolveRes.ok) { return new Response('Handle not found', { status: 404 })