diff --git a/packages/delegation/.gitignore b/packages/delegation/.gitignore new file mode 100644 index 000000000..b94707787 --- /dev/null +++ b/packages/delegation/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +dist/ diff --git a/packages/delegation/package.json b/packages/delegation/package.json new file mode 100644 index 000000000..0db0ee734 --- /dev/null +++ b/packages/delegation/package.json @@ -0,0 +1,21 @@ +{ + "name": "@metastate-foundation/delegation", + "version": "0.1.0", + "description": "Company signing delegation: scopes, signed payload formats and chain evaluation", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "check-types": "tsc --noEmit", + "test": "vitest run", + "postinstall": "npm run build" + }, + "devDependencies": { + "@types/node": "^20.11.24", + "typescript": "~5.6.2", + "vitest": "^3.2.4" + } +} diff --git a/packages/delegation/src/canonical.ts b/packages/delegation/src/canonical.ts new file mode 100644 index 000000000..c3121e39b --- /dev/null +++ b/packages/delegation/src/canonical.ts @@ -0,0 +1,43 @@ +/** + * Deterministic JSON: object keys sorted at every level, `undefined` dropped. + * Two parties serialising the same record always get the same string. + */ +export function canonicalJson(value: unknown): string { + return JSON.stringify(sortKeys(value)); +} + +function sortKeys(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortKeys); + if (value && typeof value === "object") { + // Null prototype, so an own `__proto__` key stays a key instead of + // hitting the prototype setter and silently dropping out of the hash. + const out: Record = Object.create(null); + for (const key of Object.keys(value).sort()) { + const v = (value as Record)[key]; + if (v !== undefined) out[key] = sortKeys(v); + } + return out; + } + return value; +} + +/** + * Web Crypto is global in browsers and Node 19+; Node 18 only exposes it as + * `webcrypto` on the crypto module. + */ +async function subtle(): Promise { + if (globalThis.crypto?.subtle) return globalThis.crypto.subtle; + const { webcrypto } = await import("node:crypto"); + return webcrypto.subtle as SubtleCrypto; +} + +/** Hex SHA-256 of a UTF-8 string. */ +export async function sha256Hex(input: string): Promise { + const digest = await (await subtle()).digest( + "SHA-256", + new TextEncoder().encode(input), + ); + return Array.from(new Uint8Array(digest)) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); +} diff --git a/packages/delegation/src/chain.spec.ts b/packages/delegation/src/chain.spec.ts new file mode 100644 index 000000000..649de48dc --- /dev/null +++ b/packages/delegation/src/chain.spec.ts @@ -0,0 +1,294 @@ +import { describe, expect, it } from "vitest"; +import { + type ChainSource, + checkDelegatedSignature, + type DelegationRecord, + evaluateDelegation, + isWindowWithin, + type RoleRecord, +} from "./chain"; +import type { DelegatedSignPayload } from "./payloads"; + +const NDA = "@esigner:nda"; +const INVOICE = "ontology:11111111-2222-4333-8444-555555555555"; +const NOW = new Date("2026-10-07T12:00:00.000Z"); + +const role = (over: Partial = {}): RoleRecord => ({ + companyEName: "@acme", + title: "Head of Finance", + scopes: [NDA, INVOICE], + mayRedelegate: true, + status: "active", + appLimits: { maxAmount: 10000 }, + ...over, +}); + +const delegation = ( + over: Partial = {}, +): DelegationRecord => ({ + companyEName: "@acme", + delegateEName: "@bob", + roleId: "r1", + title: "Head of Finance", + scopes: [NDA, INVOICE], + mayRedelegate: true, + grantedBy: "@dir", + status: "active", + ...over, +}); + +function source( + roles: Record, + delegations: Record, +): ChainSource { + return { + role: async (id) => roles[id] ?? null, + delegation: async (id) => delegations[id] ?? null, + }; +} + +const evaluate = (id: string, src: ChainSource) => + evaluateDelegation(id, src, { now: NOW }); + +describe("evaluateDelegation", () => { + it("accepts a role assignment", async () => { + const result = await evaluate( + "d1", + source({ r1: role() }, { d1: delegation() }), + ); + expect(result).toMatchObject({ + ok: true, + delegateEName: "@bob", + scopes: [NDA, INVOICE], + chain: ["d1"], + roleId: "r1", + appLimits: [{ maxAmount: 10000 }], + }); + }); + + it("accepts a narrowing re-delegation and collects every link's limits", async () => { + const child = delegation({ + delegateEName: "@carol", + roleId: undefined, + parentDelegationId: "d1", + title: "NDA signer", + scopes: [NDA], + mayRedelegate: false, + grantedBy: "@bob", + appLimits: { maxAmount: 500 }, + }); + const result = await evaluate( + "d2", + source({ r1: role() }, { d1: delegation(), d2: child }), + ); + expect(result).toMatchObject({ + ok: true, + delegateEName: "@carol", + title: "NDA signer", + scopes: [NDA], + chain: ["d2", "d1"], + appLimits: [{ maxAmount: 10000 }, { maxAmount: 500 }], + }); + }); + + it.each([ + ["revoked", {}, { status: "revoked" as const }, "REVOKED"], + ["expired", {}, { validUntil: "2026-01-01T00:00:00.000Z" }, "EXPIRED"], + [ + "not yet valid", + {}, + { validFrom: "2027-01-01T00:00:00.000Z" }, + "NOT_YET_VALID", + ], + ["wider than its role", { scopes: [NDA] }, {}, "NOT_A_SUBSET"], + [ + "a core scope", + { scopes: ["@w3ds:auth"] }, + { scopes: ["@w3ds:auth"] }, + "CORE_SCOPE", + ], + [ + "re-delegable under a closed role", + { mayRedelegate: false }, + {}, + "REDELEGATION_NOT_ALLOWED", + ], + [ + "for another company", + { companyEName: "@other" }, + {}, + "WRONG_COMPANY", + ], + ["dated with garbage", {}, { validUntil: "not-a-date" }, "MALFORMED"], + ])( + "rejects a delegation that is %s", + async (_name, roleOver, delOver, code) => { + const result = await evaluate( + "d1", + source({ r1: role(roleOver) }, { d1: delegation(delOver) }), + ); + expect(result).toMatchObject({ ok: false, code }); + }, + ); + + it("rejects a revoked role", async () => { + const result = await evaluate( + "d1", + source({ r1: role({ status: "revoked" }) }, { d1: delegation() }), + ); + expect(result).toMatchObject({ ok: false, code: "REVOKED", at: "r1" }); + }); + + it("rejects re-delegation from a parent that forbids it", async () => { + const result = await evaluate( + "d2", + source( + { r1: role() }, + { + d1: delegation({ mayRedelegate: false }), + d2: delegation({ + roleId: undefined, + parentDelegationId: "d1", + mayRedelegate: false, + grantedBy: "@bob", + delegateEName: "@carol", + }), + }, + ), + ); + expect(result).toMatchObject({ + ok: false, + code: "REDELEGATION_NOT_ALLOWED", + }); + }); + + it("rejects a child granted by someone other than the parent's delegate", async () => { + const result = await evaluate( + "d2", + source( + { r1: role() }, + { + d1: delegation(), + d2: delegation({ + roleId: undefined, + parentDelegationId: "d1", + grantedBy: "@mallory", + }), + }, + ), + ); + expect(result).toMatchObject({ ok: false, code: "WRONG_GRANTOR" }); + }); + + it("rejects a child that names both a role and a parent", async () => { + const result = await evaluate( + "d1", + source( + { r1: role() }, + { d1: delegation({ parentDelegationId: "d0" }) }, + ), + ); + expect(result).toMatchObject({ ok: false, code: "MALFORMED" }); + }); + + it("stops on cycles", async () => { + const result = await evaluate( + "a", + source( + {}, + { + a: delegation({ + roleId: undefined, + parentDelegationId: "b", + grantedBy: "@bob", + }), + b: delegation({ + roleId: undefined, + parentDelegationId: "a", + grantedBy: "@bob", + }), + }, + ), + ); + expect(result).toMatchObject({ ok: false, code: "CYCLE" }); + }); + + it("rejects a parent that is no longer live", async () => { + const result = await evaluate( + "d2", + source( + { r1: role() }, + { + d1: delegation({ status: "revoked" }), + d2: delegation({ + roleId: undefined, + parentDelegationId: "d1", + grantedBy: "@bob", + delegateEName: "@carol", + mayRedelegate: false, + }), + }, + ), + ); + expect(result).toMatchObject({ ok: false, code: "REVOKED", at: "d1" }); + }); +}); + +describe("isWindowWithin", () => { + it("requires the child's window inside the parent's", () => { + const parent = { + status: "active" as const, + validUntil: "2027-01-01T00:00:00.000Z", + }; + expect( + isWindowWithin( + { status: "active", validUntil: "2026-12-01T00:00:00.000Z" }, + parent, + ), + ).toBe(true); + expect(isWindowWithin({ status: "active" }, parent)).toBe(false); + }); +}); + +describe("checkDelegatedSignature", () => { + const payload: DelegatedSignPayload = { + onBehalfOf: "@acme", + signer: "@bob", + scope: NDA, + delegationId: "d1", + documentHash: "h", + session: "s", + issuedAt: NOW.toISOString(), + }; + + it("accepts a payload within the chain", async () => { + const chain = await evaluate( + "d1", + source({ r1: role() }, { d1: delegation() }), + ); + expect(checkDelegatedSignature(payload, chain)).toBeNull(); + }); + + it.each([ + [{ onBehalfOf: "@other" }, "WRONG_COMPANY"], + [{ signer: "@mallory" }, "WRONG_SIGNER"], + [{ delegationId: "d9" }, "WRONG_DELEGATION"], + [{ scope: "@w3ds:auth" }, "CORE_SCOPE"], + [{ scope: "@esigner:invoice" }, "SCOPE_NOT_DELEGATED"], + ])("rejects %o", async (over, code) => { + const chain = await evaluate( + "d1", + source({ r1: role() }, { d1: delegation() }), + ); + expect( + checkDelegatedSignature({ ...payload, ...over }, chain), + ).toMatchObject({ code }); + }); + + it("rejects when the chain is invalid", async () => { + const chain = await evaluate("d1", source({}, { d1: delegation() })); + expect(checkDelegatedSignature(payload, chain)).toMatchObject({ + code: "CHAIN_INVALID", + }); + }); +}); diff --git a/packages/delegation/src/chain.ts b/packages/delegation/src/chain.ts new file mode 100644 index 000000000..8e2b9937c --- /dev/null +++ b/packages/delegation/src/chain.ts @@ -0,0 +1,262 @@ +import type { DelegatedSignPayload } from "./payloads"; +import { + checkScopes, + isCoreScope, + isScopeSubset, + normaliseScope, + type Scope, +} from "./scopes"; + +type Validity = { + validFrom?: string | null; + validUntil?: string | null; + status: "active" | "revoked"; +}; + +export type RoleRecord = Validity & { + companyEName: string; + title: string; + scopes: Scope[]; + appLimits?: Record; + mayRedelegate: boolean; +}; + +export type DelegationRecord = Validity & { + companyEName: string; + delegateEName: string; + roleId?: string; + parentDelegationId?: string; + title: string; + scopes: Scope[]; + appLimits?: Record; + mayRedelegate: boolean; + grantedBy: string; +}; + +/** Reads records from the company's eVault. */ +export interface ChainSource { + role(id: string): Promise; + delegation(id: string): Promise; +} + +export type ChainFailureCode = + | "NOT_FOUND" + | "REVOKED" + | "NOT_YET_VALID" + | "EXPIRED" + | "MALFORMED" + | "CORE_SCOPE" + | "WRONG_COMPANY" + | "NOT_A_SUBSET" + | "REDELEGATION_NOT_ALLOWED" + | "WRONG_GRANTOR" + | "CYCLE" + | "TOO_DEEP"; + +export type ChainResult = + | { + ok: true; + companyEName: string; + delegateEName: string; + title: string; + /** What the delegate may sign right now. */ + scopes: Scope[]; + /** Every link's app limits, root first; an app must satisfy all. */ + appLimits: Record[]; + /** Delegation ids from the one evaluated up to the role assignment. */ + chain: string[]; + roleId: string; + } + | { ok: false; code: ChainFailureCode; at: string; message: string }; + +export const MAX_CHAIN_DEPTH = 16; + +/** + * Whether `[validFrom, validUntil)` covers `now`; null if it does. An + * unparsable date fails closed rather than comparing as never-expiring. + */ +function windowProblem( + record: Validity, + now: Date, +): "NOT_YET_VALID" | "EXPIRED" | "MALFORMED" | null { + const t = now.getTime(); + const from = record.validFrom ? Date.parse(record.validFrom) : null; + const until = record.validUntil ? Date.parse(record.validUntil) : null; + if (Number.isNaN(from) || Number.isNaN(until)) return "MALFORMED"; + if (from !== null && from > t) return "NOT_YET_VALID"; + if (until !== null && until <= t) return "EXPIRED"; + return null; +} + +/** Whether a child's validity window lies within its parent's. */ +export function isWindowWithin(child: Validity, parent: Validity): boolean { + const from = (v?: string | null) => (v ? Date.parse(v) : -Infinity); + const until = (v?: string | null) => (v ? Date.parse(v) : Infinity); + return ( + from(child.validFrom) >= from(parent.validFrom) && + until(child.validUntil) <= until(parent.validUntil) + ); +} + +/** + * Walks a delegation up to the role it ultimately assigns and decides what it + * lets its delegate sign now. Every link must be live, stay within the + * company, only narrow its parent, be granted by its parent's delegate, and + * come from a parent that allows re-delegation. + * + * Whether a director was entitled to assign the role is checked when the + * record is written (the eVault's write guard), not here. + */ +export async function evaluateDelegation( + delegationId: string, + source: ChainSource, + options: { now?: Date; maxDepth?: number } = {}, +): Promise { + const now = options.now ?? new Date(); + const maxDepth = options.maxDepth ?? MAX_CHAIN_DEPTH; + const fail = (code: ChainFailureCode, at: string, message: string) => + ({ ok: false, code, at, message }) as const; + + const leaf = await source.delegation(delegationId); + if (!leaf) return fail("NOT_FOUND", delegationId, "delegation not found"); + + const chain: string[] = []; + const appLimits: Record[] = []; + let id = delegationId; + let current = leaf; + + for (;;) { + if (chain.includes(id)) return fail("CYCLE", id, "delegation cycle"); + if (chain.length >= maxDepth) { + return fail("TOO_DEEP", id, "delegation chain too deep"); + } + chain.push(id); + + const problem = linkProblem(current, leaf.companyEName, now); + if (problem) return fail(problem.code, id, problem.message); + if (current.appLimits) appLimits.unshift(current.appLimits); + + if (current.roleId) { + const role = await source.role(current.roleId); + if (!role) + return fail("NOT_FOUND", current.roleId, "role not found"); + const roleProblem = linkProblem(role, leaf.companyEName, now); + if (roleProblem) { + return fail( + roleProblem.code, + current.roleId, + roleProblem.message, + ); + } + if (!isScopeSubset(current.scopes, role.scopes)) { + return fail("NOT_A_SUBSET", id, "scopes exceed the role"); + } + if (current.mayRedelegate && !role.mayRedelegate) { + return fail( + "REDELEGATION_NOT_ALLOWED", + id, + "role does not allow re-delegation", + ); + } + if (role.appLimits) appLimits.unshift(role.appLimits); + return { + ok: true, + companyEName: leaf.companyEName, + delegateEName: leaf.delegateEName, + title: leaf.title, + scopes: leaf.scopes.map((s) => normaliseScope(s) as Scope), + appLimits, + chain, + roleId: current.roleId, + }; + } + + const parentId = current.parentDelegationId as string; + const parent = await source.delegation(parentId); + if (!parent) return fail("NOT_FOUND", parentId, "parent not found"); + if (!parent.mayRedelegate) { + return fail( + "REDELEGATION_NOT_ALLOWED", + id, + "parent does not allow re-delegation", + ); + } + if (current.grantedBy !== parent.delegateEName) { + return fail( + "WRONG_GRANTOR", + id, + "not granted by the parent's delegate", + ); + } + if (!isScopeSubset(current.scopes, parent.scopes)) { + return fail("NOT_A_SUBSET", id, "scopes exceed the parent"); + } + id = parentId; + current = parent; + } +} + +function linkProblem( + record: (RoleRecord | DelegationRecord) & Validity, + companyEName: string, + now: Date, +): { code: ChainFailureCode; message: string } | null { + if (record.companyEName !== companyEName) { + return { code: "WRONG_COMPANY", message: "belongs to another company" }; + } + if ("delegateEName" in record) { + const hasRole = typeof record.roleId === "string"; + const hasParent = typeof record.parentDelegationId === "string"; + if (hasRole === hasParent) { + return { + code: "MALFORMED", + message: "needs exactly one of roleId or parentDelegationId", + }; + } + } + if (record.status !== "active") { + return { code: "REVOKED", message: "revoked" }; + } + const window = windowProblem(record, now); + if (window) return { code: window, message: window.toLowerCase() }; + const scopes = checkScopes(record.scopes); + if (scopes) { + return { + code: scopes.code === "CORE_SCOPE" ? "CORE_SCOPE" : "MALFORMED", + message: `bad scopes: ${scopes.code}`, + }; + } + return null; +} + +export type SignatureProblem = + | { code: "CHAIN_INVALID"; chain: Extract } + | { code: "WRONG_COMPANY" } + | { code: "WRONG_SIGNER" } + | { code: "WRONG_DELEGATION" } + | { code: "CORE_SCOPE" } + | { code: "SCOPE_NOT_DELEGATED" }; + +/** + * Whether a parsed `w3ds-sign/v1` payload is covered by an evaluated chain. + * The cryptographic signature itself is checked by the caller. + */ +export function checkDelegatedSignature( + payload: DelegatedSignPayload, + chain: ChainResult, +): SignatureProblem | null { + if (!chain.ok) return { code: "CHAIN_INVALID", chain }; + if (payload.onBehalfOf !== chain.companyEName) { + return { code: "WRONG_COMPANY" }; + } + if (payload.signer !== chain.delegateEName) return { code: "WRONG_SIGNER" }; + if (payload.delegationId !== chain.chain[0]) { + return { code: "WRONG_DELEGATION" }; + } + if (isCoreScope(payload.scope)) return { code: "CORE_SCOPE" }; + const scope = normaliseScope(payload.scope); + if (!scope || !chain.scopes.includes(scope)) { + return { code: "SCOPE_NOT_DELEGATED" }; + } + return null; +} diff --git a/packages/delegation/src/index.ts b/packages/delegation/src/index.ts new file mode 100644 index 000000000..d6fbcdb23 --- /dev/null +++ b/packages/delegation/src/index.ts @@ -0,0 +1,5 @@ +export * from "./canonical"; +export * from "./chain"; +export * from "./ontologies"; +export * from "./payloads"; +export * from "./scopes"; diff --git a/packages/delegation/src/ontologies.ts b/packages/delegation/src/ontologies.ts new file mode 100644 index 000000000..59fa40602 --- /dev/null +++ b/packages/delegation/src/ontologies.ts @@ -0,0 +1,9 @@ +/** Ontology ids the delegation model reads and writes. */ +export const USER_PROFILE_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; +export const COMPANY_ONTOLOGY = "0f9a3cb8-4a9f-4b5f-a1fa-3a4c2eb1f402"; +export const ROLE_ONTOLOGY = "65fd0e21-34b9-43ef-be76-c5b39727010e"; +export const DELEGATION_ONTOLOGY = "0b2f15d8-c3f9-4dba-b959-5cfa11272dae"; +export const DELEGATED_SIGNATURE_ONTOLOGY = + "e2736a06-176e-4004-8fda-b40b9a132669"; +export const SHAREHOLDING_ONTOLOGY = "6382a144-5c28-450e-bf47-e37c747791c2"; +export const BINDING_DOCUMENT_ONTOLOGY = "b1d0a8c3-4e5f-6789-0abc-def012345678"; diff --git a/packages/delegation/src/payloads.spec.ts b/packages/delegation/src/payloads.spec.ts new file mode 100644 index 000000000..0eaf47cc4 --- /dev/null +++ b/packages/delegation/src/payloads.spec.ts @@ -0,0 +1,150 @@ +import { describe, expect, it } from "vitest"; +import { + buildDelegatedSignPayload, + buildGrantPayload, + checkGrantAuthorization, + isReservedPayload, + parseDelegatedSignPayload, + PayloadError, +} from "./payloads"; +import { ROLE_ONTOLOGY } from "./ontologies"; + +const fields = { + onBehalfOf: "@acme", + signer: "@bob", + scope: "@esigner:nda", + delegationId: "d1", + documentHash: "abc", + session: "s1", + issuedAt: "2026-10-07T12:00:00.000Z", +}; + +describe("delegated sign payload", () => { + it("round-trips and is reserved", () => { + const payload = buildDelegatedSignPayload(fields); + expect(payload.startsWith("w3ds-sign/v1\n")).toBe(true); + expect(isReservedPayload(payload)).toBe(true); + expect(parseDelegatedSignPayload(payload)).toEqual(fields); + }); + + it("is the same whatever order the fields come in", () => { + const reversed = Object.fromEntries(Object.entries(fields).reverse()); + expect(buildDelegatedSignPayload(reversed as typeof fields)).toBe( + buildDelegatedSignPayload(fields), + ); + }); + + it("refuses core scopes and missing fields", () => { + expect(() => + buildDelegatedSignPayload({ ...fields, scope: "@w3ds:auth" }), + ).toThrow(PayloadError); + expect(() => + buildDelegatedSignPayload({ ...fields, session: "" }), + ).toThrow(PayloadError); + }); + + it("rejects non-canonical or tampered strings", () => { + const payload = buildDelegatedSignPayload(fields); + const spaced = payload.replace(":", ": "); + expect(parseDelegatedSignPayload(spaced)).toBeNull(); + expect( + parseDelegatedSignPayload(payload.replace("}", ',"extra":"x"}')), + ).toBeNull(); + expect(parseDelegatedSignPayload("a-login-session-uuid")).toBeNull(); + expect(isReservedPayload("a-login-session-uuid")).toBe(false); + }); +}); + +describe("grant payload", () => { + it("keeps an own __proto__ key in the hash", async () => { + const build = (record: Record) => + buildGrantPayload({ + ontology: ROLE_ONTOLOGY, + companyEName: "@acme", + signerEName: "@dir", + record, + }); + const smuggled = JSON.parse( + '{"appLimits":{"__proto__":{"maxAmount":1}}}', + ); + expect(await build(smuggled)).not.toBe(await build({ appLimits: {} })); + }); + + const record = { + companyEName: "@acme", + title: "Head of Finance", + scopes: ["@esigner:nda"], + }; + + it("ignores authorization and key order", async () => { + const a = await buildGrantPayload({ + ontology: ROLE_ONTOLOGY, + companyEName: "@acme", + signerEName: "@dir", + record, + }); + const b = await buildGrantPayload({ + ontology: ROLE_ONTOLOGY, + companyEName: "@acme", + signerEName: "@dir", + record: { + scopes: record.scopes, + authorization: { x: 1 }, + title: record.title, + companyEName: "@acme", + }, + }); + expect(a).toBe(b); + expect(a.startsWith("w3ds-grant/v1\n")).toBe(true); + }); + + it("checks the authorization against the record", async () => { + const signedPayload = await buildGrantPayload({ + ontology: ROLE_ONTOLOGY, + companyEName: "@acme", + signerEName: "@dir", + record, + }); + const authorization = { + signerEName: "@dir", + signedPayload, + signature: "sig", + signedAt: "2026-10-07T12:00:00.000Z", + }; + const ok = async (e: string, p: string, s: string) => + e === "@dir" && p === signedPayload && s === "sig"; + + expect( + await checkGrantAuthorization( + ROLE_ONTOLOGY, + "@acme", + { ...record, authorization }, + ok, + ), + ).toBeNull(); + expect( + await checkGrantAuthorization( + ROLE_ONTOLOGY, + "@acme", + { ...record, scopes: ["@esigner:invoice"], authorization }, + ok, + ), + ).toEqual({ code: "PAYLOAD_MISMATCH" }); + expect( + await checkGrantAuthorization( + ROLE_ONTOLOGY, + "@acme", + { + ...record, + authorization: { ...authorization, signature: "forged" }, + }, + ok, + ), + ).toEqual({ code: "BAD_SIGNATURE" }); + expect( + await checkGrantAuthorization(ROLE_ONTOLOGY, "@acme", record, ok), + ).toEqual({ + code: "MISSING_AUTHORIZATION", + }); + }); +}); diff --git a/packages/delegation/src/payloads.ts b/packages/delegation/src/payloads.ts new file mode 100644 index 000000000..0a73f2d15 --- /dev/null +++ b/packages/delegation/src/payloads.ts @@ -0,0 +1,180 @@ +import { canonicalJson, sha256Hex } from "./canonical"; +import { isCoreScope, normaliseScope, type Scope } from "./scopes"; + +/** + * Every payload this package defines starts with `w3ds-`. A login verifier + * must reject any such string, so a signature made for a company or over a + * grant can never be replayed as a login, and a login can never claim to be + * on someone's behalf. + */ +export const RESERVED_PAYLOAD_PREFIX = "w3ds-"; +export const SIGN_PAYLOAD_PREFIX = "w3ds-sign/v1\n"; +export const GRANT_PAYLOAD_PREFIX = "w3ds-grant/v1\n"; + +/** The wallet's signable string is capped by verifiers (packages/auth). */ +export const MAX_PAYLOAD_LENGTH = 1024; + +export function isReservedPayload(payload: string): boolean { + return payload.startsWith(RESERVED_PAYLOAD_PREFIX); +} + +/** + * What a delegate signs when signing for a company. The platform sends the + * built string as the `w3ds://sign` session and the wallet signs it unchanged; + * the platform then rejects it unless a live delegation covers it. + */ +export type DelegatedSignPayload = { + /** The company the signature is made for. */ + onBehalfOf: string; + /** The delegate whose key signs. */ + signer: string; + scope: Scope; + /** MetaEnvelope id of the Delegation relied on. */ + delegationId: string; + /** Hash of the document or record being signed. */ + documentHash: string; + /** The platform's signing session, so a signature answers one request. */ + session: string; + issuedAt: string; +}; + +const SIGN_FIELDS: (keyof DelegatedSignPayload)[] = [ + "delegationId", + "documentHash", + "issuedAt", + "onBehalfOf", + "scope", + "session", + "signer", +]; + +export class PayloadError extends Error {} + +export function buildDelegatedSignPayload( + fields: DelegatedSignPayload, +): string { + for (const key of SIGN_FIELDS) { + if (typeof fields[key] !== "string" || fields[key].length === 0) { + throw new PayloadError(`${key} is required`); + } + } + const scope = normaliseScope(fields.scope); + if (!scope) throw new PayloadError(`invalid scope ${fields.scope}`); + if (isCoreScope(scope)) { + throw new PayloadError(`${scope} can never be signed for a company`); + } + + const body: DelegatedSignPayload = { ...fields, scope }; + const payload = SIGN_PAYLOAD_PREFIX + canonicalJson(pick(body)); + if (payload.length > MAX_PAYLOAD_LENGTH) { + throw new PayloadError("payload exceeds the signable length"); + } + return payload; +} + +/** + * Parses a signed string back into its fields. Only the exact canonical form + * is accepted, so one set of fields has exactly one valid encoding. + */ +export function parseDelegatedSignPayload( + payload: string, +): DelegatedSignPayload | null { + if (!payload.startsWith(SIGN_PAYLOAD_PREFIX)) return null; + let body: unknown; + try { + body = JSON.parse(payload.slice(SIGN_PAYLOAD_PREFIX.length)); + } catch { + return null; + } + if (!body || typeof body !== "object" || Array.isArray(body)) return null; + + const keys = Object.keys(body).sort(); + if (keys.join() !== SIGN_FIELDS.join()) return null; + const fields = body as DelegatedSignPayload; + try { + return buildDelegatedSignPayload(fields) === payload ? fields : null; + } catch { + return null; + } +} + +function pick(fields: DelegatedSignPayload): DelegatedSignPayload { + const out = {} as DelegatedSignPayload; + for (const key of SIGN_FIELDS) out[key] = fields[key]; + return out; +} + +/** The signature a grantor puts on a Role, Delegation, Shareholding or Company. */ +export type Authorization = { + signerEName: string; + signedPayload: string; + signature: string; + signedAt: string; +}; + +/** + * 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. + */ +export async function buildGrantPayload(input: { + ontology: string; + companyEName: string; + signerEName: string; + record: Record; +}): Promise { + const { authorization: _ignored, ...rest } = input.record; + return ( + GRANT_PAYLOAD_PREFIX + + canonicalJson({ + companyEName: input.companyEName, + ontology: input.ontology, + recordSha256: await sha256Hex(canonicalJson(rest)), + signer: input.signerEName, + }) + ); +} + +/** Checks a signature over a payload against the signer's bound keys. */ +export type VerifySignature = ( + eName: string, + payload: string, + signature: string, +) => Promise; + +export type GrantProblem = + | { code: "MISSING_AUTHORIZATION" } + | { 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. + */ +export async function checkGrantAuthorization( + ontology: string, + companyEName: string, + record: Record, + verify: VerifySignature, +): Promise { + const auth = record.authorization as Partial | undefined; + if ( + !auth || + typeof auth.signerEName !== "string" || + typeof auth.signedPayload !== "string" || + typeof auth.signature !== "string" + ) { + return { code: "MISSING_AUTHORIZATION" }; + } + const expected = await buildGrantPayload({ + ontology, + companyEName, + signerEName: auth.signerEName, + record, + }); + if (auth.signedPayload !== expected) return { code: "PAYLOAD_MISMATCH" }; + if (!(await verify(auth.signerEName, expected, auth.signature))) { + return { code: "BAD_SIGNATURE" }; + } + return null; +} diff --git a/packages/delegation/src/scopes.spec.ts b/packages/delegation/src/scopes.spec.ts new file mode 100644 index 000000000..2d0482589 --- /dev/null +++ b/packages/delegation/src/scopes.spec.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { DELEGATION_ONTOLOGY, USER_PROFILE_ONTOLOGY } from "./ontologies"; +import { + checkScopes, + isCoreScope, + isScopeSubset, + normaliseScope, + parseScope, +} from "./scopes"; + +const INVOICE = "ontology:11111111-2222-4333-8444-555555555555"; + +describe("scopes", () => { + it("parses ontology and platform scopes", () => { + expect(parseScope(INVOICE)).toEqual({ + kind: "ontology", + ontology: "11111111-2222-4333-8444-555555555555", + }); + expect(parseScope("@esigner:nda")).toEqual({ + kind: "platform", + platform: "@esigner", + keyword: "nda", + }); + for (const bad of [ + "nda", + "ontology:xyz", + "@esigner:", + "esigner:nda", + 42, + ]) { + expect(parseScope(bad)).toBeNull(); + } + }); + + it("normalises ontology ids to lowercase", () => { + expect( + normaliseScope( + INVOICE.toUpperCase().replace("ONTOLOGY", "ontology"), + ), + ).toBe(INVOICE); + }); + + it("treats identity, authority and protocol scopes as core", () => { + expect(isCoreScope(`ontology:${DELEGATION_ONTOLOGY}`)).toBe(true); + expect(isCoreScope(`ontology:${USER_PROFILE_ONTOLOGY}`)).toBe(true); + expect(isCoreScope("@w3ds:auth")).toBe(true); + expect(isCoreScope("@W3DS:keys")).toBe(true); + expect(isCoreScope(INVOICE)).toBe(false); + expect(isCoreScope("@esigner:nda")).toBe(false); + }); + + it("rejects empty, invalid and core scope lists", () => { + expect(checkScopes([])).toEqual({ code: "EMPTY" }); + expect(checkScopes(["nope"])).toEqual({ + code: "INVALID_SCOPE", + scope: "nope", + }); + expect(checkScopes([INVOICE, "@w3ds:auth"])).toEqual({ + code: "CORE_SCOPE", + scope: "@w3ds:auth", + }); + expect(checkScopes([INVOICE, "@esigner:nda"])).toBeNull(); + }); + + it("checks subsets", () => { + expect(isScopeSubset(["@esigner:nda"], [INVOICE, "@esigner:nda"])).toBe( + true, + ); + expect(isScopeSubset([INVOICE], ["@esigner:nda"])).toBe(false); + expect(isScopeSubset(["bad"], ["bad"])).toBe(false); + }); +}); diff --git a/packages/delegation/src/scopes.ts b/packages/delegation/src/scopes.ts new file mode 100644 index 000000000..f170d3f98 --- /dev/null +++ b/packages/delegation/src/scopes.ts @@ -0,0 +1,104 @@ +import { + BINDING_DOCUMENT_ONTOLOGY, + COMPANY_ONTOLOGY, + DELEGATED_SIGNATURE_ONTOLOGY, + DELEGATION_ONTOLOGY, + ROLE_ONTOLOGY, + SHAREHOLDING_ONTOLOGY, + USER_PROFILE_ONTOLOGY, +} from "./ontologies"; + +/** + * What a delegate may sign for a company: `ontology:` for records of + * an ontology, or `@:` for a document type a platform + * declares itself. + */ +export type Scope = string; + +export type ParsedScope = + | { kind: "ontology"; ontology: string } + | { kind: "platform"; platform: string; keyword: string }; + +const ONTOLOGY_SCOPE = + /^ontology:([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i; +const PLATFORM_SCOPE = /^(@[^\s:]+):([a-z][a-z0-9-]*)$/; + +/** + * Ontologies that describe the company's own authority or a person's + * identity. Signing for them on someone else's behalf is never allowed. + */ +export const CORE_ONTOLOGIES: readonly string[] = [ + USER_PROFILE_ONTOLOGY, + BINDING_DOCUMENT_ONTOLOGY, + COMPANY_ONTOLOGY, + SHAREHOLDING_ONTOLOGY, + ROLE_ONTOLOGY, + DELEGATION_ONTOLOGY, + DELEGATED_SIGNATURE_ONTOLOGY, +]; + +/** + * Protocol-level acts (login, key and eVault management) are reserved to the + * `@w3ds` namespace so no platform can declare a keyword that stands for them. + */ +export const CORE_NAMESPACE = "@w3ds"; + +export function parseScope(scope: unknown): ParsedScope | null { + if (typeof scope !== "string") return null; + const ontology = ONTOLOGY_SCOPE.exec(scope); + if (ontology) { + return { kind: "ontology", ontology: ontology[1].toLowerCase() }; + } + const platform = PLATFORM_SCOPE.exec(scope); + if (platform) { + return { + kind: "platform", + platform: platform[1], + keyword: platform[2], + }; + } + return null; +} + +/** Normalises a scope so equal scopes compare equal (ontology ids lowercase). */ +export function normaliseScope(scope: Scope): Scope | null { + const parsed = parseScope(scope); + if (!parsed) return null; + return parsed.kind === "ontology" + ? `ontology:${parsed.ontology}` + : `${parsed.platform}:${parsed.keyword}`; +} + +/** True for scopes no role or delegation may ever include. */ +export function isCoreScope(scope: Scope): boolean { + const parsed = parseScope(scope); + if (!parsed) return false; + if (parsed.kind === "ontology") { + return CORE_ONTOLOGIES.includes(parsed.ontology); + } + return parsed.platform.toLowerCase() === CORE_NAMESPACE; +} + +export type ScopeListProblem = + | { code: "EMPTY" } + | { code: "INVALID_SCOPE"; scope: unknown } + | { code: "CORE_SCOPE"; scope: Scope }; + +/** Checks a role's or delegation's scope list; returns the first problem. */ +export function checkScopes(scopes: unknown): ScopeListProblem | null { + if (!Array.isArray(scopes) || scopes.length === 0) return { code: "EMPTY" }; + for (const scope of scopes) { + if (!parseScope(scope)) return { code: "INVALID_SCOPE", scope }; + if (isCoreScope(scope)) return { code: "CORE_SCOPE", scope }; + } + return null; +} + +/** Whether every scope in `child` is also in `parent`. */ +export function isScopeSubset(child: Scope[], parent: Scope[]): boolean { + const allowed = new Set(parent.map(normaliseScope)); + return child.every((s) => { + const n = normaliseScope(s); + return n !== null && allowed.has(n); + }); +} diff --git a/packages/delegation/tsconfig.build.json b/packages/delegation/tsconfig.build.json new file mode 100644 index 000000000..9207aa3e8 --- /dev/null +++ b/packages/delegation/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "dist", "src/**/*.spec.ts"] +} diff --git a/packages/delegation/tsconfig.json b/packages/delegation/tsconfig.json new file mode 100644 index 000000000..3023f6593 --- /dev/null +++ b/packages/delegation/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "moduleResolution": "node", + "lib": ["ES2020", "DOM"], + "declaration": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0482ce874..9bb941518 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -800,6 +800,18 @@ importers: specifier: ^3.2.4 version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.26)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/delegation: + devDependencies: + '@types/node': + specifier: ^20.11.24 + version: 20.19.26 + typescript: + specifier: ~5.6.2 + version: 5.6.3 + vitest: + specifier: ^3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@20.19.26)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.31.1)(sass@1.98.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/eslint-config: devDependencies: '@eslint/js':