diff --git a/src/core/engine.ts b/src/core/engine.ts index ebadc71..f3656dc 100644 --- a/src/core/engine.ts +++ b/src/core/engine.ts @@ -72,7 +72,7 @@ export class DecisionEngine { this.idGen = opts.idGen ?? randomUUID; } - async evaluate(intent: TxIntent, policy: Policy): Promise { + async evaluate(intent: TxIntent, policy: Policy, clientId?: string): Promise { const reasons: string[] = []; const rulesMatched: string[] = []; let verdict: Verdict = "ALLOW"; @@ -99,7 +99,7 @@ export class DecisionEngine { timestamp: this.now(), }; await this.handleRemediation(earlyDecision, policy); - return this.persist(earlyDecision); + return this.persist(earlyDecision, clientId); } const valueWei = tryBigInt(intent.value); @@ -125,6 +125,7 @@ export class DecisionEngine { const recent = await this.store.listDecisions({ owner: policy.owner, from: since, + ...(clientId !== undefined && { clientId }), }); const usedWei = recent .filter((d) => d.verdict !== "BLOCK") @@ -209,7 +210,7 @@ export class DecisionEngine { await this.handleRemediation(decision, policy); } - return this.persist(decision); + return this.persist(decision, clientId); } private async runSimulator(intent: TxIntent): Promise { @@ -227,8 +228,8 @@ export class DecisionEngine { } } - private async persist(decision: Decision): Promise { - await this.store.appendDecision(decision); + private async persist(decision: Decision, clientId?: string): Promise { + await this.store.appendDecision(decision, clientId); return decision; } diff --git a/src/core/policyService.ts b/src/core/policyService.ts index 2670915..d77d79c 100644 --- a/src/core/policyService.ts +++ b/src/core/policyService.ts @@ -10,7 +10,7 @@ export class PolicyService { private readonly idGen: () => string = randomUUID, ) {} - async create(input: unknown): Promise { + async create(input: unknown, clientId?: string): Promise { const parsed = policyInputSchema.parse(input); const policy: Policy = { id: this.idGen(), @@ -20,12 +20,12 @@ export class PolicyService { version: 1, updatedAt: this.now(), }; - await this.store.putPolicy(policy); + await this.store.putPolicy(policy, clientId); return policy; } - async update(id: string, input: PolicyInput): Promise { - const existing = await this.store.getPolicy(id); + async update(id: string, input: PolicyInput, clientId?: string): Promise { + const existing = await this.store.getPolicy(id, clientId); if (!existing) throw new Error(`Policy ${id} not found`); const policy: Policy = { ...existing, @@ -35,15 +35,15 @@ export class PolicyService { version: existing.version + 1, updatedAt: this.now(), }; - await this.store.putPolicy(policy); + await this.store.putPolicy(policy, clientId); return policy; } - get(id: string): Promise { - return this.store.getPolicy(id); + get(id: string, clientId?: string): Promise { + return this.store.getPolicy(id, clientId); } - list(owner?: Policy["owner"]): Promise { - return this.store.listPolicies(owner); + list(owner?: Policy["owner"], clientId?: string): Promise { + return this.store.listPolicies({ ...(owner !== undefined && { owner }), ...(clientId !== undefined && { clientId }) }); } } diff --git a/src/memory/memoryStore.ts b/src/memory/memoryStore.ts index 17beaae..edbbfda 100644 --- a/src/memory/memoryStore.ts +++ b/src/memory/memoryStore.ts @@ -1,39 +1,74 @@ import type { Decision, Policy } from "../core/types.js"; import type { Store } from "./store.js"; +interface TaggedPolicy { + policy: Policy; + clientId: string | null; +} + +interface TaggedDecision { + decision: Decision; + clientId: string | null; +} + +/** + * In-memory `Store` with per-browser isolation. Every row is tagged with the + * `clientId` that wrote it (null when the writer didn't supply one — typical + * for the demo CLI / curl). Reads scope to the caller's clientId when one is + * supplied; an `undefined` clientId returns everything (admin / debug). + */ export class InMemoryStore implements Store { - private policies = new Map(); - private decisions: Decision[] = []; + private policies = new Map(); + private decisions: TaggedDecision[] = []; - async putPolicy(policy: Policy): Promise { - this.policies.set(policy.id, policy); + async putPolicy(policy: Policy, clientId?: string): Promise { + this.policies.set(policy.id, { policy, clientId: clientId ?? null }); } - async getPolicy(id: string): Promise { - return this.policies.get(id) ?? null; + async getPolicy(id: string, clientId?: string): Promise { + const row = this.policies.get(id); + if (!row) return null; + if (clientId !== undefined && row.clientId !== clientId) return null; + return row.policy; } - async listPolicies(owner?: Policy["owner"]): Promise { + async listPolicies(filter: { + owner?: Policy["owner"]; + clientId?: string; + } = {}): Promise { const all = [...this.policies.values()]; - return owner ? all.filter((p) => p.owner.toLowerCase() === owner.toLowerCase()) : all; + return all + .filter((row) => { + if (filter.clientId !== undefined && row.clientId !== filter.clientId) return false; + if (filter.owner && row.policy.owner.toLowerCase() !== filter.owner.toLowerCase()) { + return false; + } + return true; + }) + .map((row) => row.policy); } - async appendDecision(decision: Decision): Promise { - this.decisions.push(decision); + async appendDecision(decision: Decision, clientId?: string): Promise { + this.decisions.push({ decision, clientId: clientId ?? null }); } async listDecisions(filter: { owner?: Policy["owner"]; from?: number; to?: number; + clientId?: string; }): Promise { - return this.decisions.filter((d) => { - if (filter.from !== undefined && d.timestamp < filter.from) return false; - if (filter.to !== undefined && d.timestamp > filter.to) return false; - if (filter.owner && d.intent.from.toLowerCase() !== filter.owner.toLowerCase()) { - return false; - } - return true; - }); + return this.decisions + .filter((row) => { + if (filter.clientId !== undefined && row.clientId !== filter.clientId) return false; + const d = row.decision; + if (filter.from !== undefined && d.timestamp < filter.from) return false; + if (filter.to !== undefined && d.timestamp > filter.to) return false; + if (filter.owner && d.intent.from.toLowerCase() !== filter.owner.toLowerCase()) { + return false; + } + return true; + }) + .map((row) => row.decision); } } diff --git a/src/memory/store.ts b/src/memory/store.ts index cf16007..2194064 100644 --- a/src/memory/store.ts +++ b/src/memory/store.ts @@ -5,15 +5,38 @@ export interface AnchorRecord { txHash: string; } +/** + * Per-browser session isolation contract. + * + * Every read + write accepts an optional `clientId`. The frontend generates a + * stable UUID per browser (stored in localStorage) and sends it as the + * `X-Client-Id` HTTP header on every API call; the Fastify route reads the + * header and threads the value down to the Store. Implementations: + * + * - On WRITE, tag the row with the supplied clientId. `undefined` -> + * untagged (system/admin/CLI rows). + * - On READ, when a clientId is provided, only return rows tagged with + * the same clientId. `undefined` -> return everything (admin / curl / + * test fixtures). + * + * The contract NEVER leaks the existence of a row owned by a different + * client: `getPolicy("foreign-id", "my-client")` returns `null`, identical + * to a genuinely missing id. This is the property `tests/clientIsolation` + * locks down. + */ export interface Store { - putPolicy(policy: Policy): Promise; - getPolicy(id: string): Promise; - listPolicies(owner?: Policy["owner"]): Promise; - appendDecision(decision: Decision): Promise; + putPolicy(policy: Policy, clientId?: string): Promise; + getPolicy(id: string, clientId?: string): Promise; + listPolicies(filter?: { + owner?: Policy["owner"]; + clientId?: string; + }): Promise; + appendDecision(decision: Decision, clientId?: string): Promise; listDecisions(filter: { owner?: Policy["owner"]; from?: number; to?: number; + clientId?: string; }): Promise; getAnchor?(id: string): AnchorRecord | undefined; } diff --git a/src/memory/zeroGStore.ts b/src/memory/zeroGStore.ts index 2683f91..a1c13a1 100644 --- a/src/memory/zeroGStore.ts +++ b/src/memory/zeroGStore.ts @@ -34,16 +34,34 @@ export interface ZeroGStoreOptions { logger?: Pick; } +interface TaggedPolicy { + policy: Policy; + clientId: string | null; +} + +interface TaggedDecision { + decision: Decision; + clientId: string | null; +} + export class ZeroGStore implements Store { private readonly indexer: IndexerLike; private readonly signer: ethers.Signer; private readonly rpcUrl: string; private readonly logger: Pick; - private policies = new Map(); - private decisions: Decision[] = []; + private policies = new Map(); + private decisions: TaggedDecision[] = []; private anchors = new Map(); + /** + * Tracks every in-flight 0G upload by row id so tests + the API admin + * route can `await` for the anchor to land. In production nothing awaits + * this — `putPolicy`/`appendDecision` return immediately after the local + * write so the request hot path stays under 50ms instead of 5-30s. + */ + private pending = new Map>(); + constructor(opts: ZeroGStoreOptions) { this.rpcUrl = opts.rpcUrl; this.logger = opts.logger ?? console; @@ -59,35 +77,63 @@ export class ZeroGStore implements Store { } } - /** - * Tracks every in-flight 0G upload by row id so tests + the API admin - * route can `await` for the anchor to land. In production nothing awaits - * this — `putPolicy`/`appendDecision` return immediately after the local - * write so the request hot path stays under 50ms instead of 5-30s. - */ - private pending = new Map>(); - - async putPolicy(policy: Policy): Promise { - this.policies.set(policy.id, policy); + async putPolicy(policy: Policy, clientId?: string): Promise { + this.policies.set(policy.id, { policy, clientId: clientId ?? null }); this.scheduleAnchor(policy.id, `policy:${policy.id}`, JSON.stringify(policy)); } - async getPolicy(id: string): Promise { - return this.policies.get(id) ?? null; + async getPolicy(id: string, clientId?: string): Promise { + const row = this.policies.get(id); + if (!row) return null; + if (clientId !== undefined && row.clientId !== clientId) return null; + return row.policy; } - async listPolicies(owner?: Policy["owner"]): Promise { + async listPolicies(filter: { + owner?: Policy["owner"]; + clientId?: string; + } = {}): Promise { const all = [...this.policies.values()]; - return owner - ? all.filter((p) => p.owner.toLowerCase() === owner.toLowerCase()) - : all; + return all + .filter((row) => { + if (filter.clientId !== undefined && row.clientId !== filter.clientId) return false; + if (filter.owner && row.policy.owner.toLowerCase() !== filter.owner.toLowerCase()) { + return false; + } + return true; + }) + .map((row) => row.policy); } - async appendDecision(decision: Decision): Promise { - this.decisions.push(decision); + async appendDecision(decision: Decision, clientId?: string): Promise { + this.decisions.push({ decision, clientId: clientId ?? null }); this.scheduleAnchor(decision.id, `decision:${decision.id}`, JSON.stringify(decision)); } + async listDecisions(filter: { + owner?: Policy["owner"]; + from?: number; + to?: number; + clientId?: string; + }): Promise { + return this.decisions + .filter((row) => { + if (filter.clientId !== undefined && row.clientId !== filter.clientId) return false; + const d = row.decision; + if (filter.from !== undefined && d.timestamp < filter.from) return false; + if (filter.to !== undefined && d.timestamp > filter.to) return false; + if (filter.owner && d.intent.from.toLowerCase() !== filter.owner.toLowerCase()) { + return false; + } + return true; + }) + .map((row) => row.decision); + } + + getAnchor(id: string): AnchorRecord | undefined { + return this.anchors.get(id); + } + /** * Awaits the background anchor upload for a single row. Used by tests so * they can assert anchor presence after the upload settles, without @@ -117,25 +163,6 @@ export class ZeroGStore implements Store { this.pending.set(rowId, p); } - async listDecisions(filter: { - owner?: Policy["owner"]; - from?: number; - to?: number; - }): Promise { - return this.decisions.filter((d) => { - if (filter.from !== undefined && d.timestamp < filter.from) return false; - if (filter.to !== undefined && d.timestamp > filter.to) return false; - if (filter.owner && d.intent.from.toLowerCase() !== filter.owner.toLowerCase()) { - return false; - } - return true; - }); - } - - getAnchor(id: string): AnchorRecord | undefined { - return this.anchors.get(id); - } - private async tryAnchor(label: string, json: string): Promise { const bytes = new TextEncoder().encode(json); const file = new MemData(bytes); diff --git a/src/risk-gate/app.ts b/src/risk-gate/app.ts index 391f13b..87671be 100644 --- a/src/risk-gate/app.ts +++ b/src/risk-gate/app.ts @@ -88,8 +88,28 @@ export function buildApp(deps: AppDeps = {}): FastifyInstance { app.get("/health", async () => ({ status: "ok" })); + /** + * Read the per-browser session id from the request. The Astro frontend + * generates a UUID once on first load and stores it in localStorage; every + * fetch sends it back via `X-Client-Id`. The risk-gate scopes all CRUD + * operations to this id so Browser A cannot see Browser B's policies or + * timeline rows. + * + * Requests without the header (curl, the demo CLI, integration tests) get + * `undefined` and the Store reverts to "no filter" — i.e. global view — + * which is the legacy behaviour and is still useful for admin / debug. + */ + function clientIdOf(req: { headers: Record }): string | undefined { + const raw = req.headers["x-client-id"]; + if (typeof raw !== "string") return undefined; + const trimmed = raw.trim(); + // Cap at 128 chars to bound the dimension of the in-memory store. + if (trimmed.length === 0 || trimmed.length > 128) return undefined; + return trimmed; + } + app.post("/policies", async (req, reply) => { - const policy = await policyService.create(req.body); + const policy = await policyService.create(req.body, clientIdOf(req)); reply.status(201); return withAnchorPolicy(policy); }); @@ -98,7 +118,7 @@ export function buildApp(deps: AppDeps = {}): FastifyInstance { const { id } = req.params as { id: string }; const parsed = policyInputSchema.parse(req.body); try { - return withAnchorPolicy(await policyService.update(id, parsed)); + return withAnchorPolicy(await policyService.update(id, parsed, clientIdOf(req))); } catch (err) { reply.status(404); return { error: "NotFound", message: (err as Error).message }; @@ -107,7 +127,7 @@ export function buildApp(deps: AppDeps = {}): FastifyInstance { app.get("/policies/:id", async (req, reply) => { const { id } = req.params as { id: string }; - const policy = await policyService.get(id); + const policy = await policyService.get(id, clientIdOf(req)); if (!policy) { reply.status(404); return { error: "NotFound" }; @@ -117,13 +137,14 @@ export function buildApp(deps: AppDeps = {}): FastifyInstance { app.get("/policies", async (req) => { const { owner } = req.query as { owner?: string }; - const list = await policyService.list(owner as `0x${string}` | undefined); + const list = await policyService.list(owner as `0x${string}` | undefined, clientIdOf(req)); return list.map(withAnchorPolicy); }); app.post("/evaluate", async (req, reply) => { const body = evaluateRequestSchema.parse(req.body); - const policy = await policyService.get(body.policyId); + const cid = clientIdOf(req); + const policy = await policyService.get(body.policyId, cid); if (!policy) { reply.status(404); return { error: "PolicyNotFound" }; @@ -135,15 +156,17 @@ export function buildApp(deps: AppDeps = {}): FastifyInstance { message: "Policy owner must match intent.from.", }; } - return withAnchorDecision(await engine.evaluate(body.intent, policy)); + return withAnchorDecision(await engine.evaluate(body.intent, policy, cid)); }); app.get("/timeline", async (req) => { const q = req.query as { owner?: string; from?: string; to?: string }; + const cid = clientIdOf(req); const list = await store.listDecisions({ owner: q.owner as `0x${string}` | undefined, from: q.from ? Number(q.from) : undefined, to: q.to ? Number(q.to) : undefined, + ...(cid !== undefined && { clientId: cid }), }); return list.map(withAnchorDecision); }); diff --git a/tests/clientIsolation.test.ts b/tests/clientIsolation.test.ts new file mode 100644 index 0000000..244ea3a --- /dev/null +++ b/tests/clientIsolation.test.ts @@ -0,0 +1,195 @@ +import { describe, expect, it } from "bun:test"; +import { buildApp } from "../src/risk-gate/app.js"; +import { InMemoryStore } from "../src/memory/memoryStore.js"; +import { COLD_VAULT, TREASURY, makeIntent, makePolicy } from "./helpers.js"; + +const A = "browser-aaa"; +const B = "browser-bbb"; + +function postPolicy(app: ReturnType, clientId?: string) { + return app.inject({ + method: "POST", + url: "/policies", + headers: clientId ? { "x-client-id": clientId } : {}, + payload: { owner: TREASURY, rules: { allowedDestinations: [COLD_VAULT] } }, + }); +} + +function listPolicies(app: ReturnType, clientId?: string) { + return app.inject({ + method: "GET", + url: "/policies", + headers: clientId ? { "x-client-id": clientId } : {}, + }); +} + +function getPolicy(app: ReturnType, id: string, clientId?: string) { + return app.inject({ + method: "GET", + url: `/policies/${encodeURIComponent(id)}`, + headers: clientId ? { "x-client-id": clientId } : {}, + }); +} + +function postEvaluate( + app: ReturnType, + policyId: string, + clientId: string, +) { + return app.inject({ + method: "POST", + url: "/evaluate", + headers: { "x-client-id": clientId }, + payload: { + policyId, + intent: { + from: TREASURY, + to: COLD_VAULT, + value: "1", + data: "0x", + chainId: 16602, + }, + }, + }); +} + +function getTimeline(app: ReturnType, clientId?: string) { + return app.inject({ + method: "GET", + url: "/timeline", + headers: clientId ? { "x-client-id": clientId } : {}, + }); +} + +describe("Per-browser session isolation via X-Client-Id", () => { + it("a policy created by browser A is not visible to browser B", async () => { + const app = buildApp(); + const created = await postPolicy(app, A); + expect(created.statusCode).toBe(201); + const policyId = created.json().id; + + const aSeesIt = await listPolicies(app, A); + expect(aSeesIt.json().map((p: { id: string }) => p.id)).toContain(policyId); + + const bSeesIt = await listPolicies(app, B); + expect(bSeesIt.json()).toHaveLength(0); + + await app.close(); + }); + + it("getPolicy returns 404 when the id belongs to a different client (no leakage)", async () => { + const app = buildApp(); + const created = await postPolicy(app, A); + const policyId = created.json().id; + + const aFinds = await getPolicy(app, policyId, A); + expect(aFinds.statusCode).toBe(200); + + const bGets404 = await getPolicy(app, policyId, B); + expect(bGets404.statusCode).toBe(404); + + await app.close(); + }); + + it("evaluate fails for browser B against a policy owned by browser A", async () => { + const app = buildApp(); + const created = await postPolicy(app, A); + const policyId = created.json().id; + + const bEval = await postEvaluate(app, policyId, B); + expect(bEval.statusCode).toBe(404); + expect(bEval.json().error).toBe("PolicyNotFound"); + + await app.close(); + }); + + it("decisions written under A do not appear in B's /timeline", async () => { + const app = buildApp(); + const created = await postPolicy(app, A); + const policyId = created.json().id; + const aEval = await postEvaluate(app, policyId, A); + expect(aEval.statusCode).toBe(200); + + const aTimeline = await getTimeline(app, A); + expect(aTimeline.json()).toHaveLength(1); + + const bTimeline = await getTimeline(app, B); + expect(bTimeline.json()).toHaveLength(0); + + await app.close(); + }); + + it("requests without X-Client-Id see the global view (admin / curl path)", async () => { + const app = buildApp(); + await postPolicy(app, A); + await postPolicy(app, B); + + const adminList = await listPolicies(app); + expect(adminList.json().length).toBeGreaterThanOrEqual(2); + + await app.close(); + }); + + it("rejects oversized X-Client-Id headers and falls back to admin view", async () => { + const app = buildApp(); + const oversized = "x".repeat(200); + const created = await postPolicy(app, oversized); + expect(created.statusCode).toBe(201); + // Server treated the oversized id as "no clientId", so the policy was + // tagged null and the admin view (no header) sees it. + const adminSeesIt = await listPolicies(app); + expect(adminSeesIt.json().length).toBeGreaterThanOrEqual(1); + await app.close(); + }); +}); + +describe("InMemoryStore: clientId tagging at the unit level", () => { + it("getPolicy returns null when the wrong clientId is supplied", async () => { + const store = new InMemoryStore(); + await store.putPolicy(makePolicy({ id: "p" }), "alice"); + expect(await store.getPolicy("p", "alice")).not.toBeNull(); + expect(await store.getPolicy("p", "bob")).toBeNull(); + expect(await store.getPolicy("p")).not.toBeNull(); // admin view + }); + + it("listDecisions filters by clientId before owner / from / to", async () => { + const store = new InMemoryStore(); + await store.appendDecision( + { + id: "d-alice", + intent: makeIntent({ value: "1" }), + verdict: "ALLOW", + riskScore: 0, + rulesMatched: [], + reasons: [], + policyId: "p", + timestamp: 100, + }, + "alice", + ); + await store.appendDecision( + { + id: "d-bob", + intent: makeIntent({ value: "1" }), + verdict: "ALLOW", + riskScore: 0, + rulesMatched: [], + reasons: [], + policyId: "p", + timestamp: 200, + }, + "bob", + ); + + expect((await store.listDecisions({ clientId: "alice" })).map((d) => d.id)).toEqual([ + "d-alice", + ]); + expect((await store.listDecisions({ clientId: "bob" })).map((d) => d.id)).toEqual([ + "d-bob", + ]); + expect((await store.listDecisions({})).map((d) => d.id)).toEqual([ + "d-alice", + "d-bob", + ]); + }); +}); diff --git a/tests/zeroGStore.test.ts b/tests/zeroGStore.test.ts index 6fb823f..6d69a01 100644 --- a/tests/zeroGStore.test.ts +++ b/tests/zeroGStore.test.ts @@ -122,7 +122,7 @@ describe("ZeroGStore", () => { await store.putPolicy(makePolicy({ id: "p1", owner: TREASURY })); await store.putPolicy(makePolicy({ id: "p2", owner: ATTACKER })); const upper = TREASURY.toUpperCase() as `0x${string}`; - const list = await store.listPolicies(upper); + const list = await store.listPolicies({ owner: upper }); expect(list.map((p) => p.id)).toEqual(["p1"]); }); diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 06ce8d4..817d731 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -7,14 +7,51 @@ export const API_BASE: string = (import.meta.env.PUBLIC_API_BASE as string | undefined) || "http://127.0.0.1:8787"; +const CLIENT_ID_KEY = "chainshield:client-id"; + +/** + * Returns a stable UUID-shaped session id, persisted in localStorage so it + * survives page reloads. The risk-gate scopes all CRUD operations to this + * id (sent as the `X-Client-Id` header) so a fresh browser sees an empty + * workspace and cannot read another browser's policies or timeline. + * + * Falls back to an in-memory id when localStorage is unavailable (Safari + * private mode, sandboxed iframes) — isolation still works for the lifetime + * of the page, just not across reloads. + */ +let inMemoryClientId: string | null = null; + +export function getClientId(): string { + if (inMemoryClientId) return inMemoryClientId; + try { + const existing = localStorage.getItem(CLIENT_ID_KEY); + if (existing && existing.length > 0) { + inMemoryClientId = existing; + return existing; + } + const fresh = crypto.randomUUID(); + localStorage.setItem(CLIENT_ID_KEY, fresh); + inMemoryClientId = fresh; + return fresh; + } catch { + // localStorage threw (private mode / disabled). Use a per-tab id. + inMemoryClientId = crypto.randomUUID(); + return inMemoryClientId; + } +} + export async function api( method: string, path: string, body?: unknown, ): Promise> { + const headers: Record = { + "X-Client-Id": getClientId(), + }; + if (body) headers["Content-Type"] = "application/json"; const res = await fetch(`${API_BASE}${path}`, { method, - headers: body ? { "Content-Type": "application/json" } : undefined, + headers, body: body ? JSON.stringify(body) : undefined, }); const text = await res.text();