// Offline verification of the atproto identity layer: multikey round-trips, // identifier normalization, service-auth JWT mint/verify against synthetic // DID documents on all three curves, tamper/expiry/audience rejection, and // the relay's composite door (dev HMAC + atproto JWT side by side). // Run: deno run -A src/sync/atproto.test.ts import { ed25519 } from '@noble/curves/ed25519.js'; import { p256 } from '@noble/curves/nist.js'; import { secp256k1 } from '@noble/curves/secp256k1.js'; import { type AtCurve, type DidDocument, encodeMultikey, decodeMultikey, mintServiceAuthJwt, normalizeIdentifier, verifyServiceAuthJwt, } from './atproto.ts'; import { AtprotoVerifier, CompositeVerifier, DevVerifier, RELAY_LXM } from '../../relay/auth.ts'; import { startRelay } from '../../relay/main.ts'; let failures = 0; const check = (label: string, ok: boolean, detail = '') => { console.log(`${ok ? 'PASS' : 'FAIL'} ${label}${detail ? ` — ${detail}` : ''}`); if (!ok) failures++; }; /* ——— identifier normalization ——— */ check('normalizes @handle', normalizeIdentifier('@Robin.Berjon.com').value === 'robin.berjon.com'); check('normalizes at:// form', normalizeIdentifier('at://robin.berjon.com').kind === 'handle'); check('accepts did:plc', normalizeIdentifier('did:plc:abc123').kind === 'did'); check('rejects garbage', (() => { try { normalizeIdentifier('not a handle!'); return false; } catch { return true; } })()); /* ——— multikey round-trip ——— */ const edPriv = ed25519.utils.randomSecretKey(); const edPub = ed25519.getPublicKey(edPriv); const mk = encodeMultikey('ed25519', edPub); const decoded = decodeMultikey(mk); check('multikey round-trips', decoded.curve === 'ed25519' && decoded.keyBytes.every((b, i) => b === edPub[i])); /* ——— service-auth on all three curves ——— */ const RELAY_DID = 'did:web:ziran.space'; function makeIdentity(curve: AtCurve): { did: string; doc: DidDocument; secret: Uint8Array } { let secret: Uint8Array, pub: Uint8Array; if (curve === 'ed25519') { secret = ed25519.utils.randomSecretKey(); pub = ed25519.getPublicKey(secret); } else if (curve === 'p256') { secret = p256.utils.randomSecretKey(); pub = p256.getPublicKey(secret, true); } else { secret = secp256k1.utils.randomSecretKey(); pub = secp256k1.getPublicKey(secret, true); } const did = `did:plc:test${curve}`; const doc: DidDocument = { id: did, alsoKnownAs: [`at://${curve}.test`], verificationMethod: [{ id: `${did}#atproto`, type: 'Multikey', publicKeyMultibase: encodeMultikey(curve, pub) }], service: [{ id: '#atproto_pds', type: 'AtprotoPersonalDataServer', serviceEndpoint: 'https://pds.test' }], }; return { did, doc, secret }; } const identities = new Map(); const resolveDid = (did: string) => { const doc = identities.get(did); return doc ? Promise.resolve(doc) : Promise.reject(new Error(`unknown ${did}`)); }; for (const curve of ['ed25519', 'p256', 'secp256k1'] as AtCurve[]) { const id = makeIdentity(curve); identities.set(id.did, id.doc); const token = mintServiceAuthJwt( { iss: id.did, aud: RELAY_DID, exp: Math.floor(Date.now() / 1000) + 60, lxm: RELAY_LXM }, curve, id.secret, ); const verified = await verifyServiceAuthJwt(token, { aud: RELAY_DID, lxm: RELAY_LXM, resolveDid }) .catch((e) => `ERR:${e.message}`); check(`${curve} service-auth JWT verifies`, verified === id.did, String(verified)); } /* ——— rejection paths ——— */ const alice = makeIdentity('secp256k1'); identities.set(alice.did, alice.doc); const good = () => ({ iss: alice.did, aud: RELAY_DID, exp: Math.floor(Date.now() / 1000) + 60, lxm: RELAY_LXM }); const expired = mintServiceAuthJwt({ ...good(), exp: Math.floor(Date.now() / 1000) - 120 }, 'secp256k1', alice.secret); check('expired token rejected', await verifyServiceAuthJwt(expired, { aud: RELAY_DID, resolveDid }).then(() => false, () => true)); const wrongAud = mintServiceAuthJwt({ ...good(), aud: 'did:web:evil.example' }, 'secp256k1', alice.secret); check('wrong-audience token rejected', await verifyServiceAuthJwt(wrongAud, { aud: RELAY_DID, resolveDid }).then(() => false, () => true)); const wrongLxm = mintServiceAuthJwt({ ...good(), lxm: 'com.example.other' }, 'secp256k1', alice.secret); check('wrong-lxm token rejected', await verifyServiceAuthJwt(wrongLxm, { aud: RELAY_DID, lxm: RELAY_LXM, resolveDid }).then(() => false, () => true)); const mallory = makeIdentity('secp256k1'); // not in the directory-of-record const forged = mintServiceAuthJwt(good(), 'secp256k1', mallory.secret); // alice's iss, mallory's key check('forged signature rejected', await verifyServiceAuthJwt(forged, { aud: RELAY_DID, resolveDid }).then(() => false, () => true)); const tampered = (() => { const t = mintServiceAuthJwt(good(), 'secp256k1', alice.secret).split('.'); const payload = JSON.parse(atob(t[1]!.replace(/-/g, '+').replace(/_/g, '/'))); payload.iss = 'did:plc:someoneelse'; identities.set('did:plc:someoneelse', alice.doc); t[1] = btoa(JSON.stringify(payload)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); return t.join('.'); })(); check('tampered payload rejected', await verifyServiceAuthJwt(tampered, { aud: RELAY_DID, resolveDid }).then(() => false, () => true)); /* ——— the composite relay door ——— */ const dataDir = await Deno.makeTempDir({ prefix: 'ziran-at-' }); const atVerifier = new AtprotoVerifier(RELAY_DID, resolveDid); const composite = new CompositeVerifier(atVerifier, new DevVerifier('at-test-secret')); const relay = await startRelay({ port: 0, dataDir, verifier: composite }); async function tryConnect(token: string): Promise { const ws = new WebSocket(`ws://127.0.0.1:${relay.port}/ws?token=${encodeURIComponent(token)}`); return await new Promise((resolve) => { ws.addEventListener('open', () => { ws.close(); resolve(true); }); ws.addEventListener('error', () => resolve(false)); }); } const atToken = mintServiceAuthJwt(good(), 'secp256k1', alice.secret); check('relay admits a valid atproto service-auth JWT', await tryConnect(atToken)); check('relay admits a dev HMAC token', await tryConnect(await new DevVerifier('at-test-secret').mint('did:ziran:dev'))); check('relay refuses a forged JWT', !(await tryConnect(mintServiceAuthJwt(good(), 'secp256k1', mallory.secret)))); check('relay refuses garbage', !(await tryConnect('nope'))); relay.shutdown(); await new Promise((r) => setTimeout(r, 100)); await Deno.remove(dataDir, { recursive: true }).catch(() => {}); if (failures) { console.error(`\n${failures} failure(s)`); Deno.exit(1); } console.log('\natproto identity layer: all good');