chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import { PersonalDataServer } from '@pdsjs/core';
import { afterEach, describe, expect, test, vi } from 'vitest';
import {
base64UrlDecode,
generateKeyPair,
importPrivateKey,
} from '../src/crypto.js';
import { createIdentityHandlers } from '../src/handlers/xrpc-identity.js';
import { generateKemKey, kemDidKey } from '../src/kem.js';
import {
buildPlcOperation,
computePlcOperationCid,
publicKeyToDidKey,
} from '../src/plc.js';
import { cborEncodeDagCbor } from '../src/repo.js';
import { createVerifier } from '../src/verify.js';
const DID = 'did:plc:kemkeytestaccount2345672';
const PLC = 'https://plc.example.com';
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe('KEM keys', () => {
test('a fresh key names its own P-256 did:key', async () => {
const { privateKey, didKey } = await generateKemKey();
expect(privateKey).toHaveLength(32);
expect(didKey).toMatch(/^did:key:zDn/);
expect(await kemDidKey(privateKey)).toBe(didKey);
});
test('refuses a key of the wrong length', async () => {
await expect(kemDidKey(new Uint8Array(31))).rejects.toThrow('32 bytes');
});
test('a genesis operation publishes the KEM key beside the signing key', () => {
const op = /** @type {{verificationMethods: Record<string, string>}} */ (
buildPlcOperation({
didKey: 'did:key:zDnaeSIGN',
kemDidKey: 'did:key:zDnaeKEM',
handle: 'alice.example.com',
pdsEndpoint: 'https://pds.example.com',
})
);
expect(op.verificationMethods).toEqual({
atproto: 'did:key:zDnaeSIGN',
atproto_kem: 'did:key:zDnaeKEM',
});
});
});
/**
* @param {{
* did?: string|null, kemKey?: Uint8Array|null, readOnly?: boolean,
* deactivated?: boolean, hostname?: string,
* }} [options]
*/
async function createIdentity({
did = DID,
kemKey = null,
readOnly = false,
deactivated = false,
hostname = 'pds.example.com',
} = {}) {
const { privateKey, publicKey } = await generateKeyPair();
const signingKey = await importPrivateKey(privateKey);
const serverKey = publicKeyToDidKey(publicKey);
const stored = { kemKey };
/** @type {Array<{type: string, body: Object}>} */
const events = [];
const identity = createIdentityHandlers({
actorStorage: /** @type {any} */ ({
async getHandle() {
return 'alice.example.com';
},
async getPrivateKey() {
return privateKey;
},
async getKemKey() {
return stored.kemKey;
},
async setKemKey(/** @type {Uint8Array} */ key) {
stored.kemKey = key;
},
}),
sharedStorage: /** @type {any} */ ({}),
hostname,
plcUrl: PLC,
jwtSecret: 'test-secret',
readOnly,
emailer: null,
k256Signer: null,
getDid: async () => did,
getSigningKey: async () => signingKey,
isDeactivated: async () => deactivated,
handleProxyHeader: async () => null,
emitEvent: async (type, body) => {
events.push({ type, body });
},
readJsonBounded: async (request) => request.json(),
readOnlyError: () => Response.json({}, { status: 401 }),
});
return { identity, stored, events, serverKey };
}
/**
* The last operation in a PLC log this server can update.
* @param {string} serverKey
* @param {Record<string, unknown>} [overrides]
*/
function lastOp(serverKey, overrides = {}) {
return {
type: 'plc_operation',
rotationKeys: ['did:key:zQ3shRECOVERY', serverKey],
verificationMethods: { atproto: serverKey },
alsoKnownAs: ['at://alice.example.com'],
services: {
atproto_pds: {
type: 'AtprotoPersonalDataServer',
endpoint: 'https://pds.example.com',
},
},
prev: null,
sig: 'c2ln',
...overrides,
};
}
/**
* A PLC directory answering the log read with `last` and a submission with
* `submitStatus`.
* @param {Object|null} last - null answers the log read with a 500
* @param {number} [submitStatus]
*/
function stubPlc(last, submitStatus = 200) {
/** @type {Array<Record<string, any>>} */
const submitted = [];
const fetchMock = vi.fn(
async (/** @type {string} */ url, /** @type {RequestInit} */ init) => {
if (url === `${PLC}/${encodeURIComponent(DID)}/log/last`) {
return last
? Response.json(last)
: new Response('down', { status: 500 });
}
if (
url === `${PLC}/${encodeURIComponent(DID)}` &&
init?.method === 'POST'
) {
submitted.push(JSON.parse(/** @type {string} */ (init.body)));
return new Response('', { status: submitStatus });
}
return new Response('not found', { status: 404 });
},
);
vi.stubGlobal('fetch', fetchMock);
return { submitted, fetchMock };
}
describe('recommended DID credentials', () => {
test('carry the KEM key once the account has one', async () => {
stubPlc(null);
const kem = await generateKemKey();
const { identity } = await createIdentity({ kemKey: kem.privateKey });
const creds = await identity.getRecommendedDidCredentials();
expect(creds?.verificationMethods.atproto_kem).toBe(kem.didKey);
});
});
describe('submitPlcOperation', () => {
/**
* @param {ReturnType<typeof createIdentityHandlers>} identity
* @param {Object} operation
*/
async function submit(identity, operation) {
const path = '/xrpc/com.atproto.identity.submitPlcOperation';
const url = new URL(`https://pds.example.com${path}`);
const handler =
/** @type {(request: Request, url: URL, auth: unknown) => Promise<Response>} */ (
identity.routes[path].handler
);
return handler(
new Request(url, { method: 'POST', body: JSON.stringify({ operation }) }),
url,
{ did: DID, scope: 'com.atproto.access' },
);
}
test('refuses a caller with no session, server or operation', async () => {
stubPlc(null);
const path = '/xrpc/com.atproto.identity.submitPlcOperation';
const url = new URL(`https://pds.example.com${path}`);
/**
* @param {ReturnType<typeof createIdentityHandlers>} identity
* @param {Object} body
* @param {unknown} auth
*/
const post = (identity, body, auth) =>
/** @type {(request: Request, url: URL, auth: unknown) => Promise<Response>} */ (
identity.routes[path].handler
)(
new Request(url, { method: 'POST', body: JSON.stringify(body) }),
url,
auth,
);
const auth = { did: DID, scope: 'com.atproto.access' };
const { identity } = await createIdentity();
expect((await post(identity, { operation: {} }, null)).status).toBe(401);
expect((await post(identity, {}, auth)).status).toBe(400);
const empty = await createIdentity({ did: null });
expect((await post(empty.identity, { operation: {} }, auth)).status).toBe(
400,
);
});
test('reports what the directory said when it refuses', async () => {
stubPlc(null, 409);
const { identity } = await createIdentity();
const response = await submit(identity, { verificationMethods: {} });
expect(response.status).toBe(400);
expect((await response.json()).message).toContain('409');
});
test('refuses an operation that drops the KEM key', async () => {
const { submitted } = stubPlc(null);
const kem = await generateKemKey();
const { identity } = await createIdentity({ kemKey: kem.privateKey });
const response = await submit(identity, {
verificationMethods: { atproto: 'did:key:zDnaeSIGN' },
});
expect(response.status).toBe(400);
expect((await response.json()).message).toBe('Incorrect KEM key');
expect(submitted).toEqual([]);
});
test('passes an operation that keeps the KEM key', async () => {
const { submitted } = stubPlc(null);
const kem = await generateKemKey();
const { identity } = await createIdentity({ kemKey: kem.privateKey });
const response = await submit(identity, {
verificationMethods: {
atproto: 'did:key:zDnaeSIGN',
atproto_kem: kem.didKey,
},
});
expect(response.status).toBe(200);
expect(submitted).toHaveLength(1);
});
});
describe('KEM key backfill', () => {
test('publishes a key, then stores it and announces the identity', async () => {
const created = await createIdentity();
const last = lastOp(created.serverKey);
const { submitted } = stubPlc(last);
expect(await created.identity.backfillKemKey()).toBe('published');
expect(submitted).toHaveLength(1);
const [op] = submitted;
const kem = await kemDidKey(
/** @type {Uint8Array} */ (created.stored.kemKey),
);
expect(op.verificationMethods).toEqual({
atproto: created.serverKey,
atproto_kem: kem,
});
expect(op.rotationKeys).toEqual(last.rotationKeys);
expect(op.prev).toBe(await computePlcOperationCid(last));
const { sig, ...unsigned } = op;
const valid = await createVerifier().verify(
created.serverKey,
cborEncodeDagCbor(unsigned),
base64UrlDecode(sig),
);
expect(valid).toBe(true);
expect(created.events.map((e) => e.type)).toEqual(['identity']);
});
test('stores a key it cannot publish when another key rotates the DID', async () => {
const created = await createIdentity();
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { submitted } = stubPlc(
lastOp(created.serverKey, { rotationKeys: ['did:key:zQ3shOTHER'] }),
);
expect(await created.identity.backfillKemKey()).toBe('stored');
expect(submitted).toEqual([]);
expect(created.stored.kemKey).toHaveLength(32);
expect(created.events).toEqual([]);
expect(warn).toHaveBeenCalled();
});
test('stores a key it cannot publish when the DID names another server', async () => {
const created = await createIdentity();
vi.spyOn(console, 'warn').mockImplementation(() => {});
const { submitted } = stubPlc(
lastOp(created.serverKey, {
services: {
atproto_pds: {
type: 'AtprotoPersonalDataServer',
endpoint: 'https://elsewhere.example.com',
},
},
}),
);
expect(await created.identity.backfillKemKey()).toBe('stored');
expect(submitted).toEqual([]);
});
test('stores a key for a did:web, whose document is kept elsewhere', async () => {
const created = await createIdentity({ did: 'did:web:alice.example.com' });
vi.spyOn(console, 'warn').mockImplementation(() => {});
const { fetchMock } = stubPlc(null);
expect(await created.identity.backfillKemKey()).toBe('stored');
expect(fetchMock).not.toHaveBeenCalled();
});
test('keeps nothing when the directory refuses, so the next run retries', async () => {
const created = await createIdentity();
stubPlc(lastOp(created.serverKey), 503);
await expect(created.identity.backfillKemKey()).rejects.toThrow('503');
expect(created.stored.kemKey).toBeNull();
});
test('keeps nothing when the log cannot be read', async () => {
const created = await createIdentity();
stubPlc(null);
await expect(created.identity.backfillKemKey()).rejects.toThrow(
'Could not read the PLC log',
);
expect(created.stored.kemKey).toBeNull();
});
test('leaves alone an account that has a key, none, or has moved away', async () => {
const { fetchMock } = stubPlc(null);
const kem = await generateKemKey();
for (const options of [
{ kemKey: kem.privateKey },
{ did: null },
{ deactivated: true },
{ readOnly: true },
]) {
const { identity } = await createIdentity(options);
expect(await identity.backfillKemKey()).toBe('skipped');
}
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe('the server', () => {
/** @param {Record<string, any>} actorStorage */
function server(actorStorage) {
/** @type {import('../src/ports.js').ExtensionHost|undefined} */
let host;
const pds = new PersonalDataServer({
actorStorage: /** @type {any} */ (actorStorage),
sharedStorage: /** @type {any} */ ({}),
blobs: /** @type {any} */ ({}),
jwtSecret: 'test-secret',
hostname: 'pds.example.com',
plcUrl: PLC,
password: 'pw',
extensions: [
(h) => {
host = h;
return { name: 'probe' };
},
],
});
return { pds, host: () => /** @type {NonNullable<typeof host>} */ (host) };
}
test('hands extensions the KEM key, read once', async () => {
const kem = await generateKemKey();
const getKemKey = vi.fn(async () => kem.privateKey);
const { pds, host } = server({ getKemKey });
expect(await host().getKemKey()).toEqual(kem);
expect(await pds.getKemKey()).toEqual(kem);
expect(getKemKey).toHaveBeenCalledTimes(1);
});
test('answers null while the account has no KEM key', async () => {
const { pds } = server({ getKemKey: async () => null });
expect(await pds.getKemKey()).toBeNull();
});
test('logs a failed backfill and reports it', async () => {
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
stubPlc(null);
const { privateKey } = await generateKeyPair();
const { pds } = server({
getDid: async () => DID,
getKemKey: async () => null,
getPrivateKey: async () => privateKey,
getAccountStatus: async () => 'active',
});
expect(await pds.maybeBackfillKemKey()).toBe('failed');
expect(error).toHaveBeenCalled();
});
test('reads the new key after a backfill stores one', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
const { privateKey } = await generateKeyPair();
/** @type {Uint8Array|null} */
let kemKey = null;
const { pds } = server({
getDid: async () => 'did:web:alice.example.com',
getKemKey: async () => kemKey,
setKemKey: async (/** @type {Uint8Array} */ key) => {
kemKey = key;
},
getPrivateKey: async () => privateKey,
getAccountStatus: async () => 'active',
});
expect(await pds.getKemKey()).toBeNull();
expect(await pds.maybeBackfillKemKey()).toBe('stored');
expect((await pds.getKemKey())?.privateKey).toBe(kemKey);
});
});