| … | … | @@ -25,16 +25,25 @@ function err (message) { return { ok: false, error: message } } |
| 25 | 25 | |
| 26 | 26 | // ── §6 local validation mirror (pure — run BEFORE the irreversible write) ── |
| 27 | 27 | |
| 28 | // Verify an op's signature against a single did:key, honoring its curve (secp256k1 or P-256), | |
| 29 | // low-S enforced (§2). Returns false on any decode/verify error (never throws). | |
| 30 | export function verifyOpSig (signedOp, didKey) { | |
| 28 | // Verify an atproto ECDSA signature: sig over sha256(messageBytes) against a did:key, honoring | |
| 29 | // its curve (secp256k1 or P-256), low-S enforced (§2 — high-S and DER-encoded sigs are rejected). | |
| 30 | // The atproto signing primitive. Returns false on any decode/verify error (never throws). | |
| 31 | export function verifySig (messageBytes, sigBytes, didKey) { | |
| 31 | 32 | let dk |
| 32 | 33 | try { dk = decodeDidKey(didKey) } catch { return false } |
| 34 | // atproto requires the raw 64-byte (r‖s) compact form — DER-encoded sigs (longer) are INVALID | |
| 35 | // even if cryptographically sound (conformance rule §2). noble would otherwise parse DER. | |
| 36 | if (!sigBytes || sigBytes.length !== 64) return false | |
| 37 | const hash = sha256(messageBytes) | |
| 38 | const curve = dk.curve === CURVE_P256 ? p256 : secp256k1 | |
| 39 | try { return curve.verify(sigBytes, hash, dk.pubkey, { lowS: true }) } catch { return false } | |
| 40 | } | |
| 41 | ||
| 42 | // Verify a PLC op's signature (over dag-cbor of the unsigned op) against a did:key. | |
| 43 | export function verifyOpSig (signedOp, didKey) { | |
| 33 | 44 | let sig |
| 34 | 45 | try { sig = base64urlToBytes(signedOp.sig) } catch { return false } |
| 35 | const hash = sha256(encodeOp(unsignedOp(signedOp))) | |
| 36 | const curve = dk.curve === CURVE_P256 ? p256 : secp256k1 | |
| 37 | try { return curve.verify(sig, hash, dk.pubkey, { lowS: true }) } catch { return false } | |
| 46 | return verifySig(encodeOp(unsignedOp(signedOp)), sig, didKey) | |
| 38 | 47 | } |
| 39 | 48 | |
| 40 | 49 | // True iff the op is signed by AT LEAST ONE of the given rotation did:keys (the directory |
| … | … | @@ -0,0 +1,61 @@ |
| 1 | // PLC ceremony dry-run harness. Mints a FRESH THROWAWAY identity (never a real account), | |
| 2 | // validates the genesis + rotation ops locally (§6), and — only when PLC_SUBMIT=1 — fires the | |
| 3 | // full ceremony against the REAL plc.directory (the authoritative oracle) and confirms on-chain. | |
| 4 | // | |
| 5 | // node scripts/plc-dryrun.mjs # OFFLINE: mint + local-validate + print the ops | |
| 6 | // PLC_SUBMIT=1 node scripts/plc-dryrun.mjs # ALSO submit the throwaway to plc.directory (PERMANENT, junk DID) | |
| 7 | // | |
| 8 | // The submit is a permanent append to the global PLC op-log for a throwaway DID — harmless, but | |
| 9 | // irreversible. It is the authoritative cross-check that our op bytes are accepted by the real | |
| 10 | // directory before we touch a real account (wj62/s6cyl). | |
| 11 | ||
| 12 | import { generateMnemonic } from '@scure/bip39' | |
| 13 | import { wordlist } from '@scure/bip39/wordlists/english' | |
| 14 | import { selfCustodyIdentity, deriveAtprotoKey } from '../lib/atproto-identity.js' | |
| 15 | import { checkGenesisOp, checkUpdateOp, PlcDirectory } from '../lib/plc-directory.js' | |
| 16 | import { didForCreateOp, opCid } from '../lib/did-plc.js' | |
| 17 | ||
| 18 | const rootMnemonic = generateMnemonic(wordlist, 256) // fresh throwaway root (recovery authority) | |
| 19 | const deviceMnemonic = generateMnemonic(wordlist, 256) // fresh throwaway device signing key | |
| 20 | const handle = 'plc-dryrun-' + Date.now() + '.example.com' // plc.directory does NOT verify handles | |
| 21 | const pdsUrl = 'https://pds.7rnx.net' | |
| 22 | ||
| 23 | const id = selfCustodyIdentity({ rootMnemonic, handle, pdsUrl, deviceKey: deriveAtprotoKey(deviceMnemonic).privateKey }) | |
| 24 | ||
| 25 | console.log('== throwaway identity ==') | |
| 26 | console.log('did ', id.did) | |
| 27 | console.log('handle ', handle) | |
| 28 | console.log('rootDidKey ', id.rootDidKey, '(genesis vm + rotationKey)') | |
| 29 | console.log('deviceDidKey', id.deviceKey.didKey, '(rotation vm)') | |
| 30 | ||
| 31 | console.log('\n== offline validation (§6 mirror) ==') | |
| 32 | const g = checkGenesisOp(id.genesisOp, id.did) | |
| 33 | const r = checkUpdateOp(id.rotationOp, id.genesisOp) | |
| 34 | console.log('genesis :', g.ok ? 'OK' : 'FAIL — ' + g.error) | |
| 35 | console.log('rotation :', r.ok ? 'OK' : 'FAIL — ' + r.error) | |
| 36 | console.log('DID = hash(genesis)? ', didForCreateOp(id.genesisOp) === id.did) | |
| 37 | console.log('rotation.prev = cid(gen)? ', id.rotationOp.prev === opCid(id.genesisOp), '(' + id.rotationOp.prev + ')') | |
| 38 | ||
| 39 | console.log('\n== genesis op (would POST /:did --genesis) ==') | |
| 40 | console.log(JSON.stringify(id.genesisOp, null, 2)) | |
| 41 | console.log('\n== rotation op (would POST /:did) ==') | |
| 42 | console.log(JSON.stringify(id.rotationOp, null, 2)) | |
| 43 | ||
| 44 | if (process.env.PLC_SUBMIT !== '1') { | |
| 45 | console.log('\n[offline only] set PLC_SUBMIT=1 to submit this throwaway to the REAL plc.directory.') | |
| 46 | process.exit(0) | |
| 47 | } | |
| 48 | ||
| 49 | console.log('\n== SUBMITTING throwaway to https://plc.directory (PERMANENT, junk DID) ==') | |
| 50 | const plc = new PlcDirectory({}) | |
| 51 | const afterGenesis = await plc.submitGenesis({ did: id.did, signedOp: id.genesisOp }) | |
| 52 | console.log('genesis accepted. getData vm =', afterGenesis.verificationMethods.atproto, | |
| 53 | afterGenesis.verificationMethods.atproto === id.rootDidKey ? '(= root)' : '(UNEXPECTED)') | |
| 54 | const afterRotate = await plc.submitUpdate({ did: id.did, signedOp: id.rotationOp }) | |
| 55 | console.log('rotation accepted. getData vm =', afterRotate.verificationMethods.atproto, | |
| 56 | afterRotate.verificationMethods.atproto === id.deviceKey.didKey ? '(= device ✓)' : '(UNEXPECTED)') | |
| 57 | const doc = await plc.resolve(id.did) | |
| 58 | const vm = (doc.verificationMethod || []).find((m) => (m.id || '').endsWith('#atproto')) | |
| 59 | console.log('resolved DID doc #atproto verificationMethod:', vm && (vm.publicKeyMultibase || vm.id)) | |
| 60 | console.log('\nDID unchanged across genesis→rotation:', id.did) | |
| 61 | console.log('✓ ceremony validated end-to-end against the real plc.directory.') |
| … | … | @@ -0,0 +1,35 @@ |
| 1 | // Cross-check our PLC-op primitives against Bluesky's implementation-neutral conformance | |
| 2 | // vectors (atproto-interop-tests, vendored from ~/indigo/atproto/atcrypto/testdata, CC-0). These | |
| 3 | // pin the EXACT bytes the atproto specs require. If a fixture disagrees with our output, our code | |
| 4 | // is wrong — this is the offline, spec-authoritative cross-check that gates the PLC migration | |
| 5 | // (stronger than goat; no permanent write). See the atproto-interop-tests + did-plc skills. | |
| 6 | ||
| 7 | import test from 'brittle' | |
| 8 | import { readFileSync } from 'node:fs' | |
| 9 | import { fileURLToPath } from 'node:url' | |
| 10 | import { dirname, join } from 'node:path' | |
| 11 | import { keyFromPrivate } from '../lib/atproto-identity.js' | |
| 12 | import { verifySig } from '../lib/plc-directory.js' | |
| 13 | ||
| 14 | const here = dirname(fileURLToPath(import.meta.url)) | |
| 15 | const load = (name) => JSON.parse(readFileSync(join(here, 'vectors', name), 'utf8')) | |
| 16 | const b64 = (s) => new Uint8Array(Buffer.from(s, 'base64')) | |
| 17 | const hex = (s) => new Uint8Array(Buffer.from(s, 'hex')) | |
| 18 | ||
| 19 | test('did:key (secp256k1): private key → did:key byte-exact (w3c_didkey_K256)', (t) => { | |
| 20 | const cases = load('w3c_didkey_K256.json') | |
| 21 | t.ok(cases.length >= 5, cases.length + ' K256 did:key vectors') | |
| 22 | for (const c of cases) { | |
| 23 | t.is(keyFromPrivate(hex(c.privateKeyBytesHex)).didKey, c.publicDidKey, c.publicDidKey.slice(0, 24) + '…') | |
| 24 | } | |
| 25 | }) | |
| 26 | ||
| 27 | test('ECDSA low-S signature validity (signature-fixtures, both curves)', (t) => { | |
| 28 | const cases = load('signature-fixtures.json') | |
| 29 | t.ok(cases.length === 6, '6 signature vectors') | |
| 30 | for (const c of cases) { | |
| 31 | const ok = verifySig(b64(c.messageBase64), b64(c.signatureBase64), c.publicKeyDid) | |
| 32 | // valid low-S sigs verify true; high-S and DER-encoded sigs MUST verify false (§2 low-S rule) | |
| 33 | t.is(ok, c.validSignature, c.comment + (c.tags.length ? ' [' + c.tags.join(',') + ']' : '')) | |
| 34 | } | |
| 35 | }) |
| … | … | @@ -0,0 +1,68 @@ |
| 1 | [ | |
| 2 | { | |
| 3 | "comment": "valid P-256 key and signature, with low-S signature", | |
| 4 | "messageBase64": "oWVoZWxsb2V3b3JsZA", | |
| 5 | "algorithm": "ES256", | |
| 6 | "didDocSuite": "EcdsaSecp256r1VerificationKey2019", | |
| 7 | "publicKeyDid": "did:key:zDnaembgSGUhZULN2Caob4HLJPaxBh92N7rtH21TErzqf8HQo", | |
| 8 | "publicKeyMultibase": "zxdM8dSstjrpZaRUwBmDvjGXweKuEMVN95A9oJBFjkWMh", | |
| 9 | "signatureBase64": "2vZNsG3UKvvO/CDlrdvyZRISOFylinBh0Jupc6KcWoJWExHptCfduPleDbG3rko3YZnn9Lw0IjpixVmexJDegg", | |
| 10 | "validSignature": true, | |
| 11 | "tags": [] | |
| 12 | }, | |
| 13 | { | |
| 14 | "comment": "valid K-256 key and signature, with low-S signature", | |
| 15 | "messageBase64": "oWVoZWxsb2V3b3JsZA", | |
| 16 | "algorithm": "ES256K", | |
| 17 | "didDocSuite": "EcdsaSecp256k1VerificationKey2019", | |
| 18 | "publicKeyDid": "did:key:zQ3shqwJEJyMBsBXCWyCBpUBMqxcon9oHB7mCvx4sSpMdLJwc", | |
| 19 | "publicKeyMultibase": "z25z9DTpsiYYJKGsWmSPJK2NFN8PcJtZig12K59UgW7q5t", | |
| 20 | "signatureBase64": "5WpdIuEUUfVUYaozsi8G0B3cWO09cgZbIIwg1t2YKdUn/FEznOndsz/qgiYb89zwxYCbB71f7yQK5Lr7NasfoA", | |
| 21 | "validSignature": true, | |
| 22 | "tags": [] | |
| 23 | }, | |
| 24 | { | |
| 25 | "comment": "P-256 key and signature, with non-low-S signature which is invalid in atproto", | |
| 26 | "messageBase64": "oWVoZWxsb2V3b3JsZA", | |
| 27 | "algorithm": "ES256", | |
| 28 | "didDocSuite": "EcdsaSecp256r1VerificationKey2019", | |
| 29 | "publicKeyDid": "did:key:zDnaembgSGUhZULN2Caob4HLJPaxBh92N7rtH21TErzqf8HQo", | |
| 30 | "publicKeyMultibase": "zxdM8dSstjrpZaRUwBmDvjGXweKuEMVN95A9oJBFjkWMh", | |
| 31 | "signatureBase64": "2vZNsG3UKvvO/CDlrdvyZRISOFylinBh0Jupc6KcWoKp7O4VS9giSAah8k5IUbXIW00SuOrjfEqQ9HEkN9JGzw", | |
| 32 | "validSignature": false, | |
| 33 | "tags": ["high-s"] | |
| 34 | }, | |
| 35 | { | |
| 36 | "comment": "K-256 key and signature, with non-low-S signature which is invalid in atproto", | |
| 37 | "messageBase64": "oWVoZWxsb2V3b3JsZA", | |
| 38 | "algorithm": "ES256K", | |
| 39 | "didDocSuite": "EcdsaSecp256k1VerificationKey2019", | |
| 40 | "publicKeyDid": "did:key:zQ3shqwJEJyMBsBXCWyCBpUBMqxcon9oHB7mCvx4sSpMdLJwc", | |
| 41 | "publicKeyMultibase": "z25z9DTpsiYYJKGsWmSPJK2NFN8PcJtZig12K59UgW7q5t", | |
| 42 | "signatureBase64": "5WpdIuEUUfVUYaozsi8G0B3cWO09cgZbIIwg1t2YKdXYA67MYxYiTMAVfdnkDCMN9S5B3vHosRe07aORmoshoQ", | |
| 43 | "validSignature": false, | |
| 44 | "tags": ["high-s"] | |
| 45 | }, | |
| 46 | { | |
| 47 | "comment": "P-256 key and signature, with DER-encoded signature which is invalid in atproto", | |
| 48 | "messageBase64": "oWVoZWxsb2V3b3JsZA", | |
| 49 | "algorithm": "ES256", | |
| 50 | "didDocSuite": "EcdsaSecp256r1VerificationKey2019", | |
| 51 | "publicKeyDid": "did:key:zDnaeT6hL2RnTdUhAPLij1QBkhYZnmuKyM7puQLW1tkF4Zkt8", | |
| 52 | "publicKeyMultibase": "ze8N2PPxnu19hmBQ58t5P3E9Yj6CqakJmTVCaKvf9Byq2", | |
| 53 | "signatureBase64": "MEQCIFxYelWJ9lNcAVt+jK0y/T+DC/X4ohFZ+m8f9SEItkY1AiACX7eXz5sgtaRrz/SdPR8kprnbHMQVde0T2R8yOTBweA", | |
| 54 | "validSignature": false, | |
| 55 | "tags": ["der-encoded"] | |
| 56 | }, | |
| 57 | { | |
| 58 | "comment": "K-256 key and signature, with DER-encoded signature which is invalid in atproto", | |
| 59 | "messageBase64": "oWVoZWxsb2V3b3JsZA", | |
| 60 | "algorithm": "ES256K", | |
| 61 | "didDocSuite": "EcdsaSecp256k1VerificationKey2019", | |
| 62 | "publicKeyDid": "did:key:zQ3shnriYMXc8wvkbJqfNWh5GXn2bVAeqTC92YuNbek4npqGF", | |
| 63 | "publicKeyMultibase": "z22uZXWP8fdHXi4jyx8cCDiBf9qQTsAe6VcycoMQPfcMQX", | |
| 64 | "signatureBase64": "MEUCIQCWumUqJqOCqInXF7AzhIRg2MhwRz2rWZcOEsOjPmNItgIgXJH7RnqfYY6M0eg33wU0sFYDlprwdOcpRn78Sz5ePgk", | |
| 65 | "validSignature": false, | |
| 66 | "tags": ["der-encoded"] | |
| 67 | } | |
| 68 | ] |
| … | … | @@ -0,0 +1,22 @@ |
| 1 | [ | |
| 2 | { | |
| 3 | "privateKeyBytesHex": "9085d2bef69286a6cbb51623c8fa258629945cd55ca705cc4e66700396894e0c", | |
| 4 | "publicDidKey": "did:key:zQ3shokFTS3brHcDQrn82RUDfCZESWL1ZdCEJwekUDPQiYBme" | |
| 5 | }, | |
| 6 | { | |
| 7 | "privateKeyBytesHex": "f0f4df55a2b3ff13051ea814a8f24ad00f2e469af73c363ac7e9fb999a9072ed", | |
| 8 | "publicDidKey": "did:key:zQ3shtxV1FrJfhqE1dvxYRcCknWNjHc3c5X1y3ZSoPDi2aur2" | |
| 9 | }, | |
| 10 | { | |
| 11 | "privateKeyBytesHex": "6b0b91287ae3348f8c2f2552d766f30e3604867e34adc37ccbb74a8e6b893e02", | |
| 12 | "publicDidKey": "did:key:zQ3shZc2QzApp2oymGvQbzP8eKheVshBHbU4ZYjeXqwSKEn6N" | |
| 13 | }, | |
| 14 | { | |
| 15 | "privateKeyBytesHex": "c0a6a7c560d37d7ba81ecee9543721ff48fea3e0fb827d42c1868226540fac15", | |
| 16 | "publicDidKey": "did:key:zQ3shadCps5JLAHcZiuX5YUtWHHL8ysBJqFLWvjZDKAWUBGzy" | |
| 17 | }, | |
| 18 | { | |
| 19 | "privateKeyBytesHex": "175a232d440be1e0788f25488a73d9416c04b6f924bea6354bf05dd2f1a75133", | |
| 20 | "publicDidKey": "did:key:zQ3shptjE6JwdkeKN4fcpnYQY3m9Cet3NiHdAfpvSUZBFoKBj" | |
| 21 | } | |
| 22 | ] |