~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
363 lines · 10.5 KB Raw History
// @pdsjs/spaces/star - permissioned repos as STAR archives.
//
//   [ magic | len | meta ] [ 1 | len | key | len | val ] ... [ 0 ]
//
// A control byte comes before each pair (1) and ends the archive (0). Each
// `len` is an unsigned varint in its shortest form. A space repo is the
// `atproto-space-repo` profile: the metadata is the repo's commit, typed by
// `$type`, and each pair is a record path and the record's DAG-CBOR encoding,
// in strict lexicographic byte order of the path.
//
// See https://dasl.ing/star.html

import {
  cborDecode,
  cborEncodeDagCbor,
  cidToString,
  createCid,
  jsonToLex,
} from '@pdsjs/core/repo';
import { RepoCommit, verifyCommit } from './commit.js';
import { bytesEqual, verifyRecordMac } from './mac.js';
import { formatRecordPath, parseRecordPath } from './path.js';
import { isSpaceUri } from './uri.js';

export const STAR_MAGIC = new Uint8Array([0x2a, 0x6c, 0x01, 0x00]);
export const REPO_STAR_TYPE = 'atproto-space-repo';

/** The largest DAG-CBOR encoding a space record may have. */
export const MAX_RECORD_SIZE = 1024 * 1024;

const CONTROL_PAIR = 0x01;
const CONTROL_END = 0x00;
const MAX_VARINT_BYTES = 9;
const MAX_META_LENGTH = 64 * 1024;
const MAX_KEY_LENGTH = 4 * 1024;

export class RepoVerificationError extends Error {
  /** @param {string} message */
  constructor(message) {
    super(message);
    this.name = 'RepoVerificationError';
  }
}

/**
 * @typedef {Object} SerializedRecord
 * @property {string} collection
 * @property {string} rkey
 * @property {Uint8Array} bytes - DAG-CBOR encoded record
 */

/**
 * Lexicographic byte order: the first differing byte decides, and a prefix
 * sorts before any longer key it starts.
 * @param {Uint8Array} a
 * @param {Uint8Array} b
 * @returns {number}
 */
export function compareBytes(a, b) {
  const length = Math.min(a.length, b.length);
  for (let i = 0; i < length; i++) {
    if (a[i] !== b[i]) return a[i] - b[i];
  }
  return a.length - b.length;
}

/**
 * Serialize a repo: the commit as the metadata, then one path/record pair per
 * record. Blobs are excluded. Records may arrive in any order; the archive
 * holds them in path order.
 *
 * @param {import('./mac.js').Commit} commit
 * @param {Iterable<SerializedRecord>} records
 * @returns {Uint8Array}
 */
export function serializeRepo(commit, records) {
  const encoder = new TextEncoder();
  const pairs = [...records]
    .map((record) => ({
      key: encoder.encode(formatRecordPath(record.collection, record.rkey)),
      value: record.bytes,
    }))
    .sort((a, b) => compareBytes(a.key, b.key));
  for (let i = 1; i < pairs.length; i++) {
    if (compareBytes(pairs[i - 1].key, pairs[i].key) === 0) {
      throw new Error('duplicate record path');
    }
  }
  const meta = cborEncodeDagCbor({
    $type: REPO_STAR_TYPE,
    ver: commit.ver,
    repo: commit.repo,
    space: commit.space,
    rev: commit.rev,
    data: commit.data,
  });

  /** @type {Uint8Array[]} */
  const chunks = [STAR_MAGIC, encodeVarint(meta.length), meta];
  const pair = Uint8Array.of(CONTROL_PAIR);
  for (const { key, value } of pairs) {
    chunks.push(pair, encodeVarint(key.length), key);
    chunks.push(encodeVarint(value.length), value);
  }
  chunks.push(Uint8Array.of(CONTROL_END));
  let size = 0;
  for (const chunk of chunks) size += chunk.length;
  const out = new Uint8Array(size);
  let offset = 0;
  for (const chunk of chunks) {
    out.set(chunk, offset);
    offset += chunk.length;
  }
  return out;
}

