From 13b1ab305ed00ea734ba5edd5b137a46214cb686 Mon Sep 17 00:00:00 2001 From: Chad Miller Date: Mon, 5 Jan 2026 08:34:52 -0800 Subject: [PATCH] feat: add PLC operation signing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- scripts/setup.js | 185 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 183 insertions(+), 2 deletions(-) diff --git a/scripts/setup.js b/scripts/setup.js index dbc3e5e..c6bfae8 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -148,6 +148,175 @@ function base58btcEncode(bytes) { return result } +// === CBOR ENCODING (minimal for PLC operations) === + +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).sort() + encodeHead(5, keys.length) + for (const key of keys) { + 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) +} + +// === HASHING === + +async function sha256(data) { + const hash = await webcrypto.subtle.digest('SHA-256', data) + return new Uint8Array(hash) +} + +// === PLC OPERATIONS === + +async function signPlcOperation(operation, privateKey) { + // Encode operation without sig field + const { sig, ...opWithoutSig } = operation + const encoded = cborEncode(opWithoutSig) + + // Sign with P-256 + const signature = await webcrypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + privateKey, + encoded + ) + + // Convert to low-S form and base64url encode + const sigBytes = ensureLowS(new Uint8Array(signature)) + return base64UrlEncode(sigBytes) +} + +function ensureLowS(sig) { + // P-256 order N + const N = BigInt('0xFFFFFFFF00000000FFFFFFFFFFFFFFFFBCE6FAADA7179E84F3B9CAC2FC632551') + const halfN = N / 2n + + const r = sig.slice(0, 32) + const s = sig.slice(32, 64) + + // Convert s to BigInt + let sInt = BigInt('0x' + bytesToHex(s)) + + // If s > N/2, replace with N - s + if (sInt > halfN) { + sInt = 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 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 base64UrlEncode(bytes) { + const binary = String.fromCharCode(...bytes) + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +async function createGenesisOperation(opts) { + const { didKey, handle, pdsUrl, cryptoKey } = opts + + // Build the full handle + const pdsHost = new URL(pdsUrl).host + const fullHandle = `${handle}.${pdsHost}` + + const operation = { + type: 'plc_operation', + rotationKeys: [didKey], + verificationMethods: { + atproto: didKey + }, + alsoKnownAs: [`at://${fullHandle}`], + services: { + atproto_pds: { + type: 'AtprotoPersonalDataServer', + endpoint: pdsUrl + } + }, + prev: null + } + + // Sign the operation + operation.sig = await signPlcOperation(operation, cryptoKey) + + return { operation, fullHandle } +} + +async function deriveDidFromOperation(operation) { + const { sig, ...opWithoutSig } = operation + const encoded = cborEncode(opWithoutSig) + const hash = await sha256(encoded) + // DID is base32 of first 24 bytes of hash + return 'did:plc:' + base32Encode(hash.slice(0, 24)) +} + +function base32Encode(bytes) { + const alphabet = 'abcdefghijklmnopqrstuvwxyz234567' + let result = '' + let bits = 0 + let value = 0 + + for (const byte of bytes) { + value = (value << 8) | byte + bits += 8 + while (bits >= 5) { + bits -= 5 + result += alphabet[(value >> bits) & 31] + } + } + + if (bits > 0) { + result += alphabet[(value << (5 - bits)) & 31] + } + + return result +} + // === MAIN === async function main() { @@ -164,10 +333,22 @@ async function main() { const keyPair = await generateP256Keypair() const didKey = publicKeyToDidKey(keyPair.publicKey) console.log(` did:key: ${didKey}`) - console.log(` Private key: ${bytesToHex(keyPair.privateKey)}`) console.log('') - // TODO: Register DID:PLC + // Step 2: Create genesis operation + console.log('Creating PLC genesis operation...') + const { operation, fullHandle } = await createGenesisOperation({ + didKey, + handle: opts.handle, + pdsUrl: opts.pds, + cryptoKey: keyPair.cryptoKey + }) + const did = await deriveDidFromOperation(operation) + console.log(` DID: ${did}`) + console.log(` Handle: ${fullHandle}`) + console.log('') + + // TODO: Register with plc.directory // TODO: Initialize PDS // TODO: Notify relay } -- 2.51.2