diff --git a/packages/delegation/src/history.spec.ts b/packages/delegation/src/history.spec.ts new file mode 100644 index 000000000..b9b4ecedc --- /dev/null +++ b/packages/delegation/src/history.spec.ts @@ -0,0 +1,562 @@ +import { describe, expect, it } from "vitest"; +import { sha256Hex } from "./canonical"; +import { checkDelegatedSignature, evaluateDelegation } from "./chain"; +import { + boardAt, + evaluateFromHistory, + historyChainSource, + type HistorySource, + resolveBoard, + type Version, +} from "./history"; +import { + COMPANY_ONTOLOGY, + DELEGATION_ONTOLOGY, + ROLE_ONTOLOGY, +} from "./ontologies"; +import { buildGrantPayload } from "./payloads"; + +const ACME = "@acme"; +const NDA = "@esigner:nda"; +const INVOICE = "ontology:11111111-2222-4333-8444-555555555555"; +const NOW = new Date("2026-12-01T00:00:00.000Z"); + +/** Only `sign` makes signatures `verify` accepts. */ +const fakeSig = async (eName: string, payload: string) => + `sig:${eName}:${await sha256Hex(payload)}`; +const verify = async (eName: string, payload: string, signature: string) => + signature === (await fakeSig(eName, payload)); + +/** An in-memory eVault history; every write appends a version. */ +class Vault implements HistorySource { + private store = new Map(); + private clock = Date.parse("2026-10-01T00:00:00.000Z"); + + write(id: string, ontology: string, parsed: Record | null) { + const versions = this.store.get(id) ?? []; + this.clock += 60_000; + versions.push({ + version: versions.length + 1, + operation: parsed + ? versions.length + ? "update" + : "create" + : "delete", + ontology, + parsed, + createdAt: new Date(this.clock).toISOString(), + }); + this.store.set(id, versions); + return this.clock; + } + + async versions(id: string) { + return this.store.get(id) ?? []; + } + + /** Signs and writes, with signedAt slightly after the vault's clock. */ + async signed( + id: string, + ontology: string, + signer: string, + record: Record, + signedAt = new Date(this.clock + 30_000).toISOString(), + ) { + const { authorization: _drop, ...rest } = record; + const signedPayload = await buildGrantPayload({ + ontology, + companyEName: ACME, + recordId: id, + signerEName: signer, + signedAt, + record: rest, + }); + const full = { + ...rest, + authorization: { + signerEName: signer, + signedPayload, + signature: await fakeSig(signer, signedPayload), + signedAt, + }, + }; + this.write(id, ontology, full); + return full; + } +} + +const company = (directors: string[]) => ({ + id: "acme", + eName: ACME, + directors, + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", +}); + +const role = (over: Record = {}) => ({ + companyEName: ACME, + title: "Head of Finance", + scopes: [NDA, INVOICE], + mayRedelegate: true, + status: "active", + createdBy: "@dir", + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + ...over, +}); + +const delegation = (over: Record = {}) => ({ + companyEName: ACME, + delegateEName: "@bob", + roleId: "role", + title: "Head of Finance", + scopes: [NDA, INVOICE], + mayRedelegate: true, + grantedBy: "@dir", + status: "active", + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + ...over, +}); + +const revoked = (record: Record, by: string) => ({ + ...record, + status: "revoked", + revokedAt: "2026-10-02T00:00:00.000Z", + revokedBy: by, + revocationReason: "revoked", +}); + +/** A company with @dir on the board, a role, and Bob holding it. */ +async function acme() { + const vault = new Vault(); + await vault.signed("company", COMPANY_ONTOLOGY, "@dir", company(["@dir"])); + await vault.signed("role", ROLE_ONTOLOGY, "@dir", role()); + await vault.signed("bob", DELEGATION_ONTOLOGY, "@dir", delegation()); + return vault; +} + +const evaluate = (vault: Vault, delegationId: string) => + evaluateFromHistory({ + delegationId, + companyEName: ACME, + companyId: "company", + source: vault, + verify, + now: NOW, + }); + +describe("resolveBoard", () => { + it("starts from a self-signed board and follows director-signed changes", async () => { + const vault = new Vault(); + await vault.signed( + "company", + COMPANY_ONTOLOGY, + "@dir", + company(["@dir"]), + ); + const added = vault.write("noise", "x", {}); + await vault.signed( + "company", + COMPANY_ONTOLOGY, + "@dir", + company(["@dir", "@eve"]), + ); + const board = await resolveBoard("company", ACME, vault, verify); + expect(board.map((b) => b.directors)).toEqual([ + ["@dir"], + ["@dir", "@eve"], + ]); + expect(boardAt(board, added)).toEqual(["@dir"]); + }); + + it("ignores boards written by outsiders or without a valid signature", async () => { + const vault = new Vault(); + await vault.signed( + "company", + COMPANY_ONTOLOGY, + "@dir", + company(["@dir"]), + ); + await vault.signed( + "company", + COMPANY_ONTOLOGY, + "@mallory", + company(["@mallory"]), + ); + vault.write("company", COMPANY_ONTOLOGY, company(["@mallory"])); + const board = await resolveBoard("company", ACME, vault, verify); + expect(board.map((b) => b.directors)).toEqual([["@dir"]]); + }); + + it("refuses a first board its signer is not on", async () => { + const vault = new Vault(); + await vault.signed( + "company", + COMPANY_ONTOLOGY, + "@mallory", + company(["@dir"]), + ); + expect(await resolveBoard("company", ACME, vault, verify)).toEqual([]); + }); +}); + +describe("evaluateFromHistory", () => { + it("accepts a delegation and a narrowing re-delegation", async () => { + const vault = await acme(); + await vault.signed( + "carol", + DELEGATION_ONTOLOGY, + "@bob", + delegation({ + roleId: undefined, + parentDelegationId: "bob", + delegateEName: "@carol", + grantedBy: "@bob", + scopes: [NDA], + mayRedelegate: false, + }), + ); + expect(await evaluate(vault, "bob")).toMatchObject({ + ok: true, + delegateEName: "@bob", + }); + const carol = await evaluate(vault, "carol"); + expect(carol).toMatchObject({ + ok: true, + delegateEName: "@carol", + scopes: [NDA], + chain: ["carol", "bob"], + }); + expect( + checkDelegatedSignature( + { + onBehalfOf: ACME, + signer: "@carol", + scope: NDA, + delegationId: "carol", + documentHash: "h", + session: "s", + issuedAt: NOW.toISOString(), + }, + carol, + ), + ).toBeNull(); + }); + + it("ignores a delegation granted by a non-director", async () => { + const vault = await acme(); + await vault.signed( + "mal", + DELEGATION_ONTOLOGY, + "@mallory", + delegation({ delegateEName: "@mal", grantedBy: "@mallory" }), + ); + expect(await evaluate(vault, "mal")).toMatchObject({ + ok: false, + code: "NOT_FOUND", + }); + }); + + it("ignores a re-delegation by someone other than the parent's delegate", async () => { + const vault = await acme(); + await vault.signed( + "x", + DELEGATION_ONTOLOGY, + "@mallory", + delegation({ + roleId: undefined, + parentDelegationId: "bob", + grantedBy: "@mallory", + scopes: [NDA], + }), + ); + expect(await evaluate(vault, "x")).toMatchObject({ + ok: false, + code: "NOT_FOUND", + }); + }); + + it("fails a grant copied onto another record", async () => { + const vault = await acme(); + const [bob] = await vault.versions("bob"); + vault.write("copy", DELEGATION_ONTOLOGY, bob.parsed); + expect(await evaluate(vault, "copy")).toMatchObject({ + ok: false, + code: "NOT_FOUND", + }); + }); + + it("ignores an older signed state written back", async () => { + const vault = await acme(); + const [v1] = await vault.versions("role"); + await vault.signed( + "role", + ROLE_ONTOLOGY, + "@dir", + role({ scopes: [NDA] }), + ); + vault.write("role", ROLE_ONTOLOGY, v1.parsed); + // Bob's [NDA, INVOICE] no longer fits the narrowed role. + expect(await evaluate(vault, "bob")).toMatchObject({ + ok: false, + code: "NOT_A_SUBSET", + }); + }); + + it("makes a valid revocation final and ignores a forged one", async () => { + const vault = await acme(); + const [bob] = await vault.versions("bob"); + vault.write( + "bob", + DELEGATION_ONTOLOGY, + revoked(bob.parsed as any, "@mallory"), + ); + expect(await evaluate(vault, "bob")).toMatchObject({ ok: true }); + + await vault.signed( + "bob", + DELEGATION_ONTOLOGY, + "@dir", + revoked(delegation(), "@dir"), + ); + await vault.signed( + "bob", + DELEGATION_ONTOLOGY, + "@dir", + delegation({ updatedAt: "2026-11-01T00:00:00.000Z" }), + ); + vault.write("bob", DELEGATION_ONTOLOGY, bob.parsed); + expect(await evaluate(vault, "bob")).toMatchObject({ + ok: false, + code: "REVOKED", + }); + }); + + it("ignores a revocation signed by the delegate", async () => { + const vault = await acme(); + await vault.signed( + "bob", + DELEGATION_ONTOLOGY, + "@bob", + revoked(delegation(), "@bob"), + ); + expect(await evaluate(vault, "bob")).toMatchObject({ ok: true }); + }); + + it("keeps grants made by a director who later left", async () => { + const vault = await acme(); + await vault.signed( + "company", + COMPANY_ONTOLOGY, + "@dir", + company(["@dir", "@eve"]), + ); + await vault.signed( + "eves", + DELEGATION_ONTOLOGY, + "@eve", + delegation({ delegateEName: "@frank", grantedBy: "@eve" }), + ); + await vault.signed( + "company", + COMPANY_ONTOLOGY, + "@dir", + company(["@dir"]), + ); + expect(await evaluate(vault, "eves")).toMatchObject({ + ok: true, + delegateEName: "@frank", + }); + await vault.signed( + "late", + DELEGATION_ONTOLOGY, + "@eve", + delegation({ delegateEName: "@gus", grantedBy: "@eve" }), + ); + expect(await evaluate(vault, "late")).toMatchObject({ + ok: false, + code: "NOT_FOUND", + }); + }); + + it("revokes children implicitly when the role is revoked", async () => { + const vault = await acme(); + await vault.signed( + "role", + ROLE_ONTOLOGY, + "@dir", + revoked(role(), "@dir"), + ); + expect(await evaluate(vault, "bob")).toMatchObject({ + ok: false, + code: "REVOKED", + at: "role", + }); + }); + + it("survives vandalism and deletion of a valid record", async () => { + const vault = await acme(); + vault.write("bob", DELEGATION_ONTOLOGY, { + companyEName: ACME, + junk: true, + }); + vault.write("bob", DELEGATION_ONTOLOGY, null); + expect(await evaluate(vault, "bob")).toMatchObject({ ok: true }); + }); + + it("rejects immutable fields changing in a later version", async () => { + const vault = await acme(); + await vault.signed( + "bob", + DELEGATION_ONTOLOGY, + "@dir", + delegation({ delegateEName: "@mallory" }), + ); + expect(await evaluate(vault, "bob")).toMatchObject({ + ok: true, + delegateEName: "@bob", + }); + }); + + it("terminates on a cycle of re-delegations", async () => { + const vault = await acme(); + await vault.signed( + "a", + DELEGATION_ONTOLOGY, + "@bob", + delegation({ + roleId: undefined, + parentDelegationId: "b", + grantedBy: "@bob", + }), + ); + await vault.signed( + "b", + DELEGATION_ONTOLOGY, + "@bob", + delegation({ + roleId: undefined, + parentDelegationId: "a", + grantedBy: "@bob", + }), + ); + expect(await evaluate(vault, "a")).toMatchObject({ ok: false }); + }); + + it("ignores a postdated version so it cannot block later changes", async () => { + const vault = await acme(); + await vault.signed( + "bob", + DELEGATION_ONTOLOGY, + "@dir", + delegation({ title: "Forever" }), + "9999-01-01T00:00:00.000Z", + ); + await vault.signed( + "bob", + DELEGATION_ONTOLOGY, + "@dir", + revoked(delegation(), "@dir"), + ); + expect(await evaluate(vault, "bob")).toMatchObject({ + ok: false, + code: "REVOKED", + }); + }); + + it("ignores a revocation written before any valid grant", async () => { + const vault = await acme(); + await vault.signed( + "pre", + DELEGATION_ONTOLOGY, + "@mallory", + revoked(delegation({ grantedBy: "@mallory" }), "@mallory"), + ); + await vault.signed( + "pre", + DELEGATION_ONTOLOGY, + "@dir", + delegation({ delegateEName: "@hal" }), + ); + expect(await evaluate(vault, "pre")).toMatchObject({ + ok: true, + delegateEName: "@hal", + }); + }); + + it("resolves concurrent lookups that share a parent", async () => { + const vault = await acme(); + for (const id of ["c1", "c2"]) { + await vault.signed( + id, + DELEGATION_ONTOLOGY, + "@bob", + delegation({ + roleId: undefined, + parentDelegationId: "bob", + delegateEName: `@${id}`, + grantedBy: "@bob", + scopes: [NDA], + mayRedelegate: false, + }), + ); + } + const board = await resolveBoard("company", ACME, vault, verify); + const src = historyChainSource({ + companyEName: ACME, + board, + source: vault, + verify, + }); + const results = await Promise.all( + ["c1", "c2", "bob"].map((id) => + evaluateDelegation(id, src, { now: NOW }), + ), + ); + expect(results.map((r) => r.ok)).toEqual([true, true, true]); + }); + + it("stops reading history past the depth limit", async () => { + const vault = await acme(); + let parent = "bob"; + for (let i = 0; i < 40; i++) { + const id = `deep${i}`; + await vault.signed( + id, + DELEGATION_ONTOLOGY, + "@bob", + delegation({ + roleId: undefined, + parentDelegationId: parent, + grantedBy: "@bob", + }), + ); + parent = id; + } + let reads = 0; + const counting = { + versions: (id: string) => (reads++, vault.versions(id)), + }; + const result = await evaluateFromHistory({ + delegationId: parent, + companyEName: ACME, + companyId: "company", + source: counting, + verify, + now: NOW, + }); + expect(result.ok).toBe(false); + expect(reads).toBeLessThan(25); + }); + + it("finds nothing without a board", async () => { + const vault = new Vault(); + await vault.signed("role", ROLE_ONTOLOGY, "@dir", role()); + await vault.signed("bob", DELEGATION_ONTOLOGY, "@dir", delegation()); + expect(await evaluate(vault, "bob")).toMatchObject({ + ok: false, + code: "NOT_FOUND", + }); + }); +}); diff --git a/packages/delegation/src/history.ts b/packages/delegation/src/history.ts new file mode 100644 index 000000000..75f8552e1 --- /dev/null +++ b/packages/delegation/src/history.ts @@ -0,0 +1,326 @@ +import { canonicalJson } from "./canonical"; +import { + type ChainResult, + type ChainSource, + type DelegationRecord, + evaluateDelegation, + MAX_CHAIN_DEPTH, + type RoleRecord, +} from "./chain"; +import { + COMPANY_ONTOLOGY, + DELEGATION_ONTOLOGY, + ROLE_ONTOLOGY, +} from "./ontologies"; +import { checkGrantAuthorization, type VerifySignature } from "./payloads"; + +/** + * One version of a MetaEnvelope as the eVault's `metaEnvelopeHistory` returns + * it. `createdAt` is when the eVault stored the version. + */ +export type Version = { + version: number; + operation: "create" | "update" | "delete"; + ontology: string; + parsed: Record | null; + createdAt: string; +}; + +/** Reads a record's versions from the company's eVault, oldest first. */ +export interface HistorySource { + versions(id: string): Promise; +} + +/** Who the directors were, from each point in time on. */ +export type BoardTimeline = { from: number; directors: string[] }[]; + +/** + * The eVault is a plain store: anyone able to write to it can write anything. + * Authority is therefore read from history, keeping only versions that carry + * a valid grant from someone entitled to make them. Everything else, whether + * a forged write, a copy from another record or an older signed state written + * back, is skipped as if it were never there. + */ + +/** Builds the board's timeline from the Company record's history. */ +export async function resolveBoard( + companyId: string, + companyEName: string, + source: HistorySource, + verify: VerifySignature, +): Promise { + const timeline: BoardTimeline = []; + let board: string[] | null = null; + let lastSignedAt = Number.NEGATIVE_INFINITY; + + for (const v of await source.versions(companyId)) { + const p = v.parsed; + if (v.operation === "delete" || !p || v.ontology !== COMPANY_ONTOLOGY) { + continue; + } + if (p.eName !== companyEName || !isENameList(p.directors)) continue; + const signed = await validGrant( + COMPANY_ONTOLOGY, + companyEName, + companyId, + p, + verify, + lastSignedAt, + v.createdAt, + ); + if (!signed) continue; + + // The first board is set by one of its own directors; after that only + // a sitting director may change it. + const entitled: string[] = board ?? p.directors; + if (!entitled.includes(signed.signer)) continue; + + lastSignedAt = signed.signedAt; + if (board && canonicalJson(board) === canonicalJson(p.directors)) + continue; + board = p.directors as string[]; + timeline.push({ from: Date.parse(v.createdAt), directors: board }); + } + return timeline; +} + +/** The directors at a point in time; none before the first board. */ +export function boardAt(timeline: BoardTimeline, at: number): string[] { + let directors: string[] = []; + for (const entry of timeline) { + if (entry.from <= at) directors = entry.directors; + else break; + } + return directors; +} + +export type HistoryContext = { + companyEName: string; + board: BoardTimeline; + source: HistorySource; + verify: VerifySignature; +}; + +const ROLE_IMMUTABLE = ["companyEName", "createdBy"]; +const DELEGATION_IMMUTABLE = [ + "companyEName", + "delegateEName", + "roleId", + "parentDelegationId", + "grantedBy", +]; + +/** + * Resolves Roles and Delegations from history and feeds them to the chain + * evaluator. A signer must have been entitled when their version was stored: + * a director for a role or a role assignment, the parent's delegate for a + * re-delegation, and a director or the grantor to revoke. Grants outlive a + * director's later removal. Revocation is final. + * + * A source is a snapshot for one verification: it caches what it reads and + * never re-reads history, so build a new one per verification (as + * `evaluateFromHistory` does) or a later revocation will not be seen. + */ +export function historyChainSource(ctx: HistoryContext): ChainSource { + const roles = new Map>(); + const delegations = new Map(); + + // Cycles are detected along the current lookup path; only settled results + // are shared, so concurrent lookups never wait on each other. + const resolveDelegation = async ( + id: string, + path: ReadonlySet, + ): Promise => { + // A loop, or a chain past the evaluator's depth limit, resolves to + // nothing before any more history is read. + if (path.has(id) || path.size >= MAX_CHAIN_DEPTH) return null; + if (delegations.has(id)) return delegations.get(id) ?? null; + const inner = new Set(path).add(id); + const resolved = (await resolveRecord( + id, + DELEGATION_ONTOLOGY, + DELEGATION_IMMUTABLE, + ctx, + async (p, state, signer, at) => { + const directors = boardAt(ctx.board, at); + if (p.status === "revoked") { + // Revoking needs something valid to revoke. + if (!state) return false; + return ( + revokedBySigner(p, signer) && + (directors.includes(signer) || + signer === state.grantedBy) + ); + } + if (p.grantedBy !== signer) return false; + if (typeof p.roleId === "string") { + return directors.includes(signer); + } + if (typeof p.parentDelegationId === "string") { + const parent = await resolveDelegation( + p.parentDelegationId, + inner, + ); + return parent?.delegateEName === signer; + } + return false; + }, + )) as DelegationRecord | null; + delegations.set(id, resolved); + return resolved; + }; + + const source: ChainSource = { + role(id) { + if (!roles.has(id)) { + roles.set( + id, + resolveRecord( + id, + ROLE_ONTOLOGY, + ROLE_IMMUTABLE, + ctx, + async (p, state, signer, at) => { + if (!boardAt(ctx.board, at).includes(signer)) + return false; + if (!state && p.createdBy !== signer) return false; + return revokedBySigner(p, signer); + }, + ) as Promise, + ); + } + return roles.get(id) as Promise; + }, + delegation: (id) => resolveDelegation(id, new Set()), + }; + return source; +} + +/** + * Verifies a delegation for a company straight from its eVault history: the + * board from the Company record (the eVault's manifest), then the chain. + */ +export async function evaluateFromHistory(input: { + delegationId: string; + companyEName: string; + companyId: string; + source: HistorySource; + verify: VerifySignature; + now?: Date; +}): Promise { + const board = await resolveBoard( + input.companyId, + input.companyEName, + input.source, + input.verify, + ); + return evaluateDelegation( + input.delegationId, + historyChainSource({ + companyEName: input.companyEName, + board, + source: input.source, + verify: input.verify, + }), + { now: input.now }, + ); +} + +type Entitled = ( + next: Record, + state: Record | null, + signer: string, + at: number, +) => Promise; + +/** The latest valid state of a record, or null if it never had one. */ +async function resolveRecord( + id: string, + ontology: string, + immutable: string[], + ctx: HistoryContext, + entitled: Entitled, +): Promise | null> { + let state: Record | null = null; + let lastSignedAt = Number.NEGATIVE_INFINITY; + + for (const v of await ctx.source.versions(id)) { + if (state?.status === "revoked") break; + const p = v.parsed; + if (v.operation === "delete" || !p || v.ontology !== ontology) continue; + if (p.companyEName !== ctx.companyEName) continue; + const signed = await validGrant( + ontology, + ctx.companyEName, + id, + p, + ctx.verify, + lastSignedAt, + v.createdAt, + ); + if (!signed) continue; + if ( + state && + immutable.some( + (f) => canonicalJson(state?.[f]) !== canonicalJson(p[f]), + ) + ) { + continue; + } + if ( + !(await entitled(p, state, signed.signer, Date.parse(v.createdAt))) + ) { + continue; + } + state = p; + lastSignedAt = signed.signedAt; + } + return state; +} + +/** How far a signer's clock may run ahead of the eVault's. */ +export const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000; + +/** + * A version's grant is valid if it is signed for this record, is not older + * than the last valid version (so an earlier state cannot be written back), + * and was not dated after the eVault stored it (so a signer cannot postdate a + * version to make every later change look older). + */ +async function validGrant( + ontology: string, + companyEName: string, + id: string, + record: Record, + verify: VerifySignature, + lastSignedAt: number, + storedAt: string, +): Promise<{ signer: string; signedAt: number } | null> { + if ( + await checkGrantAuthorization( + ontology, + companyEName, + id, + record, + verify, + ) + ) { + return null; + } + const signedAt = Date.parse(record.authorization.signedAt); + if (signedAt < lastSignedAt) return null; + if (signedAt > Date.parse(storedAt) + MAX_CLOCK_SKEW_MS) return null; + return { signer: record.authorization.signerEName, signedAt }; +} + +function revokedBySigner(p: Record, signer: string): boolean { + return p.status !== "revoked" || p.revokedBy === signer; +} + +function isENameList(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.length > 0 && + value.every((v) => typeof v === "string" && /^@\S+$/.test(v)) + ); +} diff --git a/packages/delegation/src/index.ts b/packages/delegation/src/index.ts index d6fbcdb23..85144c5cd 100644 --- a/packages/delegation/src/index.ts +++ b/packages/delegation/src/index.ts @@ -1,5 +1,6 @@ export * from "./canonical"; export * from "./chain"; +export * from "./history"; export * from "./ontologies"; export * from "./payloads"; export * from "./scopes"; diff --git a/packages/delegation/src/payloads.spec.ts b/packages/delegation/src/payloads.spec.ts index 0eaf47cc4..8e9d584ce 100644 --- a/packages/delegation/src/payloads.spec.ts +++ b/packages/delegation/src/payloads.spec.ts @@ -55,13 +55,17 @@ describe("delegated sign payload", () => { }); }); +const SIGNED_AT = "2026-10-07T12:00:00.000Z"; + describe("grant payload", () => { it("keeps an own __proto__ key in the hash", async () => { const build = (record: Record) => buildGrantPayload({ ontology: ROLE_ONTOLOGY, companyEName: "@acme", + recordId: "r1", signerEName: "@dir", + signedAt: SIGNED_AT, record, }); const smuggled = JSON.parse( @@ -80,13 +84,17 @@ describe("grant payload", () => { const a = await buildGrantPayload({ ontology: ROLE_ONTOLOGY, companyEName: "@acme", + recordId: "r1", signerEName: "@dir", + signedAt: SIGNED_AT, record, }); const b = await buildGrantPayload({ ontology: ROLE_ONTOLOGY, companyEName: "@acme", + recordId: "r1", signerEName: "@dir", + signedAt: SIGNED_AT, record: { scopes: record.scopes, authorization: { x: 1 }, @@ -102,14 +110,16 @@ describe("grant payload", () => { const signedPayload = await buildGrantPayload({ ontology: ROLE_ONTOLOGY, companyEName: "@acme", + recordId: "r1", signerEName: "@dir", + signedAt: SIGNED_AT, record, }); const authorization = { signerEName: "@dir", signedPayload, signature: "sig", - signedAt: "2026-10-07T12:00:00.000Z", + signedAt: SIGNED_AT, }; const ok = async (e: string, p: string, s: string) => e === "@dir" && p === signedPayload && s === "sig"; @@ -118,6 +128,7 @@ describe("grant payload", () => { await checkGrantAuthorization( ROLE_ONTOLOGY, "@acme", + "r1", { ...record, authorization }, ok, ), @@ -126,6 +137,7 @@ describe("grant payload", () => { await checkGrantAuthorization( ROLE_ONTOLOGY, "@acme", + "r1", { ...record, scopes: ["@esigner:invoice"], authorization }, ok, ), @@ -134,6 +146,7 @@ describe("grant payload", () => { await checkGrantAuthorization( ROLE_ONTOLOGY, "@acme", + "r1", { ...record, authorization: { ...authorization, signature: "forged" }, @@ -142,9 +155,69 @@ describe("grant payload", () => { ), ).toEqual({ code: "BAD_SIGNATURE" }); expect( - await checkGrantAuthorization(ROLE_ONTOLOGY, "@acme", record, ok), + await checkGrantAuthorization( + ROLE_ONTOLOGY, + "@acme", + "r1", + record, + ok, + ), ).toEqual({ code: "MISSING_AUTHORIZATION", }); }); + + it("binds the record id and signing time", async () => { + const signedPayload = await buildGrantPayload({ + ontology: ROLE_ONTOLOGY, + companyEName: "@acme", + recordId: "r1", + signerEName: "@dir", + signedAt: SIGNED_AT, + record, + }); + const authorization = { + signerEName: "@dir", + signedPayload, + signature: "sig", + signedAt: SIGNED_AT, + }; + const ok = async () => true; + expect( + await checkGrantAuthorization( + ROLE_ONTOLOGY, + "@acme", + "r2", + { ...record, authorization }, + ok, + ), + ).toEqual({ code: "PAYLOAD_MISMATCH" }); + expect( + await checkGrantAuthorization( + ROLE_ONTOLOGY, + "@acme", + "r1", + { + ...record, + authorization: { + ...authorization, + signedAt: "2026-10-08T00:00:00.000Z", + }, + }, + ok, + ), + ).toEqual({ code: "PAYLOAD_MISMATCH" }); + expect( + await checkGrantAuthorization( + ROLE_ONTOLOGY, + "@acme", + "r1", + { + ...record, + authorization: { ...authorization, signedAt: "soon" }, + }, + ok, + ), + ).toEqual({ code: "BAD_SIGNED_AT" }); + }); }); diff --git a/packages/delegation/src/payloads.ts b/packages/delegation/src/payloads.ts index 0a73f2d15..93a84339d 100644 --- a/packages/delegation/src/payloads.ts +++ b/packages/delegation/src/payloads.ts @@ -113,14 +113,20 @@ export type Authorization = { }; /** - * The string a grantor signs to authorise a record: it names the record's - * ontology, company and signer, and commits to the record through its hash, - * which keeps it short enough to sign whatever the record's size. + * The string a grantor signs to authorise a record. It names the record's + * ontology, company, MetaEnvelope id, signer and signing time, and commits to + * the record through its hash, which keeps it short whatever the record's size. + * + * Binding the id means a copy onto another record fails; binding the time lets + * verifiers refuse an older signed state written back over a newer one. The + * writer therefore picks the record's id before signing and creating it. */ export async function buildGrantPayload(input: { ontology: string; companyEName: string; + recordId: string; signerEName: string; + signedAt: string; record: Record; }): Promise { const { authorization: _ignored, ...rest } = input.record; @@ -129,7 +135,9 @@ export async function buildGrantPayload(input: { canonicalJson({ companyEName: input.companyEName, ontology: input.ontology, + recordId: input.recordId, recordSha256: await sha256Hex(canonicalJson(rest)), + signedAt: input.signedAt, signer: input.signerEName, }) ); @@ -144,16 +152,19 @@ export type VerifySignature = ( export type GrantProblem = | { code: "MISSING_AUTHORIZATION" } + | { code: "BAD_SIGNED_AT" } | { code: "PAYLOAD_MISMATCH" } | { code: "BAD_SIGNATURE" }; /** * Checks that a record's `authorization` was signed by its stated signer over - * this exact record. Who that signer must be is the caller's decision. + * this exact record under this id. Who that signer must be is the caller's + * decision. */ export async function checkGrantAuthorization( ontology: string, companyEName: string, + recordId: string, record: Record, verify: VerifySignature, ): Promise { @@ -166,10 +177,18 @@ export async function checkGrantAuthorization( ) { return { code: "MISSING_AUTHORIZATION" }; } + if ( + typeof auth.signedAt !== "string" || + Number.isNaN(Date.parse(auth.signedAt)) + ) { + return { code: "BAD_SIGNED_AT" }; + } const expected = await buildGrantPayload({ ontology, companyEName, + recordId, signerEName: auth.signerEName, + signedAt: auth.signedAt, record, }); if (auth.signedPayload !== expected) return { code: "PAYLOAD_MISMATCH" };