// Inter-service auth: the token a repo host presents to an authority for // notifyWrite, and the authority presents to a syncer for notifySpaceDeleted. // // Signers here use @pdsjs/core/crypto's sign(), which normalizes to low-S, for // the reason auth.test.js gives. import { compressPublicKey, sign } from '@pdsjs/core/crypto'; import { publicKeyToDidKey } from '@pdsjs/core/plc'; import { describe, expect, it } from 'vitest'; import { ATPROTO_KEY_ID } from '../src/authority.js'; import { createServiceAuth, verifyServiceAuth } from '../src/service-auth.js'; import { createVerifier } from '../src/verifier.js'; const CALLER = 'did:plc:servicecaller00000000000'; const US = 'did:plc:servicereceiver000000000'; const LXM = 'com.atproto.space.notifyWrite'; /** A P-256 identity, its did:key published as `#atproto`. */ async function makeIdentity() { const keys = await crypto.subtle.generateKey( { name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify'], ); const raw = new Uint8Array( await crypto.subtle.exportKey('raw', keys.publicKey), ); const didKey = publicKeyToDidKey(compressPublicKey(raw)); return { didDoc: { id: CALLER, verificationMethod: [ { id: ATPROTO_KEY_ID, type: 'Multikey', publicKeyMultibase: didKey.slice('did:key:'.length), }, ], }, signer: { curve: /** @type {const} */ ('p256'), /** @param {Uint8Array} bytes */ sign: (bytes) => sign(keys.privateKey, bytes), }, }; } /** @param {{signer: {sign: (b: Uint8Array) => Promise}}} identity */ function tokenFrom(identity) { return createServiceAuth({ iss: CALLER, aud: US, lxm: LXM, signer: identity.signer, }); } /** * @param {string} jwt * @param {(did: string, opts?: {forceRefresh?: boolean}) => Promise} resolveDid */ function verify(jwt, resolveDid) { return verifyServiceAuth({ jwt, aud: US, lxm: LXM, resolveDid, verifier: createVerifier(), }); } describe('verifyServiceAuth', () => { it('accepts a token signed by the published key', async () => { const caller = await makeIdentity(); const jwt = await tokenFrom(caller); await expect(verify(jwt, async () => caller.didDoc)).resolves.toEqual({ iss: CALLER, }); }); it('refuses a token signed by another key', async () => { const caller = await makeIdentity(); const impostor = await makeIdentity(); const jwt = await tokenFrom(impostor); await expect(verify(jwt, async () => caller.didDoc)).rejects.toMatchObject({ code: 'BadJwtSignature', }); }); it('accepts a token signed by the key the caller rotated to', async () => { const held = await makeIdentity(); const rotated = await makeIdentity(); const jwt = await tokenFrom(rotated); const resolved = /** @type {boolean[]} */ ([]); const doc = await verify(jwt, async (_did, opts) => { resolved.push(Boolean(opts?.forceRefresh)); return opts?.forceRefresh ? rotated.didDoc : held.didDoc; }); expect(doc).toEqual({ iss: CALLER }); // The held answer is read first; the forced read only follows a failure. expect(resolved).toEqual([false, true]); }); it('reads the directory once when the held key verifies', async () => { const caller = await makeIdentity(); const jwt = await tokenFrom(caller); let reads = 0; await verify(jwt, async () => { reads++; return caller.didDoc; }); expect(reads).toBe(1); }); it('refuses when the forced read returns the same key', async () => { const caller = await makeIdentity(); const impostor = await makeIdentity(); const jwt = await tokenFrom(impostor); let reads = 0; await expect( verify(jwt, async () => { reads++; return caller.didDoc; }), ).rejects.toMatchObject({ code: 'BadJwtSignature' }); expect(reads).toBe(2); }); it('refuses a token whose issuer does not resolve', async () => { const caller = await makeIdentity(); const jwt = await tokenFrom(caller); await expect(verify(jwt, async () => null)).rejects.toMatchObject({ code: 'DidNotFound', }); }); });