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