OverviewFilesCommitsIssuesDocsReleasesPeers

atproto-identity commits / f1650aa

interop conformance cross-check — caught + fixed a DER-signature-acceptance bug
Wire Bluesky's implementation-neutral conformance vectors (atproto-interop-tests, vendored CC-0) as the offline, spec-authoritative cross-check gating the PLC migration — stronger than goat, no permanent write. - Refactor verifyOpSig → reusable verifySig(messageBytes, sigBytes, didKey). - FIX (found by the vectors): verifySig accepted DER-encoded signatures; atproto requires the raw 64-byte (r‖s) compact form only. Enforce sigBytes.length===64. A live submit would NOT have caught this (we always produce compact sigs; the leniency was on the verify path). - test/interop-conformance.test.js 2/2 (13 asserts): did:key secp256k1 5/5 byte-exact (w3c_didkey_K256); ECDSA low-S validity 6/6 (valid low-S accepted, high-S + DER rejected). Full suite green. - scripts/plc-dryrun.mjs: throwaway-DID dry-run harness (offline by default; PLC_SUBMIT=1 fires against the real directory). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
f1650aad3ad377ecf8bb5a40791e6f7772b03b05
by plankatron <fleeky@gmail.com> · 2026-07-04T20:55:03.000Z

Changed files (5) +201 -6

lib/plc-directory.js +15 -6
……@@ -25,16 +25,25 @@ function err (message) { return { ok: false, error: message } }
2525
2626// ── §6 local validation mirror (pure — run BEFORE the irreversible write) ──
2727
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).
30export 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).
31export function verifySig (messageBytes, sigBytes, didKey) {
3132 let dk
3233 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.
43export function verifyOpSig (signedOp, didKey) {
3344 let sig
3445 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)
3847}
3948
4049// True iff the op is signed by AT LEAST ONE of the given rotation did:keys (the directory
scripts/plc-dryrun.mjs +61 -0
……@@ -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
12import { generateMnemonic } from '@scure/bip39'
13import { wordlist } from '@scure/bip39/wordlists/english'
14import { selfCustodyIdentity, deriveAtprotoKey } from '../lib/atproto-identity.js'
15import { checkGenesisOp, checkUpdateOp, PlcDirectory } from '../lib/plc-directory.js'
16import { didForCreateOp, opCid } from '../lib/did-plc.js'
17
18const rootMnemonic = generateMnemonic(wordlist, 256) // fresh throwaway root (recovery authority)
19const deviceMnemonic = generateMnemonic(wordlist, 256) // fresh throwaway device signing key
20const handle = 'plc-dryrun-' + Date.now() + '.example.com' // plc.directory does NOT verify handles
21const pdsUrl = 'https://pds.7rnx.net'
22
23const id = selfCustodyIdentity({ rootMnemonic, handle, pdsUrl, deviceKey: deriveAtprotoKey(deviceMnemonic).privateKey })
24
25console.log('== throwaway identity ==')
26console.log('did ', id.did)
27console.log('handle ', handle)
28console.log('rootDidKey ', id.rootDidKey, '(genesis vm + rotationKey)')
29console.log('deviceDidKey', id.deviceKey.didKey, '(rotation vm)')
30
31console.log('\n== offline validation (§6 mirror) ==')
32const g = checkGenesisOp(id.genesisOp, id.did)
33const r = checkUpdateOp(id.rotationOp, id.genesisOp)
34console.log('genesis :', g.ok ? 'OK' : 'FAIL — ' + g.error)
35console.log('rotation :', r.ok ? 'OK' : 'FAIL — ' + r.error)
36console.log('DID = hash(genesis)? ', didForCreateOp(id.genesisOp) === id.did)
37console.log('rotation.prev = cid(gen)? ', id.rotationOp.prev === opCid(id.genesisOp), '(' + id.rotationOp.prev + ')')
38
39console.log('\n== genesis op (would POST /:did --genesis) ==')
40console.log(JSON.stringify(id.genesisOp, null, 2))
41console.log('\n== rotation op (would POST /:did) ==')
42console.log(JSON.stringify(id.rotationOp, null, 2))
43
44if (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
49console.log('\n== SUBMITTING throwaway to https://plc.directory (PERMANENT, junk DID) ==')
50const plc = new PlcDirectory({})
51const afterGenesis = await plc.submitGenesis({ did: id.did, signedOp: id.genesisOp })
52console.log('genesis accepted. getData vm =', afterGenesis.verificationMethods.atproto,
53 afterGenesis.verificationMethods.atproto === id.rootDidKey ? '(= root)' : '(UNEXPECTED)')
54const afterRotate = await plc.submitUpdate({ did: id.did, signedOp: id.rotationOp })
55console.log('rotation accepted. getData vm =', afterRotate.verificationMethods.atproto,
56 afterRotate.verificationMethods.atproto === id.deviceKey.didKey ? '(= device ✓)' : '(UNEXPECTED)')
57const doc = await plc.resolve(id.did)
58const vm = (doc.verificationMethod || []).find((m) => (m.id || '').endsWith('#atproto'))
59console.log('resolved DID doc #atproto verificationMethod:', vm && (vm.publicKeyMultibase || vm.id))
60console.log('\nDID unchanged across genesis→rotation:', id.did)
61console.log('✓ ceremony validated end-to-end against the real plc.directory.')
test/interop-conformance.test.js +35 -0
……@@ -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
7import test from 'brittle'
8import { readFileSync } from 'node:fs'
9import { fileURLToPath } from 'node:url'
10import { dirname, join } from 'node:path'
11import { keyFromPrivate } from '../lib/atproto-identity.js'
12import { verifySig } from '../lib/plc-directory.js'
13
14const here = dirname(fileURLToPath(import.meta.url))
15const load = (name) => JSON.parse(readFileSync(join(here, 'vectors', name), 'utf8'))
16const b64 = (s) => new Uint8Array(Buffer.from(s, 'base64'))
17const hex = (s) => new Uint8Array(Buffer.from(s, 'hex'))
18
19test('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
27test('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})
test/vectors/signature-fixtures.json +68 -0
……@@ -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]
test/vectors/w3c_didkey_K256.json +22 -0
……@@ -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]