// The PLC operation flow, driven through the identity handlers: the mailed // confirmation code, signing an operation chained onto the DID's log, and the // endpoint wrapper around the recommended credentials. import { afterEach, describe, expect, it, vi } from 'vitest'; import { generateKeyPair, importPrivateKey, } from '../packages/core/src/crypto.js'; import { createIdentityHandlers } from '../packages/core/src/handlers/xrpc-identity.js'; const DID = 'did:plc:plcoperationtestacct2345'; const AUTH = { did: DID, scope: 'com.atproto.access' }; const SECRET = 'test-secret'; afterEach(() => vi.unstubAllGlobals()); /** Answer the PLC directory's /data and /log/last endpoints. */ function stubPlcDirectory() { vi.stubGlobal( 'fetch', vi.fn(async (/** @type {string} */ url) => { const u = String(url); if (u.endsWith('/data')) { return new Response(JSON.stringify({ rotationKeys: [] })); } if (u.endsWith('/log/last')) { return new Response(JSON.stringify({ cid: 'bafyreiprevop' })); } return new Response('', { status: 404 }); }), ); } /** * @param {Object} [options] * @param {{address: string|null, verified: boolean}|null} [options.email] * @param {boolean} [options.withEmailer] * @param {boolean} [options.withKey] * @param {boolean} [options.failSend] */ async function createIdentity({ email = { address: 'chad@example.com', verified: true }, withEmailer = true, withKey = true, failSend = false, } = {}) { const { privateKey } = await generateKeyPair(); const signingKey = withKey ? await importPrivateKey(privateKey) : null; /** @type {{token: {codeHash: string, expiresAt: number}|null}} */ const state = { token: null }; /** @type {Array<{to: string, text: string}>} */ const sent = []; const identity = createIdentityHandlers( /** @type {any} */ ({ actorStorage: { getHandle: async () => 'alice.example.com', getPrivateKey: async () => (withKey ? privateKey : null), getKemKey: async () => null, getEmail: async () => email && { ...email, pending: null }, setPlcToken: async (/** @type {any} */ token) => { state.token = token; }, getPlcToken: async () => state.token, }, hostname: 'pds.example.com', plcUrl: 'https://plc.example.com', jwtSecret: SECRET, emailer: withEmailer ? { async send(/** @type {any} */ message) { if (failSend) throw new Error('smtp down'); sent.push(message); }, } : null, k256Signer: null, getDid: async () => DID, getSigningKey: async () => signingKey, readJsonBounded: async (/** @type {Request} */ request) => request.json(), }), ); return { identity, state, sent }; } /** * @param {ReturnType} identity * @param {string} path * @param {{body?: Object, auth?: Object|null}} [options] */ function call(identity, path, { body, auth = null } = {}) { const url = new URL(`https://pds.example.com${path}`); const request = new Request(url, { method: body === undefined ? 'GET' : 'POST', headers: { 'Content-Type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body), }); const handler = /** @type {any} */ (identity.routes[path]).handler; return handler(request, url, auth); } const REQUEST = '/xrpc/com.atproto.identity.requestPlcOperationSignature'; const SIGN = '/xrpc/com.atproto.identity.signPlcOperation'; const CREDENTIALS = '/xrpc/com.atproto.identity.getRecommendedDidCredentials'; describe('getRecommendedDidCredentials endpoint', () => { it('answers the credentials, gating on auth and initialization', async () => { stubPlcDirectory(); const { identity } = await createIdentity(); expect((await call(identity, CREDENTIALS)).status).toBe(401); const { identity: uninit } = await createIdentity({ withKey: false }); expect((await call(uninit, CREDENTIALS, { auth: AUTH })).status).toBe(400); const res = await call(identity, CREDENTIALS, { auth: AUTH }); const body = await res.json(); expect(body.alsoKnownAs).toEqual(['at://alice.example.com']); expect(body.verificationMethods.atproto).toMatch(/^did:key:/); expect(body.services.atproto_pds.endpoint).toBe('https://pds.example.com'); }); }); describe('requestPlcOperationSignature', () => { it('requires authentication', async () => { const { identity } = await createIdentity(); expect((await call(identity, REQUEST, { body: {} })).status).toBe(401); }); it('issues no code when it cannot reach the account holder', async () => { // No sender, and separately no confirmed address: both answer 200 with no // code, leaving the session as the only authorization. for (const options of [ { withEmailer: false }, { email: { address: 'chad@example.com', verified: false } }, { email: null }, ]) { const { identity, state } = await createIdentity(options); const res = await call(identity, REQUEST, { body: {}, auth: AUTH }); expect(res.status).toBe(200); expect(state.token).toBeNull(); } }); it('mails a code and stores its hash', async () => { const { identity, state, sent } = await createIdentity(); const res = await call(identity, REQUEST, { body: {}, auth: AUTH }); expect(res.status).toBe(200); expect(sent).toHaveLength(1); expect(sent[0].to).toBe('chad@example.com'); expect(sent[0].text).toMatch(/\d{6}/); expect(state.token?.codeHash).toBeTruthy(); }); it('clears the stored token when the message cannot be sent', async () => { const { identity, state } = await createIdentity({ failSend: true }); const res = await call(identity, REQUEST, { body: {}, auth: AUTH }); expect(res.status).toBe(502); expect(state.token).toBeNull(); }); }); describe('signPlcOperation', () => { it('requires authentication', async () => { const { identity } = await createIdentity(); expect((await call(identity, SIGN, { body: {} })).status).toBe(401); }); it('signs without a code when the server cannot confirm by email', async () => { stubPlcDirectory(); const { identity } = await createIdentity({ withEmailer: false }); const res = await call(identity, SIGN, { body: {}, auth: AUTH }); expect(res.status).toBe(200); const { operation } = await res.json(); expect(operation.type).toBe('plc_operation'); expect(operation.prev).toBe('bafyreiprevop'); expect(operation.sig).toBeTruthy(); }); it('demands the mailed code once one can be sent', async () => { stubPlcDirectory(); const { identity, sent } = await createIdentity(); const withoutCode = await call(identity, SIGN, { body: {}, auth: AUTH }); expect(withoutCode.status).toBe(400); expect((await withoutCode.json()).message).toContain( 'requestPlcOperationSignature', ); await call(identity, REQUEST, { body: {}, auth: AUTH }); const code = /** @type {RegExpMatchArray} */ ( sent[0].text.match(/\d{6}/) )[0]; const wrongCode = await call(identity, SIGN, { body: { token: '000000' }, auth: AUTH, }); expect(wrongCode.status).toBe(400); expect((await wrongCode.json()).message).toBe( 'Confirmation code is invalid', ); const res = await call(identity, SIGN, { body: { token: code }, auth: AUTH, }); expect(res.status).toBe(200); expect((await res.json()).operation.sig).toBeTruthy(); // Spent: the same code signs exactly one operation. const reuse = await call(identity, SIGN, { body: { token: code }, auth: AUTH, }); expect(reuse.status).toBe(400); }); it('lets caller-supplied fields override the recommended defaults', async () => { stubPlcDirectory(); const { identity } = await createIdentity({ withEmailer: false }); const res = await call(identity, SIGN, { body: { rotationKeys: ['did:key:zCallerChoice'] }, auth: AUTH, }); const { operation } = await res.json(); expect(operation.rotationKeys).toEqual(['did:key:zCallerChoice']); expect(operation.alsoKnownAs).toEqual(['at://alice.example.com']); }); it('refuses before the server holds a signing key', async () => { const { identity } = await createIdentity({ withKey: false, withEmailer: false, }); const res = await call(identity, SIGN, { body: {}, auth: AUTH }); expect(res.status).toBe(400); expect((await res.json()).message).toBe('PDS not initialized'); }); });