/**
 * Read a STAR archive: its metadata, then its pairs.
 *
 * Lengths are checked before a frame is read, so an oversized frame fails
 * without being sliced out.
 *
 * @param {Uint8Array} bytes
 * @param {{maxValueLength?: number}} [opts]
 * @returns {{meta: Record<string, unknown>, pairs: Array<{key: Uint8Array, value: Uint8Array}>}}
 * @throws {RepoVerificationError}
 */
export function readStar(bytes, opts = {}) {
  const maxValueLength = opts.maxValueLength ?? Number.MAX_SAFE_INTEGER;
  const reader = { bytes, offset: 0 };
  if (!bytesEqual(bytes.subarray(0, STAR_MAGIC.length), STAR_MAGIC)) {
    throw new RepoVerificationError('invalid STAR magic bytes');
  }
  reader.offset = STAR_MAGIC.length;
  const metaBytes = readFrame(reader, MAX_META_LENGTH);

  /** @type {unknown} */
  let meta;
  try {
    meta = cborDecode(metaBytes);
  } catch {
    throw new RepoVerificationError('invalid metadata');
  }
  if (!isPlainObject(meta) || typeof meta.$type !== 'string') {
    throw new RepoVerificationError('metadata must be a map with a $type');
  }
  if (!bytesEqual(cborEncodeDagCbor(meta), metaBytes)) {
    throw new RepoVerificationError('metadata is not canonical DAG-CBOR');
  }

  const pairs = [];
  for (;;) {
    const control = bytes[reader.offset++];
    if (control === undefined) {
      throw new RepoVerificationError('unexpected end of archive');
    }
    if (control === CONTROL_END) break;
    if (control !== CONTROL_PAIR) {
      throw new RepoVerificationError(`invalid control byte: ${control}`);
    }
    const key = readFrame(reader, MAX_KEY_LENGTH);
    const value = readFrame(reader, maxValueLength);
    pairs.push({ key, value });
  }
  if (reader.offset !== bytes.length) {
    throw new RepoVerificationError('unexpected bytes after end of archive');
  }
  return { meta, pairs };
}

/**
 * @typedef {Object} VerifiedRecord
 * @property {string} collection
 * @property {string} rkey
 * @property {string} cid
 * @property {Record<string, unknown>} record
 */

/**
 * Verify a serialized repo and collect its records.
 *
 * The commit in the metadata is verified against the response's MAC, each
 * record is folded into a set hash, and the result must match the commit's
 * data. Only that match shows the repo arrived complete.
 *
 * @param {Uint8Array} star
 * @param {import('./commit.js').VerifyCommitParams} params
 * @returns {Promise<{commit: import('./mac.js').Commit, records: VerifiedRecord[]}>}
 * @throws {RepoVerificationError}
 */
export async function verifyRepoStar(star, params) {
  const { meta, pairs } = readStar(star, { maxValueLength: MAX_RECORD_SIZE });
  const commit = parseRepoMeta(meta);
  if (!(await verifyCommit(commit, params))) {
    throw new RepoVerificationError('commit failed verification');
  }

  const decoder = new TextDecoder();
  const repo = new RepoCommit();
  /** @type {VerifiedRecord[]} */
  const records = [];
  /** @type {Uint8Array|undefined} */
  let prevKey;
  for (const { key, value } of pairs) {
    const path = decoder.decode(key);
    /** @type {{collection: string, rkey: string}} */
    let parts;
    try {
      parts = parseRecordPath(path);
    } catch {
      throw new RepoVerificationError(`invalid record path: ${path}`);
    }
    if (prevKey !== undefined && compareBytes(key, prevKey) <= 0) {
      throw new RepoVerificationError(`records out of order at ${path}`);
    }
    prevKey = key;

    /** @type {unknown} */
    let record;
    try {
      record = cborDecode(value);
    } catch {
      throw new RepoVerificationError(`invalid record cbor at ${path}`);
    }
    if (!isPlainObject(record)) {
      throw new RepoVerificationError(`invalid record at ${path}`);
    }
    const cid = cidToString(await createCid(value));
    repo.add(parts.collection, parts.rkey, cid);
    records.push({ ...parts, cid, record });
  }

  if (!(await repo.matches(commit))) {
    throw new RepoVerificationError('records do not match the commit data');
  }
  return { commit, records };
}

