Repository navigation
feat(delegation): scopes, signed payload formats and delegation chain evaluation #1192
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| node_modules/ | ||
| dist/ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| 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(""); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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", | ||
| }); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.