Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/delegation/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
node_modules/
dist/
21 changes: 21 additions & 0 deletions packages/delegation/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
43 changes: 43 additions & 0 deletions packages/delegation/src/canonical.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = Object.create(null);
for (const key of Object.keys(value).sort()) {
const v = (value as Record<string, unknown>)[key];
if (v !== undefined) out[key] = sortKeys(v);
Comment thread
coodos marked this conversation as resolved.
}
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<SubtleCrypto> {
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<string> {
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("");
}
294 changes: 294 additions & 0 deletions packages/delegation/src/chain.spec.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): RoleRecord => ({
companyEName: "@acme",
title: "Head of Finance",
scopes: [NDA, INVOICE],
mayRedelegate: true,
status: "active",
appLimits: { maxAmount: 10000 },
...over,
});

const delegation = (
over: Partial<DelegationRecord> = {},
): 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<string, RoleRecord>,
delegations: Record<string, DelegationRecord>,
): 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",
});
});
});
Loading
Loading