chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
import { cborEncodeDagCbor } from '@pdsjs/core/repo';
import { describe, expect, it } from 'vitest';
import {
compareBytes,
encodeVarint,
MAX_RECORD_SIZE,
RepoVerificationError,
readStar,
STAR_MAGIC,
serializeRepo,
verifyRecord,
verifyRepoStar,
} from '../src/star.js';
import { fromHex, referenceCommit, toHex, vectors } from './helpers/vectors.js';
const records = vectors.commit.records.map((/** @type {any} */ r) => ({
collection: r.collection,
rkey: r.rkey,
bytes: fromHex(r.valueHex),
}));
const params = {
space: vectors.commit.space,
repo: vectors.commit.repo,
sharedSecret: fromHex(vectors.commit.secretHex),
mac: fromHex(vectors.commit.macHex),
};
/**
* A STAR archive from raw frames.
* @param {Uint8Array} meta
* @param {Array<[Uint8Array, Uint8Array]>} pairs
*/
function frames(meta, pairs) {
/** @type {number[]} */
const out = [...STAR_MAGIC, ...encodeVarint(meta.length), ...meta];
for (const [k, v] of pairs) {
out.push(
1,
...encodeVarint(k.length),
...k,
...encodeVarint(v.length),
...v,
);
}
out.push(0);
return new Uint8Array(out);
}
const enc = new TextEncoder();
const meta = () =>
cborEncodeDagCbor({ $type: 'atproto-space-repo', ...referenceCommit() });
describe('serializeRepo', () => {
it('reproduces the reference archive byte for byte', () => {
expect(toHex(serializeRepo(referenceCommit(), records))).toBe(
vectors.commit.starHex,
);
});
it('sorts records given out of order', () => {
expect(
toHex(serializeRepo(referenceCommit(), [...records].reverse())),
).toBe(vectors.commit.starHex);
});
it('refuses a duplicate path', () => {
expect(() =>
serializeRepo(referenceCommit(), [records[0], records[0]]),
).toThrow(/duplicate/);
});
});
describe('verifyRepoStar', () => {
it('verifies the reference archive', async () => {
const { commit, records: out } = await verifyRepoStar(
fromHex(vectors.commit.starHex),
params,
);
expect(commit).toEqual(referenceCommit());
expect(out.map((r) => r.record)).toEqual([
{ text: 'hello' },
{ text: 'world' },
]);
expect(out.map((r) => r.cid)).toEqual(
vectors.commit.records.map((/** @type {any} */ r) => r.cid),
);
});
it('refuses an archive missing a record', async () => {
const star = serializeRepo(referenceCommit(), [records[0]]);
await expect(verifyRepoStar(star, params)).rejects.toThrow(/do not match/);
});
it('refuses a commit the MAC does not cover', async () => {
await expect(
verifyRepoStar(fromHex(vectors.commit.starHex), {
...params,
sharedSecret: new Uint8Array(32),
}),
).rejects.toThrow(/commit failed verification/);
});
it('refuses records out of order', async () => {
const star = frames(meta(), [
[
enc.encode(`${records[1].collection}/${records[1].rkey}`),
records[1].bytes,
],
[
enc.encode(`${records[0].collection}/${records[0].rkey}`),
records[0].bytes,
],
]);
await expect(verifyRepoStar(star, params)).rejects.toThrow(/out of order/);
});
it('refuses a bad path, bad CBOR or a non-map record', async () => {
for (const [key, value, pattern] of /** @type {const} */ ([
['nopath', records[0].bytes, /invalid record path/],
['app.example.post/a', new Uint8Array([0xa1]), /./],
['app.example.post/a', cborEncodeDagCbor([1]), /invalid record at/],
])) {
await expect(
verifyRepoStar(frames(meta(), [[enc.encode(key), value]]), params),
).rejects.toThrow(pattern);
}
});
it('refuses metadata of another type or shape', async () => {
for (const m of [
{ ...referenceCommit(), $type: 'something-else' },
{ $type: 'atproto-space-repo', ...referenceCommit(), repo: 'nope' },
{ $type: 'atproto-space-repo', ...referenceCommit(), data: 'nope' },
]) {
await expect(
verifyRepoStar(frames(cborEncodeDagCbor(m), []), params),
).rejects.toThrow(RepoVerificationError);
}
});
});
describe('readStar', () => {
it('refuses bad magic, missing metadata and truncation', () => {
expect(() => readStar(new Uint8Array([1, 2, 3, 4]))).toThrow(/magic/);
expect(() => readStar(STAR_MAGIC)).toThrow(/unexpected end/);
const star = fromHex(vectors.commit.starHex);
expect(() => readStar(star.subarray(0, star.length - 1))).toThrow(
/unexpected end/,
);
// Ended where a pair's value should start.
const open = frames(meta(), []).slice(0, -1);
expect(() => readStar(new Uint8Array([...open, 1, 1, 0x61]))).toThrow(
/unexpected end/,
);
expect(() => readStar(open)).toThrow(/unexpected end/);
});
it('refuses an archive without its control bytes, or with more after the end', () => {
const open = frames(meta(), []).slice(0, -1);
expect(() => readStar(new Uint8Array([...open, 7]))).toThrow(
/invalid control byte: 7/,
);
expect(() => readStar(new Uint8Array([...open, 0, 1]))).toThrow(
/after end of archive/,
);
});
it('refuses metadata that is not a canonical typed map', () => {
expect(() => readStar(frames(new Uint8Array([0xa1]), []))).toThrow(
RepoVerificationError,
);
expect(() => readStar(frames(cborEncodeDagCbor({ a: 1 }), []))).toThrow(
/\$type/,
);
// { $type: 'x', a: 1 } with its keys out of canonical order, which puts
// the shorter key first.
const nonCanonical = new Uint8Array([
0xa2,
0x65,
...enc.encode('$type'),
0x61,
0x78,
0x61,
0x61,
0x01,
]);
expect(() => readStar(frames(nonCanonical, []))).toThrow(/canonical/);
});
it('refuses a padded or overlong varint and an oversized value', () => {
expect(() => readStar(new Uint8Array([...STAR_MAGIC, 0x81, 0x00]))).toThrow(
/non-canonical/,
);
expect(() =>
readStar(
new Uint8Array([
...STAR_MAGIC,
0x80,
0x80,
0x80,
0x80,
0x80,
0x80,
0x80,
0x80,
0x80,
0x01,
]),
),
).toThrow(/too long/);
expect(() =>
readStar(frames(meta(), [[enc.encode('a/b'), new Uint8Array(10)]]), {
maxValueLength: 5,
}),
).toThrow(/exceeds limit/);
});
it('encodes lengths past 32 bits', () => {
expect(toHex(encodeVarint(2 ** 35))).toBe('808080808001');
expect(MAX_RECORD_SIZE).toBe(1024 * 1024);
});
it('orders bytes lexicographically', () => {
expect(compareBytes(enc.encode('a'), enc.encode('ab'))).toBeLessThan(0);
expect(compareBytes(enc.encode('b'), enc.encode('ab'))).toBeGreaterThan(0);
});
});
describe('verifyRecord', () => {
const { uri, cid } = vectors.record;
const recordParams = {
uri,
sharedSecret: fromHex(vectors.record.secretHex),
mac: fromHex(vectors.record.macHex),
};
const record = { uri, cid, value: { text: 'hello' } };
it('verifies the reference record', async () => {
expect(await verifyRecord(record, recordParams)).toBe(true);
});
it('refuses another uri, value, cid or MAC', async () => {
expect(
await verifyRecord(record, { ...recordParams, uri: `${uri}x` }),
).toBe(false);
expect(
await verifyRecord({ ...record, value: { text: 'world' } }, recordParams),
).toBe(false);
expect(
await verifyRecord({ ...record, cid: 'not-a-cid' }, recordParams),
).toBe(false);
expect(
await verifyRecord(record, {
...recordParams,
mac: new Uint8Array(32),
}),
).toBe(false);
});
});