/**
 * Verify a getRecord response against the MAC it carried: it must be the
 * record that was requested, the MAC must cover its uri and cid, and its value
 * must encode to that cid.
 *
 * @param {{uri: string, cid: string, value: unknown}} record
 * @param {{uri: string, sharedSecret: Uint8Array, mac: Uint8Array}} params
 * @returns {Promise<boolean>}
 */
export async function verifyRecord(record, params) {
  if (record.uri !== params.uri) return false;
  try {
    if (
      !(await verifyRecordMac(
        params.sharedSecret,
        record.uri,
        record.cid,
        params.mac,
      ))
    ) {
      return false;
    }
  } catch {
    return false;
  }
  const valueCid = cidToString(
    await createCid(cborEncodeDagCbor(jsonToLex(record.value))),
  );
  return valueCid === record.cid;
}

/**
 * @param {Record<string, unknown>} meta
 * @returns {import('./mac.js').Commit}
 */
function parseRepoMeta(meta) {
  const { $type, ver, repo, space, rev, data } = meta;
  if (
    $type !== REPO_STAR_TYPE ||
    typeof ver !== 'number' ||
    typeof repo !== 'string' ||
    !repo.startsWith('did:') ||
    typeof space !== 'string' ||
    !isSpaceUri(space) ||
    typeof rev !== 'string' ||
    !(data instanceof Uint8Array)
  ) {
    throw new RepoVerificationError('invalid repo metadata');
  }
  return { ver, repo, space, rev, data };
}

/**
 * @param {unknown} value
 * @returns {value is Record<string, unknown>}
 */
function isPlainObject(value) {
  return (
    typeof value === 'object' &&
    value !== null &&
    !Array.isArray(value) &&
    !(value instanceof Uint8Array)
  );
}

/**
 * @param {number} n
 * @returns {Uint8Array}
 */
export function encodeVarint(n) {
  const out = [];
  let v = n;
  while (v >= 0x80) {
    out.push((v % 0x80) | 0x80);
    v = Math.floor(v / 0x80);
  }
  out.push(v);
  return new Uint8Array(out);
}

/**
 * The next frame.
 * @param {{bytes: Uint8Array, offset: number}} reader
 * @param {number} maxLength
 * @returns {Uint8Array}
 */
function readFrame(reader, maxLength) {
  const length = readVarint(reader);
  if (length > maxLength) {
    throw new RepoVerificationError(
      `length ${length} exceeds limit of ${maxLength}`,
    );
  }
  if (reader.offset + length > reader.bytes.length) {
    throw new RepoVerificationError('unexpected end of archive');
  }
  const frame = reader.bytes.subarray(reader.offset, reader.offset + length);
  reader.offset += length;
  return frame;
}

/**
 * Unsigned LEB128, least significant group first. Arithmetic rather than
 * bitwise operators, since lengths can exceed 32 bits.
 * @param {{bytes: Uint8Array, offset: number}} reader
 * @returns {number}
 */
function readVarint(reader) {
  let value = 0;
  for (let i = 0; i < MAX_VARINT_BYTES; i++) {
    if (reader.offset >= reader.bytes.length) {
      throw new RepoVerificationError('unexpected end of archive');
    }
    const byte = reader.bytes[reader.offset++];
    value += (byte & 0x7f) * 2 ** (7 * i);
    if (byte < 0x80) {
      // A final zero byte only pads the encoding, which STAR forbids.
      if (byte === 0 && i > 0) {
        throw new RepoVerificationError('non-canonical varint');
      }
      return value;
    }
  }
  throw new RepoVerificationError('varint too long');
}