// 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' }); }); });