chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// An account whose atproto key is secp256k1 — the curve the reference PDS
// gives every account, and the only one some third-party services (Go feed
// generators) can verify. WebCrypto cannot sign it, so core takes the
// implementation as a port; these tests drive that port end to end with the
// same @noble/curves implementation the platform packages inject.
import { k256Signer, secp256k1Verify } from '@pdsjs/node';
import { describe, expect, it } from 'vitest';
import { createAccessJwt } from '../packages/core/src/auth.js';
import {
base64UrlDecode,
decodeStoredKey,
encodeStoredKey,
generateKeyPair,
importPrivateKey,
sign,
} from '../packages/core/src/crypto.js';
import { createIdentityHandlers } from '../packages/core/src/handlers/xrpc-identity.js';
import { PersonalDataServer } from '../packages/core/src/pds.js';
import { publicKeyToDidKey } from '../packages/core/src/plc.js';
import { createVerifier, parseDidKey } from '../packages/core/src/verify.js';
import { ATPROTO_PDS_ID } from '../packages/spaces/src/authority.js';
import {
createMemoryBlobContext,
createMemorySpaceStorage,
} from '../packages/spaces/src/memory-storage.js';
import { createSpaceRoutes } from '../packages/spaces/src/routes.js';
import {
createServiceAuth,
verifyServiceAuth,
} from '../packages/spaces/src/service-auth.js';
import { createSpaceToken } from '../packages/spaces/src/token.js';
const DID = 'did:plc:k256exampleaccount000000';
const JWT_SECRET = 'test-secret';
/**
* @param {Uint8Array} stored - Key as persisted (curve-tagged for secp256k1)
* @param {{signer?: any}} [opts]
*/
function server(stored, opts = {}) {
let saved = stored;
return new PersonalDataServer({
actorStorage: /** @type {any} */ ({
async getDid() {
return DID;
},
async getPrivateKey() {
return saved;
},
async setPrivateKey(/** @type {Uint8Array} */ key) {
saved = key;
},
async getKemKey() {
return null;
},
async getHandle() {
return 'alice.example.com';
},
}),
sharedStorage: /** @type {any} */ ({}),
blobs: /** @type {any} */ ({}),
jwtSecret: JWT_SECRET,
hostname: 'pds.example.com',
verifier: createVerifier({ secp256k1: secp256k1Verify }),
k256Signer: 'signer' in opts ? opts.signer : k256Signer,
});
}
function k256Account() {
const privateKey = k256Signer.generatePrivateKey();
return {
privateKey,
stored: encodeStoredKey(privateKey, 'secp256k1'),
didKey: publicKeyToDidKey(k256Signer.getPublicKey(privateKey), 'secp256k1'),
};
}
describe('stored key format', () => {
it('round-trips a secp256k1 key with its curve', () => {
const privateKey = k256Signer.generatePrivateKey();
const decoded = decodeStoredKey(encodeStoredKey(privateKey, 'secp256k1'));
expect(decoded.curve).toBe('secp256k1');
expect(decoded.privateKey).toEqual(privateKey);
});
it('reads a bare 32-byte key as P-256, the format stored before curves were tagged', async () => {
const { privateKey } = await generateKeyPair();
const decoded = decodeStoredKey(privateKey);
expect(decoded.curve).toBe('p256');
expect(decoded.privateKey).toEqual(privateKey);
});
});
describe('did:key encoding', () => {
it('gives a secp256k1 key the zQ3s prefix the ecosystem expects', () => {
const { didKey } = k256Account();
expect(didKey.startsWith('did:key:zQ3s')).toBe(true);
expect(parseDidKey(didKey).curve).toBe('secp256k1');
});
});
describe('service auth with a secp256k1 account key', () => {
it('signs the token ES256K, and the signature verifies against the account did:key', async () => {
const { stored, didKey } = k256Account();
const pds = server(stored);
const jwt = await pds._proxy.createServiceAuthForAppView(
DID,
'app.bsky.feed.sendInteractions',
);
const [headerB64, payloadB64, sigB64] = jwt.split('.');
const header = JSON.parse(
new TextDecoder().decode(base64UrlDecode(headerB64)),
);
// ES256K, not ES256: the alg has to match the curve in the DID document,
// or a verifier picks the wrong algorithm and rejects the token.
expect(header.alg).toBe('ES256K');
const claims = JSON.parse(
new TextDecoder().decode(base64UrlDecode(payloadB64)),
);
expect(claims.iss).toBe(DID);
expect(claims.lxm).toBe('app.bsky.feed.sendInteractions');
const verifier = createVerifier({ secp256k1: secp256k1Verify });
const valid = await verifier.verify(
didKey,
new TextEncoder().encode(`${headerB64}.${payloadB64}`),
base64UrlDecode(sigB64),
);
expect(valid).toBe(true);
});
it('still signs ES256 for a P-256 account, even with a k256 signer available', async () => {
const { privateKey, publicKey } = await generateKeyPair();
const pds = server(privateKey);
const jwt = await pds._proxy.createServiceAuthForAppView(
DID,
'app.bsky.feed.getTimeline',
);
const header = JSON.parse(
new TextDecoder().decode(base64UrlDecode(jwt.split('.')[0])),
);
expect(header.alg).toBe('ES256');
const [headerB64, payloadB64, sigB64] = jwt.split('.');
const valid = await createVerifier().verify(
publicKeyToDidKey(publicKey),
new TextEncoder().encode(`${headerB64}.${payloadB64}`),
base64UrlDecode(sigB64),
);
expect(valid).toBe(true);
});
});
describe('recommended DID credentials', () => {
it('publishes the secp256k1 did:key as the account verification method', async () => {
const { stored, didKey } = k256Account();
const identity = createIdentityHandlers(
/** @type {any} */ ({
actorStorage: {
getPrivateKey: async () => stored,
getKemKey: async () => null,
getHandle: async () => 'alice.example.com',
},
hostname: 'pds.example.com',
plcUrl: 'https://plc.test',
k256Signer,
getDid: async () => DID,
}),
);
const credentials = await identity.getRecommendedDidCredentials();
expect(credentials?.verificationMethods.atproto).toBe(didKey);
expect(credentials?.rotationKeys).toContain(didKey);
});
});
describe('OAuth token signing', () => {
it('mints at+jwt with ES256K and publishes a matching JWKS entry', async () => {
const { stored } = k256Account();
const pds = server(stored);
const token = await pds._oauth.createOAuthAccessToken({
issuer: 'https://pds.example.com',
subject: DID,
clientId: 'https://app.example.com/client-metadata.json',
scope: 'atproto',
tokenId: 'tok-1',
dpopJkt: 'jkt-1',
expiresIn: 3600,
});
const header = JSON.parse(
new TextDecoder().decode(base64UrlDecode(token.split('.')[0])),
);
expect(header.alg).toBe('ES256K');
const jwks = await (await pds._oauth.handleOAuthJwks()).json();
expect(jwks.keys[0].alg).toBe('ES256K');
expect(jwks.keys[0].crv).toBe('secp256k1');
});
});
describe('repo commits', () => {
it('signs a commit that verifies against the account did:key', async () => {
const { stored, didKey } = k256Account();
const pds = server(stored);
const signingKey = await pds.getSigningKey();
expect(signingKey).not.toBeNull();
const bytes = new TextEncoder().encode('commit bytes');
const sig = await sign(/** @type {any} */ (signingKey), bytes);
expect(sig.length).toBe(64);
const verifier = createVerifier({ secp256k1: secp256k1Verify });
expect(await verifier.verify(didKey, bytes, sig)).toBe(true);
});
});
describe('a deployment without a secp256k1 signer', () => {
it('refuses to use a stored secp256k1 key rather than signing it wrong', async () => {
const { stored } = k256Account();
const pds = server(stored, { signer: undefined });
await expect(pds.getSigningKey()).rejects.toThrow(/secp256k1/);
});
it('mints P-256 keys for new accounts', async () => {
const { privateKey } = await generateKeyPair();
const pds = server(privateKey, { signer: undefined });
const minted = await pds.generateSigningKey();
expect(decodeStoredKey(minted.stored).curve).toBe('p256');
expect(minted.didKey.startsWith('did:key:zDn')).toBe(true);
});
});
describe('new accounts on a secp256k1-capable deployment', () => {
it('mint secp256k1 keys, matching what the reference PDS issues', async () => {
const { privateKey } = await generateKeyPair();
const minted = await server(privateKey).generateSigningKey();
expect(decodeStoredKey(minted.stored).curve).toBe('secp256k1');
expect(minted.didKey.startsWith('did:key:zQ3s')).toBe(true);
// The minted handle signs, and its signature verifies under the did:key
// that would be published for it.
const data = new TextEncoder().encode('hello');
const sig = await sign(minted.signingKey, data);
const verifier = createVerifier({ secp256k1: secp256k1Verify });
expect(await verifier.verify(minted.didKey, data, sig)).toBe(true);
});
});
describe('proxying to a feed generator', () => {
it('sends an ES256K service token — what foryou.club and other Go feedgens verify', async () => {
const { stored, didKey } = k256Account();
const calls = /** @type {{url: string, auth: string|null}[]} */ ([]);
const originalFetch = globalThis.fetch;
globalThis.fetch = /** @type {any} */ (
async (/** @type {any} */ url, /** @type {any} */ init) => {
calls.push({
url: String(url),
auth: init?.headers?.get?.('Authorization') ?? null,
});
return new Response('{}', {
headers: { 'Content-Type': 'application/json' },
});
}
);
try {
const didResolver = async (/** @type {string} */ did) =>
did === 'did:web:foryou.club'
? {
service: [
{
id: '#bsky_fg',
type: 'BskyFeedGenerator',
serviceEndpoint: 'https://foryou.club',
},
],
}
: null;
const pds = new PersonalDataServer({
actorStorage: /** @type {any} */ ({
async getDid() {
return DID;
},
async getPrivateKey() {
return stored;
},
async getKemKey() {
return null;
},
async getHandle() {
return 'alice.example.com';
},
}),
sharedStorage: /** @type {any} */ ({}),
blobs: /** @type {any} */ ({}),
jwtSecret: JWT_SECRET,
hostname: 'pds.example.com',
didResolver,
verifier: createVerifier({ secp256k1: secp256k1Verify }),
k256Signer,
});
const token = await createAccessJwt(DID, JWT_SECRET);
const res = await pds.fetch(
new Request(
'https://pds.example.com/xrpc/app.bsky.feed.sendInteractions',
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'atproto-proxy': 'did:web:foryou.club#bsky_fg',
'Content-Type': 'application/json',
},
body: JSON.stringify({ interactions: [] }),
},
),
);
expect(res.status).toBe(200);
expect(calls).toHaveLength(1);
const jwt = (calls[0].auth || '').slice('Bearer '.length);
const [headerB64, payloadB64, sigB64] = jwt.split('.');
const header = JSON.parse(
new TextDecoder().decode(base64UrlDecode(headerB64)),
);
expect(header.alg).toBe('ES256K');
const claims = JSON.parse(
new TextDecoder().decode(base64UrlDecode(payloadB64)),
);
expect(claims.aud).toBe('did:web:foryou.club');
expect(claims.lxm).toBe('app.bsky.feed.sendInteractions');
const verifier = createVerifier({ secp256k1: secp256k1Verify });
expect(
await verifier.verify(
didKey,
new TextEncoder().encode(`${headerB64}.${payloadB64}`),
base64UrlDecode(sigB64),
),
).toBe(true);
} finally {
globalThis.fetch = originalFetch;
}
});
});
const SPACE_AUTHORITY = 'did:plc:k256spaceauthority00000';
const SPACE = `at://${SPACE_AUTHORITY}/space/com.example.forum/general`;
const SPACE_SCOPE =
`space:com.example.forum?authority=${SPACE_AUTHORITY}` +
'&collection=com.example.post';
/** @param {string} jwt */
function jwtHeader(jwt) {
return JSON.parse(
new TextDecoder().decode(base64UrlDecode(jwt.split('.')[0])),
);
}
/** A secp256k1 signer in the shape the platform packages hand @pdsjs/spaces. */
function k256SpaceSigner() {
const privateKey = k256Signer.generatePrivateKey();
const didKey = publicKeyToDidKey(
k256Signer.getPublicKey(privateKey),
'secp256k1',
);
return {
didKey,
/** A DID document publishing the key as the account's atproto method. */
didDoc: {
verificationMethod: [
{
id: '#atproto',
type: 'Multikey',
publicKeyMultibase: didKey.slice('did:key:'.length),
},
],
},
signer: {
curve: /** @type {const} */ ('secp256k1'),
/** @param {Uint8Array} bytes */
sign: (bytes) => k256Signer.sign(privateKey, bytes),
},
};
}
describe('space tokens with a secp256k1 account key', () => {
it('signs a delegation token ES256K, and the signature verifies against the account did:key', async () => {
const { didKey, signer } = k256SpaceSigner();
const path = '/xrpc/com.atproto.space.getDelegationToken';
const routes = createSpaceRoutes({
spaceStorage: createMemorySpaceStorage(),
...createMemoryBlobContext(),
getDid: async () => DID,
getSigner: async () => signer,
getKemKey: async () => null,
resolveDid: async () => null,
verifier: createVerifier({ secp256k1: secp256k1Verify }),
});
const url = new URL(`https://pds.example.com${path}`);
url.searchParams.set('space', SPACE);
// The handler declares `this: PersonalDataServer` and does not use it.
const res = await routes[path].handler.call(
/** @type {any} */ (null),
new Request(url),
url,
{ did: DID, scope: SPACE_SCOPE },
);
expect(res.status).toBe(200);
const { token } = await res.json();
const [headerB64, payloadB64, sigB64] = token.split('.');
// The authority resolves the issuer's key, reads secp256k1, and refuses a
// header naming another algorithm before it checks the signature.
expect(jwtHeader(token).alg).toBe('ES256K');
const payload = JSON.parse(
new TextDecoder().decode(base64UrlDecode(payloadB64)),
);
expect(payload.aud).toBe(`${SPACE_AUTHORITY}${ATPROTO_PDS_ID}`);
const verifier = createVerifier({ secp256k1: secp256k1Verify });
expect(
await verifier.verify(
didKey,
new TextEncoder().encode(`${headerB64}.${payloadB64}`),
base64UrlDecode(sigB64),
),
).toBe(true);
});
it('signs a space credential ES256K', async () => {
const { signer } = k256SpaceSigner();
const credential = await createSpaceToken(
'credential',
{ iss: SPACE_AUTHORITY, sub: SPACE, keyId: 'did:key:zDnaeholder' },
signer,
);
expect(jwtHeader(credential).alg).toBe('ES256K');
});
it('signs a notifyWrite service token ES256K', async () => {
const { signer } = k256SpaceSigner();
const token = await createServiceAuth({
iss: DID,
aud: `${SPACE_AUTHORITY}${ATPROTO_PDS_ID}`,
lxm: 'com.atproto.space.notifyWrite',
signer,
});
expect(jwtHeader(token).alg).toBe('ES256K');
});
it('signs ES256 for a P-256 account key', async () => {
const { privateKey } = await generateKeyPair();
const key = await importPrivateKey(privateKey);
const signer = {
curve: /** @type {const} */ ('p256'),
/** @param {Uint8Array} bytes */
sign: (bytes) => sign(key, bytes),
};
const token = await createSpaceToken(
'delegation',
{ iss: DID, sub: SPACE, aud: `${SPACE_AUTHORITY}${ATPROTO_PDS_ID}` },
signer,
);
expect(jwtHeader(token).alg).toBe('ES256');
});
});
describe('service tokens presented to a space endpoint', () => {
const AUD = 'did:plc:k256spacerepohost000000';
const LXM = 'com.atproto.space.notifyWrite';
/** @param {{didDoc: object}} identity @param {string} jwt */
function verifyOpts(identity, jwt) {
return {
jwt,
aud: AUD,
lxm: LXM,
resolveDid: async () => identity.didDoc,
verifier: createVerifier({ secp256k1: secp256k1Verify }),
};
}
it('accepts a token stamped ES256K by a secp256k1 key', async () => {
const identity = k256SpaceSigner();
const jwt = await createServiceAuth({
iss: DID,
aud: AUD,
lxm: LXM,
signer: identity.signer,
});
await expect(verifyServiceAuth(verifyOpts(identity, jwt))).resolves.toEqual(
{ iss: DID },
);
});
it('refuses a token stamped ES256 by the same key', async () => {
const identity = k256SpaceSigner();
// A signer that does not declare its curve stamps ES256, which is the
// token this server sent until the alg was derived from the key.
const jwt = await createServiceAuth({
iss: DID,
aud: AUD,
lxm: LXM,
signer: { sign: identity.signer.sign },
});
expect(jwtHeader(jwt).alg).toBe('ES256');
await expect(
verifyServiceAuth(verifyOpts(identity, jwt)),
).rejects.toMatchObject({ code: 'BadJwtSignature' });
});
});