diff --git a/docker/Dockerfile.w3ds-oidc-connector b/docker/Dockerfile.w3ds-oidc-connector index 5dc1dfa0c..c9aff08fd 100644 --- a/docker/Dockerfile.w3ds-oidc-connector +++ b/docker/Dockerfile.w3ds-oidc-connector @@ -20,6 +20,7 @@ RUN npm install -g pnpm@10.25.0 COPY --from=prepare /app/out/json/ . # Copy full source for packages that need postinstall builds COPY --from=prepare /app/out/full/packages/auth packages/auth +COPY --from=prepare /app/out/full/packages/delegation packages/delegation COPY --from=prepare /app/out/full/infrastructure/signature-validator infrastructure/signature-validator RUN pnpm install --frozen-lockfile # Build the project @@ -34,6 +35,7 @@ COPY --from=builder /app/package.json ./ COPY --from=builder /app/pnpm-workspace.yaml ./ COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/packages/auth ./packages/auth +COPY --from=builder /app/packages/delegation ./packages/delegation COPY --from=builder /app/infrastructure/signature-validator ./infrastructure/signature-validator COPY --from=builder /app/services/w3ds-oidc-connector/dist ./services/w3ds-oidc-connector/dist COPY --from=builder /app/services/w3ds-oidc-connector/package.json ./services/w3ds-oidc-connector/ diff --git a/packages/auth/package.json b/packages/auth/package.json index 9bebb1d72..c1137b4dc 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -41,6 +41,7 @@ "dist" ], "dependencies": { + "@metastate-foundation/delegation": "workspace:*", "jose": "^5.2.2", "jsonwebtoken": "^9.0.2", "signature-validator": "workspace:*", diff --git a/packages/auth/src/delegated-signature.spec.ts b/packages/auth/src/delegated-signature.spec.ts new file mode 100644 index 000000000..18d911a5c --- /dev/null +++ b/packages/auth/src/delegated-signature.spec.ts @@ -0,0 +1,480 @@ +import { + buildGrantPayload, + COMPANY_ONTOLOGY, + DELEGATION_ONTOLOGY, + ROLE_ONTOLOGY, +} from "@metastate-foundation/delegation"; +import { SignJWT, exportJWK, generateKeyPair as generateJoseKeyPair } from "jose"; +import type { KeyLike } from "jose"; +import { beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { verifyDelegatedSignature } from "./delegated-signature.js"; +import { clearJwksCache } from "./ename-signature.js"; +import { buildDelegatedSignRequest, buildGrantSignRequest } from "./sign-requests.js"; +import { verifyLoginSignature } from "./verify-login.js"; + +const REGISTRY = "http://registry.test"; +const ACME = "@acme"; +const NDA = "@esigner:nda"; +const NOW = new Date("2026-12-01T00:00:00.000Z"); + +interface Wallet { + publicKey: string; + sign: (payload: string) => Promise; +} + +/** A software-key wallet: `z` + hex SPKI public key, base64 raw signature. */ +async function softwareWallet(): Promise { + const pair = await crypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + const spki = Buffer.from(await crypto.subtle.exportKey("spki", pair.publicKey)); + return { + publicKey: `z${spki.toString("hex")}`, + sign: async (payload) => + Buffer.from( + await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + pair.privateKey, + new TextEncoder().encode(payload), + ), + ).toString("base64"), + }; +} + +type Version = { + version: number; + operation: string; + ontology: string; + parsed: Record | null; + createdAt: string; +}; + +/** A Registry, people's eVaults and a company eVault, all behind one fetch. */ +class World { + private registry!: { privateKey: KeyLike; jwk: Record }; + private wallets = new Map(); + private history = new Map(); + private clock = Date.parse("2026-10-01T00:00:00.000Z"); + companyType: string | null = "company"; + historyCalls = 0; + resolveCalls = 0; + /** eNames whose Registry lookup fails, as in an outage. */ + down = new Set(); + + async init() { + const { privateKey, publicKey } = await generateJoseKeyPair("ES256"); + this.registry = { + privateKey, + jwk: { ...(await exportJWK(publicKey)), kid: "k1", alg: "ES256" }, + }; + for (const who of ["@dir", "@bob", "@mallory"]) { + this.wallets.set(who, await softwareWallet()); + } + return this; + } + + async versionsOf(id: string) { + return this.history.get(id) ?? []; + } + + wallet(eName: string) { + return this.wallets.get(eName) as Wallet; + } + + write(id: string, ontology: string, parsed: Record) { + const versions = this.history.get(id) ?? []; + this.clock += 60_000; + versions.push({ + version: versions.length + 1, + operation: versions.length ? "update" : "create", + ontology, + parsed, + createdAt: new Date(this.clock).toISOString(), + }); + this.history.set(id, versions); + } + + /** Signs a record as its grantor through the real grant request helper. */ + async signed(id: string, ontology: string, signer: string, record: Record) { + const signedAt = new Date(this.clock + 30_000).toISOString(); + const request = await buildGrantSignRequest({ + ontology, + companyEName: ACME, + recordId: id, + signerEName: signer, + record, + redirectUri: "https://app.test/cb", + message: "Approve", + signedAt, + }); + this.write(id, ontology, { + ...record, + authorization: { + signerEName: signer, + signedPayload: request.payload, + signature: await this.wallet(signer).sign(request.payload), + signedAt, + }, + }); + } + + private async certificate(eName: string) { + return new SignJWT({ ename: eName, publicKey: this.wallet(eName).publicKey }) + .setProtectedHeader({ alg: "ES256", kid: "k1" }) + .setIssuedAt() + .setExpirationTime("1h") + .sign(this.registry.privateKey); + } + + fetch = (async (input: string | URL | Request, init?: RequestInit) => { + const url = new URL(input instanceof Request ? input.url : input); + const headers = new Headers(init?.headers); + const json = (body: unknown, status = 200) => Response.json(body, { status }); + + if (url.origin === REGISTRY && url.pathname === "/resolve") { + const who = url.searchParams.get("w3id") as string; + this.resolveCalls++; + if (this.down.has(who)) return json({ error: "unavailable" }, 503); + return json({ kind: "evault", ename: who, uri: `http://evault.test/${who.slice(1)}/` }); + } + if (url.origin === REGISTRY && url.pathname === "/.well-known/jwks.json") { + return json({ keys: [this.registry.jwk] }); + } + if (url.origin === "http://evault.test" && url.pathname === "/whois") { + const who = headers.get("X-ENAME") as string; + if (who === ACME && this.companyType === "broken") return json({}, 503); + if (who === ACME) { + return json({ + w3id: ACME, + keyBindingCertificates: [], + type: this.companyType, + manifest: this.companyType ? { id: "company" } : null, + }); + } + if (!this.wallets.has(who)) return json({ keyBindingCertificates: [] }); + return json({ keyBindingCertificates: [await this.certificate(who)] }); + } + if (url.origin === "http://evault.test" && url.pathname === "/graphql") { + if (headers.get("X-ENAME") !== ACME || headers.get("authorization") !== "Bearer platform-token") { + return json({ errors: [{ message: "Access denied" }] }); + } + this.historyCalls++; + const { variables } = JSON.parse(String(init?.body)); + const all = [...(this.history.get(variables.id) ?? [])].reverse(); + if (all.length === 0) return json({ data: { metaEnvelopeHistory: null } }); + const before = variables.after ? Number(atob(variables.after)) : Number.POSITIVE_INFINITY; + const page = all.filter((v) => v.version < before).slice(0, variables.first); + const last = page.at(-1); + return json({ + data: { + metaEnvelopeHistory: { + edges: page.map((node) => ({ node })), + pageInfo: { + hasNextPage: !!last && last.version > 1, + endCursor: last ? btoa(String(last.version)) : null, + }, + }, + }, + }); + } + return new Response("not found", { status: 404 }); + }) as typeof globalThis.fetch; +} + +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], + mayRedelegate: false, + 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], + mayRedelegate: false, + grantedBy: "@dir", + status: "active", + createdAt: "2026-10-01T00:00:00.000Z", + updatedAt: "2026-10-01T00:00:00.000Z", + ...over, +}); + +async function acme() { + const world = await new World().init(); + await world.signed("company", COMPANY_ONTOLOGY, "@dir", company(["@dir"])); + await world.signed("role", ROLE_ONTOLOGY, "@dir", role()); + await world.signed("bob", DELEGATION_ONTOLOGY, "@dir", delegation()); + return world; +} + +/** Bob signs an NDA for Acme through the real request helper. */ +async function bobSigns(world: World, over: Record = {}) { + const { payload } = buildDelegatedSignRequest({ + onBehalfOf: ACME, + signer: "@bob", + scope: NDA, + delegationId: "bob", + documentHash: "abc123", + session: "s-1", + issuedAt: NOW.toISOString(), + redirectUri: "https://esigner.test/cb", + title: "Head of Finance", + ...over, + }); + return { payload, signature: await world.wallet(over.signer ?? "@bob").sign(payload) }; +} + +const check = (world: World, signed: { payload: string; signature: string }) => + verifyDelegatedSignature({ + ...signed, + registryBaseUrl: REGISTRY, + platformToken: "platform-token", + fetch: world.fetch, + now: NOW, + }); + +beforeEach(() => clearJwksCache()); + +describe("verifyDelegatedSignature", () => { + let world: World; + beforeAll(async () => { + world = await acme(); + }); + + it("accepts a signature covered by a live delegation", async () => { + const result = await check(world, await bobSigns(world)); + expect(result).toMatchObject({ + valid: true, + companyEName: ACME, + title: "Head of Finance", + scopes: [NDA], + chain: ["bob"], + }); + }); + + it("rejects a signature by someone other than the payload's signer", async () => { + const signed = await bobSigns(world); + const forged = { ...signed, signature: await world.wallet("@mallory").sign(signed.payload) }; + expect(await check(world, forged)).toMatchObject({ valid: false, error: "bad_signature" }); + }); + + it("rejects a scope the delegation does not cover", async () => { + expect(await check(world, await bobSigns(world, { scope: "@esigner:invoice" }))).toMatchObject({ + valid: false, + error: "not_covered", + detail: "SCOPE_NOT_DELEGATED", + }); + }); + + it("rejects a plain login session", async () => { + const signature = await world.wallet("@bob").sign("a-login-session"); + expect(await check(world, { payload: "a-login-session", signature })).toMatchObject({ + valid: false, + error: "invalid_payload", + }); + }); +}); + +describe("verifyDelegatedSignature against changing history", () => { + it("rejects once the delegation is revoked", async () => { + const world = await acme(); + await world.signed("bob", DELEGATION_ONTOLOGY, "@dir", { + ...delegation(), + status: "revoked", + revokedAt: "2026-10-02T00:00:00.000Z", + revokedBy: "@dir", + revocationReason: "revoked", + }); + expect(await check(world, await bobSigns(world))).toMatchObject({ + valid: false, + error: "chain_invalid", + detail: "REVOKED", + }); + }); + + it("ignores a board an outsider wrote into the company eVault", async () => { + const world = await new World().init(); + await world.signed("company", COMPANY_ONTOLOGY, "@dir", company(["@dir"])); + await world.signed("company", COMPANY_ONTOLOGY, "@mallory", company(["@mallory"])); + await world.signed("role", ROLE_ONTOLOGY, "@mallory", role({ createdBy: "@mallory" })); + await world.signed("bob", DELEGATION_ONTOLOGY, "@mallory", delegation({ grantedBy: "@mallory" })); + expect(await check(world, await bobSigns(world))).toMatchObject({ + valid: false, + error: "chain_invalid", + detail: "NOT_FOUND", + }); + }); + + it("reads history across pages", async () => { + const world = await acme(); + for (let i = 0; i < 150; i++) world.write("company", COMPANY_ONTOLOGY, { noise: i }); + expect(await check(world, await bobSigns(world))).toMatchObject({ valid: true }); + }); + + it("refuses an eVault that is not a company", async () => { + const world = await acme(); + world.companyType = "user"; + expect(await check(world, await bobSigns(world))).toMatchObject({ + valid: false, + error: "not_a_company", + }); + }); +}); + +describe("verifyDelegatedSignature under abuse and outages", () => { + it("reports a Registry outage as an outage, not a bad signature", async () => { + const world = await acme(); + const signed = await bobSigns(world); + world.down.add("@bob"); + expect(await check(world, signed)).toMatchObject({ valid: false, error: "resolve_failed" }); + }); + + it("reports an outage hit while checking a director's grant", async () => { + const world = await acme(); + const signed = await bobSigns(world); + world.down.add("@dir"); + expect(await check(world, signed)).toMatchObject({ valid: false, error: "resolve_failed" }); + }); + + it("reports a company /whois outage as an outage", async () => { + const world = await acme(); + world.companyType = "broken"; + expect(await check(world, await bobSigns(world))).toMatchObject({ valid: false, error: "resolve_failed" }); + }); + + it("returns the normalised signer for storing a grant", async () => { + const request = await buildGrantSignRequest({ + ontology: ROLE_ONTOLOGY, + companyEName: "acme", + recordId: "role", + signerEName: "dir", + record: role(), + redirectUri: "https://app.test/cb", + message: "Create role", + }); + expect(request.signerEName).toBe("@dir"); + expect(request.payload).toContain('"signer":"@dir"'); + }); + + it("looks up each signer once and caps how many it looks up", async () => { + const world = await acme(); + const [bob] = (await world.versionsOf("bob")) as Version[]; + const { authorization: _drop, ...record } = bob.parsed as Record; + // Forged grants naming fifty different signers, written after the real one. + for (let i = 0; i < 50; i++) { + const signer = `@forger${i}`; + const signedAt = new Date(Date.parse("2026-10-01T02:00:00.000Z") + i * 60_000).toISOString(); + const signedPayload = await buildGrantPayload({ + ontology: DELEGATION_ONTOLOGY, + companyEName: ACME, + recordId: "bob", + signerEName: signer, + signedAt, + record, + }); + world.write("bob", DELEGATION_ONTOLOGY, { + ...record, + authorization: { signerEName: signer, signedPayload, signature: "AAAA", signedAt }, + }); + } + world.resolveCalls = 0; + expect(await check(world, await bobSigns(world))).toMatchObject({ valid: true }); + // The company, the real signers, then at most the signer cap. + expect(world.resolveCalls).toBeLessThanOrEqual(1 + 32); + }); + + it("accepts requests built with bare eNames", async () => { + const world = await acme(); + const { payload } = buildDelegatedSignRequest({ + onBehalfOf: "acme", + signer: "bob", + scope: NDA, + delegationId: "bob", + documentHash: "abc123", + session: "s-1", + issuedAt: NOW.toISOString(), + redirectUri: "https://esigner.test/cb", + title: "Head of Finance", + }); + const signature = await world.wallet("@bob").sign(payload); + expect(await check(world, { payload, signature })).toMatchObject({ valid: true }); + }); +}); + +describe("sign requests", () => { + it("makes the signed payload the session and keeps names readable", () => { + const { uri, payload } = buildDelegatedSignRequest({ + onBehalfOf: ACME, + signer: "@bob", + scope: NDA, + delegationId: "bob", + documentHash: "abc", + session: "s-1", + redirectUri: "https://esigner.test/cb", + title: "Directrice Générale", + companyName: "Ünïcode Ltd", + }); + const url = new URL(uri); + expect(url.protocol).toBe("w3ds:"); + expect(url.searchParams.get("session")).toBe(payload); + expect(url.searchParams.get("redirect_uri")).toBe("https://esigner.test/cb"); + // The wallet decodes data with atob, then JSON.parse. + const data = JSON.parse(atob(url.searchParams.get("data") as string)); + expect(data.message).toBe("Signing as Directrice Générale for Ünïcode Ltd"); + expect(data.sessionId).toBe(payload); + }); + + it("builds the grant payload the delegation package expects", async () => { + const record = role(); + const request = await buildGrantSignRequest({ + ontology: ROLE_ONTOLOGY, + companyEName: ACME, + recordId: "role", + signerEName: "@dir", + record, + redirectUri: "https://app.test/cb", + message: "Create role", + signedAt: "2026-10-01T00:00:00.000Z", + }); + expect(request.payload).toBe( + await buildGrantPayload({ + ontology: ROLE_ONTOLOGY, + companyEName: ACME, + recordId: "role", + signerEName: "@dir", + signedAt: "2026-10-01T00:00:00.000Z", + record, + }), + ); + }); +}); + +describe("verifyLoginSignature", () => { + it("never accepts a delegated or grant payload as a login", async () => { + const result = await verifyLoginSignature({ + eName: "@bob", + signature: "anything", + session: 'w3ds-sign/v1\n{"x":1}', + registryBaseUrl: REGISTRY, + }); + expect(result).toEqual({ valid: false, error: "reserved_payload" }); + }); +}); diff --git a/packages/auth/src/delegated-signature.ts b/packages/auth/src/delegated-signature.ts new file mode 100644 index 000000000..ff247164c --- /dev/null +++ b/packages/auth/src/delegated-signature.ts @@ -0,0 +1,326 @@ +/** + * Verifies a signature made for a company under a delegation. + * + * The company's eVault is a plain store and enforces nothing, so authority is + * decided here: the delegate's signature over the `w3ds-sign/v1` payload, then + * the board and the delegation chain read from the eVault's version history, + * each link checked against its signer's grant (`@metastate-foundation/delegation`). + */ + +import { + checkDelegatedSignature, + type DelegatedSignPayload, + evaluateFromHistory, + type HistorySource, + parseDelegatedSignPayload, + type Version, +} from "@metastate-foundation/delegation"; +import { + type EnameVerificationError, + normalizeEName, + type ResolveCertifiedKeysResult, + resolveCertifiedKeys, + verifyWithCertifiedKeys, +} from "./ename-signature.js"; + +export type DelegatedSignatureError = + | "invalid_payload" + | "bad_signature" + | "resolve_failed" + | "not_a_company" + | "history_failed" + | "chain_invalid" + | "not_covered" + | "timeout"; + +export interface VerifyDelegatedSignatureOptions { + /** The exact string the wallet signed: a `w3ds-sign/v1` payload. */ + payload: string; + signature: string; + registryBaseUrl: string; + /** A token the company's eVault accepts for reading history, e.g. a platform token. */ + platformToken: string; + /** Applied to each Registry and eVault call. Defaults to 5000 ms. */ + timeoutMs?: number; + fetch?: typeof fetch; + signal?: AbortSignal; + /** The moment to judge validity at. Defaults to now. */ + now?: Date; +} + +export interface VerifyDelegatedSignatureResult { + valid: boolean; + error?: DelegatedSignatureError; + /** The underlying reason, e.g. the chain failure code. */ + detail?: string; + payload?: DelegatedSignPayload; + companyEName?: string; + title?: string; + scopes?: string[]; + /** Every link's app limits, root first; the platform must satisfy all. */ + appLimits?: Record[]; + /** Delegation ids from the one used up to its role assignment. */ + chain?: string[]; +} + +const DEFAULT_TIMEOUT_MS = 5000; +const HISTORY_PAGE = 100; +/** Bounds how much history one record may make us read. */ +const MAX_HISTORY_PAGES = 20; +/** + * Bounds how many signers' keys one verification looks up. Anyone can write a + * version naming any signer, so without a cap a record could make a single + * verification fan out into a lookup per version. + */ +const MAX_SIGNERS = 32; + +/** Failures of the infrastructure, as opposed to a signature not verifying. */ +const OUTAGES: Partial> = { + resolve_failed: "resolve_failed", + whois_failed: "resolve_failed", + jwks_failed: "resolve_failed", + timeout: "timeout", +}; + +const HISTORY_QUERY = `query History($id: ID!, $first: Int, $after: String) { + metaEnvelopeHistory(id: $id, first: $first, after: $after) { + edges { node { version operation ontology parsed createdAt } } + pageInfo { hasNextPage endCursor } + } +}`; + +class Failure extends Error { + constructor( + readonly code: DelegatedSignatureError, + readonly detail?: string, + ) { + super(code); + } +} + +/** + * Verifies that the payload's signer signed it and that a live delegation in + * the named company's eVault covers its scope. Never throws. + */ +export async function verifyDelegatedSignature( + options: VerifyDelegatedSignatureOptions, +): Promise { + const payload = + typeof options.payload === "string" + ? parseDelegatedSignPayload(options.payload) + : null; + if (!payload) return { valid: false, error: "invalid_payload" }; + + const request = { + timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS, + fetch: options.fetch ?? globalThis.fetch, + signal: options.signal, + }; + // Each signer's certified keys are looked up once; every signature is + // then checked locally. + const signers = new Map>(); + const keysOf = (eName: string): Promise => { + const key = normalizeEName(eName); + if (!signers.has(key)) { + if (signers.size >= MAX_SIGNERS) { + return Promise.resolve({ ok: false, error: "no_valid_certificate" }); + } + signers.set( + key, + resolveCertifiedKeys({ + eName: key, + registryBaseUrl: options.registryBaseUrl, + ...request, + }), + ); + } + return signers.get(key) as Promise; + }; + // An outage while checking a historical grant must not read as "no such + // grant": remember it, and report it if the chain then fails. + let outage: DelegatedSignatureError | null = null; + const verify = async (eName: string, signedPayload: string, signature: string) => { + const keys = await keysOf(eName); + if (!keys.ok && OUTAGES[keys.error]) outage ??= OUTAGES[keys.error] ?? null; + return keys.ok + ? (await verifyWithCertifiedKeys(keys.publicKeys, signedPayload, signature)) !== null + : false; + }; + + try { + const signerKeys = await keysOf(payload.signer); + if (!signerKeys.ok) { + throw new Failure(OUTAGES[signerKeys.error] ?? "bad_signature", signerKeys.error); + } + const signed = await verifyWithCertifiedKeys( + signerKeys.publicKeys, + options.payload, + options.signature, + ); + if (!signed) throw new Failure("bad_signature", "no_valid_certificate"); + + const company = normalizeEName(payload.onBehalfOf); + const evault = await resolveEVault(company, options.registryBaseUrl, request); + const companyId = await companyRecordId(company, evault, request); + + const chain = await evaluateFromHistory({ + delegationId: payload.delegationId, + companyEName: company, + companyId, + source: historySource(company, evault, options.platformToken, request), + verify, + now: options.now, + }); + const problem = checkDelegatedSignature(payload, chain); + if (problem && outage) throw new Failure(outage, "upstream outage while checking grants"); + if (problem) { + throw problem.code === "CHAIN_INVALID" + ? new Failure("chain_invalid", problem.chain.code) + : new Failure("not_covered", problem.code); + } + if (!chain.ok) throw new Failure("chain_invalid"); + + return { + valid: true, + payload, + companyEName: company, + title: chain.title, + scopes: chain.scopes, + appLimits: chain.appLimits, + chain: chain.chain, + }; + } catch (error) { + if (error instanceof Failure) { + return { valid: false, error: error.code, detail: error.detail, payload }; + } + return { + valid: false, + error: isTimeout(error) ? "timeout" : "history_failed", + payload, + }; + } +} + +type Request = { + timeoutMs: number; + fetch: typeof fetch; + signal?: AbortSignal; +}; + +async function call( + url: URL, + request: Request, + failure: DelegatedSignatureError, + init: RequestInit = {}, +): Promise { + const signals = [AbortSignal.timeout(request.timeoutMs)]; + if (request.signal) signals.push(request.signal); + try { + const response = await request.fetch(url, { + ...init, + headers: { accept: "application/json", ...(init.headers ?? {}) }, + signal: AbortSignal.any(signals), + redirect: "error", + cache: "no-store", + }); + if (!response.ok) throw new Failure(failure); + return await response.json(); + } catch (error) { + if (error instanceof Failure) throw error; + if (isTimeout(error)) throw new Failure("timeout"); + throw new Failure(failure); + } +} + +async function resolveEVault( + eName: string, + registryBaseUrl: string, + request: Request, +): Promise { + const url = new URL("/resolve", registryBaseUrl); + url.searchParams.set("w3id", eName); + const resolved = (await call(url, request, "resolve_failed")) as { + kind?: unknown; + uri?: unknown; + } | null; + if (resolved?.kind !== "evault" || typeof resolved.uri !== "string") { + throw new Failure("resolve_failed"); + } + try { + const uri = new URL(resolved.uri); + if (uri.protocol !== "http:" && uri.protocol !== "https:") throw new Error(); + return uri; + } catch { + throw new Failure("resolve_failed"); + } +} + +/** The company's own Company record: the eVault's pinned manifest. */ +async function companyRecordId( + eName: string, + evault: URL, + request: Request, +): Promise { + const whois = (await call(new URL("/whois", evault), request, "resolve_failed", { + headers: { "X-ENAME": eName }, + })) as { type?: unknown; manifest?: { id?: unknown } | null } | null; + if (whois?.type !== "company" || typeof whois.manifest?.id !== "string") { + throw new Failure("not_a_company"); + } + return whois.manifest.id; +} + +function historySource( + eName: string, + evault: URL, + token: string, + request: Request, +): HistorySource { + return { + async versions(id) { + const newestFirst: Version[] = []; + let after: string | null = null; + for (let page = 0; page < MAX_HISTORY_PAGES; page++) { + const body = (await call(new URL("/graphql", evault), request, "history_failed", { + method: "POST", + headers: { + "content-type": "application/json", + "X-ENAME": eName, + authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ + query: HISTORY_QUERY, + variables: { id, first: HISTORY_PAGE, after }, + }), + })) as { + data?: { + metaEnvelopeHistory?: { + edges?: { node?: Version }[]; + pageInfo?: { hasNextPage?: boolean; endCursor?: string | null }; + } | null; + }; + errors?: unknown[]; + } | null; + if (body?.errors?.length) throw new Failure("history_failed"); + const connection = body?.data?.metaEnvelopeHistory; + // A record the eVault has never seen has no history. + if (!connection) return []; + for (const edge of connection.edges ?? []) { + if (edge?.node) newestFirst.push(edge.node); + } + if (!connection.pageInfo?.hasNextPage || !connection.pageInfo.endCursor) { + return newestFirst.reverse(); + } + after = connection.pageInfo.endCursor; + } + throw new Failure("history_failed", "history too long"); + }, + }; +} + +function isTimeout(error: unknown): boolean { + return ( + error instanceof Error && + (error.name === "TimeoutError" || error.name === "AbortError") + ); +} diff --git a/packages/auth/src/ename-signature.ts b/packages/auth/src/ename-signature.ts index dbb001c6d..dff706f63 100644 --- a/packages/auth/src/ename-signature.ts +++ b/packages/auth/src/ename-signature.ts @@ -156,30 +156,32 @@ function httpUrl(value: unknown): URL | null { } } +export interface ResolveCertifiedKeysOptions + extends Omit {} + +export type ResolveCertifiedKeysResult = + | { ok: true; eName: string; publicKeys: string[] } + | { ok: false; eName?: string; error: EnameVerificationError }; + /** - * Verifies that the wallet holding `eName` signed `payload`: resolves the - * eName's eVault at the Registry, fetches its key binding certificates, checks - * each against the Registry's keys, and accepts if any certified key verifies - * the signature. Never throws; every failure returns `valid: false`. + * Fetches the public keys the Registry has certified for `eName`: resolves + * its eVault, reads the key binding certificates from `/whois`, and keeps + * those that verify against the Registry's keys and name this eName. Lets a + * caller check many signatures by one signer with a single lookup. Never + * throws. */ -export async function verifyEnameSignature( - options: VerifyEnameSignatureOptions, -): Promise { - const { payload, signature, registryBaseUrl, signal } = options; +export async function resolveCertifiedKeys( + options: ResolveCertifiedKeysOptions, +): Promise { + const { registryBaseUrl, signal } = options; if ( typeof options.eName !== "string" || - typeof payload !== "string" || - typeof signature !== "string" || typeof registryBaseUrl !== "string" || options.eName.trim().replace(/^@+/, "") === "" || options.eName.length > MAX_ENAME_LENGTH || - payload === "" || - payload.length > MAX_PAYLOAD_LENGTH || - signature === "" || - signature.length > MAX_SIGNATURE_LENGTH || !httpUrl(registryBaseUrl) ) { - return { valid: false, error: "invalid_input" }; + return { ok: false, error: "invalid_input" }; } const eName = normalizeEName(options.eName); @@ -197,10 +199,10 @@ export async function verifyEnameSignature( failure: "resolve_failed", })) as { kind?: unknown; uri?: unknown } | null; if (resolved?.kind !== "evault") { - return { valid: false, eName, error: "not_an_evault" }; + return { ok: false, eName, error: "not_an_evault" }; } const evault = httpUrl(resolved.uri); - if (!evault) return { valid: false, eName, error: "resolve_failed" }; + if (!evault) return { ok: false, eName, error: "resolve_failed" }; const whois = (await getJson(new URL("/whois", evault), { ...request, @@ -208,13 +210,13 @@ export async function verifyEnameSignature( failure: "whois_failed", })) as { keyBindingCertificates?: unknown } | null; if (!Array.isArray(whois?.keyBindingCertificates)) { - return { valid: false, eName, error: "whois_failed" }; + return { ok: false, eName, error: "whois_failed" }; } const certificates = whois.keyBindingCertificates .filter((value): value is string => typeof value === "string") .slice(0, MAX_CERTIFICATES); if (certificates.length === 0) { - return { valid: false, eName, error: "no_certificates" }; + return { ok: false, eName, error: "no_certificates" }; } const keyOptions = { @@ -223,6 +225,7 @@ export async function verifyEnameSignature( }; let keys = await registryKeys(registryBaseUrl, { ...keyOptions, refresh: false }); let refreshed = false; + const publicKeys: string[] = []; for (const certificate of certificates) { let claims: Record; @@ -242,22 +245,79 @@ export async function verifyEnameSignature( if (typeof claims.ename !== "string" || normalizeEName(claims.ename) !== eName) { continue; } - if (typeof claims.publicKey !== "string") continue; - const verification = await verifyP256Detailed(claims.publicKey, signature, payload); - if (verification.valid) { - return { - valid: true, - eName, - publicKey: claims.publicKey, - keyType: verification.encoding === "base58" ? "hardware" : "software", - }; - } + if (typeof claims.publicKey === "string") publicKeys.push(claims.publicKey); } - return { valid: false, eName, error: "no_valid_certificate" }; + if (publicKeys.length === 0) { + return { ok: false, eName, error: "no_valid_certificate" }; + } + return { ok: true, eName, publicKeys }; } catch (error) { if (error instanceof VerificationFailure) { - return { valid: false, eName, error: error.code }; + return { ok: false, eName, error: error.code }; + } + return { ok: false, eName, error: isTimeout(error) ? "timeout" : "no_valid_certificate" }; + } +} + +/** + * Checks a signature against already-certified keys, with no network calls. + */ +export async function verifyWithCertifiedKeys( + publicKeys: string[], + payload: string, + signature: string, +): Promise<{ publicKey: string; keyType: EnameKeyType } | null> { + if ( + typeof payload !== "string" || + typeof signature !== "string" || + payload === "" || + payload.length > MAX_PAYLOAD_LENGTH || + signature === "" || + signature.length > MAX_SIGNATURE_LENGTH + ) { + return null; + } + for (const publicKey of publicKeys) { + const verification = await verifyP256Detailed(publicKey, signature, payload); + if (verification.valid) { + return { + publicKey, + keyType: verification.encoding === "base58" ? "hardware" : "software", + }; } - return { valid: false, eName, error: isTimeout(error) ? "timeout" : "no_valid_certificate" }; } + return null; +} + +/** + * Verifies that the wallet holding `eName` signed `payload`: resolves the + * eName's eVault at the Registry, fetches its key binding certificates, checks + * each against the Registry's keys, and accepts if any certified key verifies + * the signature. Never throws; every failure returns `valid: false`. + */ +export async function verifyEnameSignature( + options: VerifyEnameSignatureOptions, +): Promise { + const { payload, signature } = options; + if ( + typeof payload !== "string" || + typeof signature !== "string" || + payload === "" || + payload.length > MAX_PAYLOAD_LENGTH || + signature === "" || + signature.length > MAX_SIGNATURE_LENGTH + ) { + return { valid: false, error: "invalid_input" }; + } + const keys = await resolveCertifiedKeys(options); + if (!keys.ok) { + return keys.eName + ? { valid: false, eName: keys.eName, error: keys.error } + : { valid: false, error: keys.error }; + } + const verified = await verifyWithCertifiedKeys(keys.publicKeys, payload, signature); + if (!verified) { + return { valid: false, eName: keys.eName, error: "no_valid_certificate" }; + } + return { valid: true, eName: keys.eName, ...verified }; } diff --git a/packages/auth/src/index.ts b/packages/auth/src/index.ts index 090c01209..be5492535 100644 --- a/packages/auth/src/index.ts +++ b/packages/auth/src/index.ts @@ -23,3 +23,18 @@ export type { VerifyEnameSignatureOptions, VerifyEnameSignatureResult, } from "./ename-signature.js"; +export { verifyDelegatedSignature } from "./delegated-signature.js"; +export type { + DelegatedSignatureError, + VerifyDelegatedSignatureOptions, + VerifyDelegatedSignatureResult, +} from "./delegated-signature.js"; +export { + buildDelegatedSignRequest, + buildGrantSignRequest, +} from "./sign-requests.js"; +export type { + DelegatedSignRequestOptions, + GrantSignRequestOptions, + SignRequest, +} from "./sign-requests.js"; diff --git a/packages/auth/src/sign-requests.ts b/packages/auth/src/sign-requests.ts new file mode 100644 index 000000000..60a395a22 --- /dev/null +++ b/packages/auth/src/sign-requests.ts @@ -0,0 +1,122 @@ +/** + * Builds `w3ds://sign` requests for company delegation. The eID wallet signs + * the `session` string as-is, so the signed payload *is* the session; the + * wallet needs no changes and the platform rejects what no delegation covers. + */ + +import { + buildDelegatedSignPayload, + buildGrantPayload, + type DelegatedSignPayload, +} from "@metastate-foundation/delegation"; +import { normalizeEName } from "./ename-signature.js"; + +export interface SignRequest { + /** The `w3ds://sign` URI to show as a QR code or deep link. */ + uri: string; + /** The exact string the wallet will sign (also the `session`). */ + payload: string; +} + +export interface DelegatedSignRequestOptions + extends Omit { + /** Where the wallet POSTs the signature. */ + redirectUri: string; + platformUrl?: string; + /** The delegate's title, shown in the wallet. */ + title: string; + /** The company's display name; defaults to its eName. */ + companyName?: string; + issuedAt?: string; +} + +/** A request for a delegate to sign for a company. */ +export function buildDelegatedSignRequest( + options: DelegatedSignRequestOptions, +): SignRequest { + const payload = buildDelegatedSignPayload({ + // The verifier compares these as eNames, so they carry their `@`. + onBehalfOf: normalizeEName(options.onBehalfOf), + signer: normalizeEName(options.signer), + scope: options.scope, + delegationId: options.delegationId, + documentHash: options.documentHash, + session: options.session, + issuedAt: options.issuedAt ?? new Date().toISOString(), + }); + const message = `Signing as ${options.title} for ${options.companyName ?? normalizeEName(options.onBehalfOf)}`; + return { + payload, + uri: signUri(payload, message, options.redirectUri, options.platformUrl), + }; +} + +export interface GrantSignRequestOptions { + ontology: string; + companyEName: string; + /** The MetaEnvelope id the record will be written under. */ + recordId: string; + signerEName: string; + /** The record as it will be stored, without `authorization`. */ + record: Record; + redirectUri: string; + platformUrl?: string; + /** What the signer is approving, shown in the wallet. */ + message: string; + signedAt?: string; +} + +/** + * A request for a director or delegate to authorise a Company, Role, + * Delegation or Shareholding record. Once the signature arrives, store the + * record with `authorization: { signerEName, signedPayload: payload, + * signature, signedAt }` under `recordId`, using the returned (normalised) + * `signerEName` and `signedAt`. + */ +export async function buildGrantSignRequest( + options: GrantSignRequestOptions, +): Promise { + const signedAt = options.signedAt ?? new Date().toISOString(); + const signerEName = normalizeEName(options.signerEName); + const payload = await buildGrantPayload({ + ontology: options.ontology, + companyEName: normalizeEName(options.companyEName), + recordId: options.recordId, + signerEName, + signedAt, + record: options.record, + }); + return { + payload, + signedAt, + signerEName, + uri: signUri(payload, options.message, options.redirectUri, options.platformUrl), + }; +} + +function signUri( + session: string, + message: string, + redirectUri: string, + platformUrl?: string, +): string { + const params = new URLSearchParams({ + session, + data: base64Json({ message, sessionId: session }), + redirect_uri: redirectUri, + }); + if (platformUrl) params.set("platform_url", platformUrl); + return `w3ds://sign?${params.toString()}`; +} + +/** + * The wallet decodes `data` with `atob`, which yields bytes, not UTF-8 text. + * Escaping non-ASCII characters keeps the JSON pure ASCII so names survive. + */ +function base64Json(value: unknown): string { + const ascii = JSON.stringify(value).replace( + /[\u0080-￿]/g, + (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); + return Buffer.from(ascii, "ascii").toString("base64"); +} diff --git a/packages/auth/src/verify-login.ts b/packages/auth/src/verify-login.ts index 2904aa0f0..49a589f2d 100644 --- a/packages/auth/src/verify-login.ts +++ b/packages/auth/src/verify-login.ts @@ -1,3 +1,4 @@ +import { isReservedPayload } from "@metastate-foundation/delegation"; import { verifySignature } from "signature-validator"; import type { LoginVerificationConfig, @@ -7,6 +8,10 @@ import type { export async function verifyLoginSignature( config: LoginVerificationConfig, ): Promise { + // A signature made for a company or over a grant is never a login. + if (typeof config.session === "string" && isReservedPayload(config.session)) { + return { valid: false, error: "reserved_payload" }; + } const result = await verifySignature({ eName: config.eName, signature: config.signature, diff --git a/packages/delegation/src/history.spec.ts b/packages/delegation/src/history.spec.ts index b9b4ecedc..68a12fc50 100644 --- a/packages/delegation/src/history.spec.ts +++ b/packages/delegation/src/history.spec.ts @@ -189,6 +189,22 @@ describe("resolveBoard", () => { expect(board.map((b) => b.directors)).toEqual([["@dir"]]); }); + it("never grants a board to a record born without one", async () => { + const vault = new Vault(); + vault.write("company", COMPANY_ONTOLOGY, { + id: "acme", + eName: ACME, + createdAt: "2026-10-01T00:00:00.000Z", + }); + await vault.signed( + "company", + COMPANY_ONTOLOGY, + "@mallory", + company(["@mallory"]), + ); + expect(await resolveBoard("company", ACME, vault, verify)).toEqual([]); + }); + it("refuses a first board its signer is not on", async () => { const vault = new Vault(); await vault.signed( diff --git a/packages/delegation/src/history.ts b/packages/delegation/src/history.ts index 75f8552e1..85a9e3f0d 100644 --- a/packages/delegation/src/history.ts +++ b/packages/delegation/src/history.ts @@ -42,7 +42,14 @@ export type BoardTimeline = { from: number; directors: string[] }[]; * back, is skipped as if it were never there. */ -/** Builds the board's timeline from the Company record's history. */ +/** + * Builds the board's timeline from the Company record's history. + * + * A board exists only if the record was born with one: its very first version + * must carry a board signed by one of its own directors. A Company record that + * started without directors can never acquire a board, so an existing company + * cannot be claimed by whoever adds directors to it first. + */ export async function resolveBoard( companyId: string, companyEName: string, @@ -53,12 +60,19 @@ export async function resolveBoard( let board: string[] | null = null; let lastSignedAt = Number.NEGATIVE_INFINITY; - for (const v of await source.versions(companyId)) { + const versions = await source.versions(companyId); + for (const [index, v] of versions.entries()) { const p = v.parsed; - if (v.operation === "delete" || !p || v.ontology !== COMPANY_ONTOLOGY) { + const isBoard = + v.operation !== "delete" && + !!p && + v.ontology === COMPANY_ONTOLOGY && + p.eName === companyEName && + isENameList(p.directors); + if (!isBoard) { + if (index === 0) return timeline; continue; } - if (p.eName !== companyEName || !isENameList(p.directors)) continue; const signed = await validGrant( COMPANY_ONTOLOGY, companyEName, @@ -68,12 +82,13 @@ export async function resolveBoard( 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; + if (!signed || !entitled.includes(signed.signer)) { + if (index === 0) return timeline; + continue; + } lastSignedAt = signed.signedAt; if (board && canonicalJson(board) === canonicalJson(p.directors)) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9bb941518..f3cf58752 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -771,6 +771,9 @@ importers: packages/auth: dependencies: + '@metastate-foundation/delegation': + specifier: workspace:* + version: link:../delegation jose: specifier: ^5.2.2 version: 5.10.0