chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// 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<Uint8Array>}}} 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<any>} 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',
});
});
});