// @pdsjs/spaces/commit - permissioned repo commits with deniable signatures. import { LtHash } from './lthash.js'; import { bytesEqual, COMMIT_VERSION, computeMac, encodeCommitCtx, } from './mac.js'; import { formatSetHashElement } from './path.js'; /** * @typedef {Object} SignedCommit * @property {number} ver * @property {Uint8Array} hash * @property {Uint8Array} ikm * @property {Uint8Array} sig * @property {Uint8Array} mac * @property {string} rev */ 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; } /** * Fold in every record an index describes, to compare against a commit. * @param {Record} index - "{collection}/{rkey}" to CID */ static fromIndex(index) { const commit = new RepoCommit(); for (const [path, cid] of Object.entries(index)) { commit.setHash.add(`${path}/${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 signed commit. Verify the commit first — * on its own this says nothing about authenticity. * @param {SignedCommit} commit */ async matches(commit) { return bytesEqual(await this.setHash.digest(), commit.hash); } /** * Sign a commit over the current contents. * * The signature covers only the ctx, never the digest, so a leaked commit * proves nothing about what the author wrote. The digest is bound to the ctx * by a symmetric MAC instead: readers get integrity, third parties get * nothing. A fresh ikm per commit means each reader receives a distinct one. * * @param {{space: string, author: string, rev: string}} ctx * @param {{sign: (bytes: Uint8Array) => Promise}} signer * @returns {Promise} */ async sign(ctx, signer) { const hash = await this.setHash.digest(); const ikm = crypto.getRandomValues(new Uint8Array(32)); const ctxBytes = encodeCommitCtx(ctx, ikm); return { ver: COMMIT_VERSION, hash, ikm, mac: await computeMac(ikm, ctxBytes, hash), sig: await signer.sign(ctxBytes), rev: ctx.rev, }; } } /** * Verify a commit's signature (authenticity) and MAC (integrity). Once this * passes, `hash` is trusted as the author's claim about their repo, which is * what makes RepoCommit#matches meaningful. * * @param {SignedCommit} commit * @param {{space: string, author: string, rev: string}} ctx * @param {string} didKey * @param {import('@pdsjs/core/ports').SignatureVerifierPort} verifier * @returns {Promise} */ export async function verifyCommit(commit, ctx, didKey, verifier) { if (commit.ver !== COMMIT_VERSION) return false; if (commit.rev !== ctx.rev) return false; const ctxBytes = encodeCommitCtx(ctx, commit.ikm); const mac = await computeMac(commit.ikm, ctxBytes, commit.hash); if (!bytesEqual(mac, commit.mac)) return false; return verifier.verify(didKey, ctxBytes, commit.sig); }