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

chadtmiller.com/pds.js

An atproto PDS in JavaScript, for Cloudflare Workers and Node

0
0
main
122 lines · 3.4 KB Raw History
// @pdsjs/spaces/commit - permissioned repo commits.
//
// A commit names the repo, the space, the rev and the digest of the repo's set
// hash. It carries no signature: the repo host authenticates it to each syncer
// with a MAC (see mac.js).

import { LtHash } from './lthash.js';
import { bytesEqual, COMMIT_VERSION, verifyCommitMac } from './mac.js';
import { formatSetHashElement } from './path.js';

/** @typedef {import('./mac.js').Commit} Commit */

export class RepoCommit {
  /** @param {LtHash} [setHash] */
  constructor(setHash = new LtHash()) {
    this.setHash = setHash;
  }

  /** @param {Uint8Array|null|undefined} state */
  static fromState(state) {
    return new RepoCommit(new LtHash(state ?? null));
  }

  /** @param {Iterable<{collection: string, rkey: string, cid: string}>} records */
  static fromRecords(records) {
    const commit = new RepoCommit();
    for (const { collection, rkey, cid } of records) {
      commit.add(collection, rkey, cid);
    }
    return commit;
  }

  /**
   * @param {string} collection
   * @param {string} rkey
   * @param {string} cid
   * @returns {this}
   */
  add(collection, rkey, cid) {
    this.setHash.add(formatSetHashElement(collection, rkey, cid));
    return this;
  }

  /**
   * @param {string} collection
   * @param {string} rkey
   * @param {string} cid
   * @returns {this}
   */
  remove(collection, rkey, cid) {
    this.setHash.remove(formatSetHashElement(collection, rkey, cid));
    return this;
  }

  /** @param {{collection: string, rkey: string, cid: string|null, prev: string|null}} op */
  applyOp(op) {
    if (op.prev) this.remove(op.collection, op.rkey, op.prev);
    if (op.cid) this.add(op.collection, op.rkey, op.cid);
    return this;
  }

  /**
   * @param {Iterable<{collection: string, rkey: string, cid: string|null, prev: string|null}>} ops
   * @returns {this}
   */
  applyOps(ops) {
    for (const op of ops) this.applyOp(op);
    return this;
  }

  state() {
    return this.setHash.state();
  }

  /**
   * Whether this repo's contents match a commit. Verify the commit first. On
   * its own this says nothing about authenticity.
   * @param {Commit} commit
   */
  async matches(commit) {
    return bytesEqual(await this.setHash.digest(), commit.data);
  }

  /**
   * A commit over the current contents.
   * @param {{space: string, repo: string, rev: string}} ctx
   * @returns {Promise<Commit>}
   */
  async toCommit(ctx) {
    return {
      ver: COMMIT_VERSION,
      repo: ctx.repo,
      space: ctx.space,
      rev: ctx.rev,
      data: await this.setHash.digest(),
    };
  }
}

/**
 * @typedef {Object} VerifyCommitParams
 * @property {string} space - the space that was requested
 * @property {string} repo - the repo that was requested
 * @property {Uint8Array} sharedSecret - the secret for the request
 * @property {Uint8Array} mac - from the response's Atproto-Space-Mac header
 */

/**
 * Verify a commit against the MAC its response carried. Once this passes,
 * `data` is trusted as the author's claim about their repo, which is what makes
 * RepoCommit#matches meaningful.
 *
 * @param {Commit} commit
 * @param {VerifyCommitParams} params
 * @returns {Promise<boolean>}
 */
export async function verifyCommit(commit, params) {
  if (commit.ver !== COMMIT_VERSION) return false;
  if (commit.repo !== params.repo) return false;
  if (commit.space !== params.space) return false;
  return verifyCommitMac(params.sharedSecret, commit, params.mac);
}