···11+/**
22+ * Sender-frame cross-check helper (Phase 2 — docs/pubsub-state-machine.md).
33+ *
44+ * This module is intentionally small and Electron-free at runtime so it
55+ * can be unit-tested under `ELECTRON_RUN_AS_NODE=1`. The only Electron
66+ * touch point is a type import for `IpcMainEvent` / `IpcMainInvokeEvent`,
77+ * which the TypeScript compiler erases.
88+ *
99+ * Motivation: every `tile:*` IPC handler takes `payload.token` and
1010+ * resolves it to a capability grant. Nothing today checks that
1111+ * `event.sender` (the `WebContents` that delivered the frame) is the
1212+ * same `WebContents` that owns the token. A tile with an XSS bug that
1313+ * leaks its token could be impersonated by any other tile that learned
1414+ * the leaked token — the capability gate would trust the token field
1515+ * and let the forged frame through.
1616+ *
1717+ * Binding model:
1818+ * - Eager binding: `createTileBrowserWindow` and
1919+ * `registerTrustedBuiltinWindow` call `setTokenOwner(token,
2020+ * win.webContents.id)` right after `new BrowserWindow()`. This
2121+ * covers every regular tile + trustedBuiltin core renderer.
2222+ * - Trust-on-first-use fallback: for renderers we can't bind at
2323+ * construction time (e.g. `<webview>` guests minted inside
2424+ * `will-attach-webview` where Electron doesn't surface the guest
2525+ * wc id), the first IPC frame that carries the token binds
2626+ * `event.sender.id` atomically. Legitimate renderers always send
2727+ * their own first frame (tile:validate-token or tile:ready during
2828+ * preload) before any other code can access the token, so TOFU is
2929+ * race-free in practice.
3030+ *
3131+ * Rejection path:
3232+ * - `missing-token`: the frame did not carry a token → drop + drift.
3333+ * - `invalid-token`: no grant exists for the token → drop + drift.
3434+ * - `sender-mismatch`: grant is bound to a different wc id → drop + drift.
3535+ *
3636+ * Phase 8 will factor the check into the central `tile-ipc-gate.ts`
3737+ * chokepoint. For Phase 2 the check is duplicated inline at every
3838+ * handler — consistent is simpler than exempt.
3939+ */
4040+4141+import { getGrantForToken, getTokenOwner, setTokenOwner } from './tile-tokens.js';
4242+import { publish, scopes, getSystemAddress } from './pubsub.js';
4343+import type { CapabilityGrant } from './tile-manifest.js';
4444+4545+/**
4646+ * Emit a `tile:drift` event describing an IPC frame that was dropped
4747+ * by the sender-frame cross-check. Logged at warn level so it surfaces
4848+ * in `/tmp/test-electron.log`. Rate-limiting and structured topic
4949+ * ownership land in Phase 8; for now direct publish is fine.
5050+ */
5151+export function emitTileDrift(reason: string, ctx: Record<string, unknown>): void {
5252+ const payload = { reason, ...ctx, ts: Date.now() };
5353+ try {
5454+ publish(getSystemAddress(), scopes.GLOBAL, 'tile:drift', payload);
5555+ } catch (err) {
5656+ // Publishing drift must never throw out of the handler — swallow
5757+ // and log so the original rejection path stays clean.
5858+ console.error('[tile-ipc] Failed to publish tile:drift:', err);
5959+ }
6060+ console.warn(`[tile-ipc] tile:drift reason=${reason}`, ctx);
6161+}
6262+6363+/**
6464+ * Minimal shape of the IPC event we read from. Declared inline so this
6565+ * module doesn't force a direct `import { type IpcMainEvent } from
6666+ * 'electron'` — which would pull the module graph back into Electron
6767+ * territory at type-level (harmless but noisy for tests that stub
6868+ * `Electron.IpcMainEvent`).
6969+ */
7070+export interface SenderFrameEvent {
7171+ sender: {
7272+ id: number;
7373+ getURL(): string;
7474+ };
7575+}
7676+7777+/**
7878+ * Verify that `event.sender` matches the WebContents bound to
7979+ * `token`. Returns the grant on success; returns `null` on failure
8080+ * and emits `tile:drift`. On trust-on-first-use (no owner bound
8181+ * yet), atomically binds `event.sender.id` as the owner.
8282+ *
8383+ * Callers should substitute this for `getGrantForToken(token)` at
8484+ * every `tile:*` handler entry point. A `null` return means the
8585+ * handler MUST NOT perform any side-effect — drop silently.
8686+ *
8787+ * `opName` is the handler's channel name, used for drift telemetry
8888+ * and debugging. Keep it stable across releases so CI can fail on
8989+ * unexpected drift events.
9090+ */
9191+export function verifyTokenSender(
9292+ event: SenderFrameEvent,
9393+ token: string | undefined,
9494+ opName: string,
9595+): CapabilityGrant | null {
9696+ if (!token) {
9797+ emitTileDrift('missing-token', { op: opName, senderWcId: event.sender.id });
9898+ return null;
9999+ }
100100+ const grant = getGrantForToken(token);
101101+ if (!grant) {
102102+ emitTileDrift('invalid-token', { op: opName, senderWcId: event.sender.id });
103103+ return null;
104104+ }
105105+ const ownerWcId = getTokenOwner(token);
106106+ const senderWcId = event.sender.id;
107107+ if (ownerWcId === undefined) {
108108+ // Trust-on-first-use: bind the first sender we see. Legitimate
109109+ // renderers always send their own first frame before leaking the
110110+ // token anywhere.
111111+ setTokenOwner(token, senderWcId);
112112+ return grant;
113113+ }
114114+ if (ownerWcId !== senderWcId) {
115115+ let senderUrl = '';
116116+ try { senderUrl = event.sender.getURL(); } catch { /* wc destroyed */ }
117117+ emitTileDrift('sender-mismatch', {
118118+ op: opName,
119119+ tokenTileId: grant.tileId,
120120+ ownerWcId,
121121+ senderWcId,
122122+ senderUrl,
123123+ });
124124+ return null;
125125+ }
126126+ return grant;
127127+}
+144
backend/electron/tile-ipc.test.ts
···11+/**
22+ * Unit tests for tile-ipc.ts — Phase 2 sender-frame cross-check.
33+ *
44+ * The central invariant tested here: every `tile:*` IPC handler routes
55+ * through `verifyTokenSender()`. If the `event.sender` WebContents id
66+ * differs from the WebContents id bound to `payload.token`, the frame
77+ * is dropped and a `tile:drift` event is published. Without this check,
88+ * a tile with an XSS bug that leaked its token could be impersonated
99+ * by any other tile that learned the leaked token — the capability
1010+ * gate would trust the token field and let the forged frame through.
1111+ *
1212+ * Runs under Electron's Node host (via `yarn test:unit`). `pubsub`
1313+ * works under ELECTRON_RUN_AS_NODE because it has no Electron imports;
1414+ * `verifyTokenSender` imports `electron` only for IpcMainEvent type
1515+ * annotations, which are erased at runtime.
1616+ */
1717+1818+import { describe, it, beforeEach, afterEach } from 'node:test';
1919+import * as assert from 'node:assert';
2020+2121+import {
2222+ generateToken,
2323+ setTokenOwner,
2424+ clearAllTokens,
2525+ getTokenOwner,
2626+} from './tile-tokens.js';
2727+import { resolveCapabilities } from './tile-manifest.js';
2828+import { subscribe, unsubscribe, scopes } from './pubsub.js';
2929+import { verifyTokenSender } from './tile-ipc-sender-check.js';
3030+3131+// ─── Helpers ─────────────────────────────────────────────────────────
3232+3333+/** Fake IpcMainEvent.sender — only the id is load-bearing for the check. */
3434+function fakeEvent(senderWcId: number): Electron.IpcMainEvent {
3535+ return {
3636+ sender: {
3737+ id: senderWcId,
3838+ getURL: () => `peek://fake-sender-${senderWcId}/`,
3939+ },
4040+ } as unknown as Electron.IpcMainEvent;
4141+}
4242+4343+function mintToken(tileId: string): string {
4444+ const grant = resolveCapabilities(tileId, { pubsub: { scopes: ['global'] } }, true);
4545+ return generateToken(tileId, 'background', grant);
4646+}
4747+4848+/**
4949+ * Subscribe to the `tile:drift` topic and collect every payload
5050+ * delivered during the test. Callers inspect `events` after calling
5151+ * `verifyTokenSender` to assert drift emission shape.
5252+ */
5353+function captureDriftEvents(): { events: Array<Record<string, unknown>>; stop: () => void } {
5454+ const events: Array<Record<string, unknown>> = [];
5555+ const source = `test-drift-listener-${Math.random().toString(36).slice(2, 8)}`;
5656+ subscribe(source, scopes.GLOBAL, 'tile:drift', (msg) => {
5757+ events.push(msg as Record<string, unknown>);
5858+ });
5959+ return {
6060+ events,
6161+ stop: () => { unsubscribe(source, 'tile:drift'); },
6262+ };
6363+}
6464+6565+// ─── Tests ───────────────────────────────────────────────────────────
6666+6767+describe('verifyTokenSender (Phase 2: sender-frame cross-check)', () => {
6868+ let drift: ReturnType<typeof captureDriftEvents>;
6969+7070+ beforeEach(() => {
7171+ clearAllTokens();
7272+ drift = captureDriftEvents();
7373+ });
7474+7575+ afterEach(() => {
7676+ drift.stop();
7777+ clearAllTokens();
7878+ });
7979+8080+ it('accepts a frame when sender matches the bound owner', () => {
8181+ const token = mintToken('tile-a');
8282+ setTokenOwner(token, 100);
8383+8484+ const grant = verifyTokenSender(fakeEvent(100), token, 'tile:pubsub:publish');
8585+ assert.ok(grant, 'expected grant returned for legitimate sender');
8686+ assert.strictEqual(grant.tileId, 'tile-a');
8787+ assert.strictEqual(drift.events.length, 0, 'no drift event should have been emitted');
8888+ });
8989+9090+ it('rejects a frame whose sender WebContents id does not match the token owner', () => {
9191+ // Mint a token for tile A. Bind it to wc id 100 (tile A's own window).
9292+ const token = mintToken('tile-a');
9393+ setTokenOwner(token, 100);
9494+9595+ // Simulate tile B (wc id 200) sending a tile:pubsub:publish frame
9696+ // with tile A's leaked token.
9797+ const grant = verifyTokenSender(fakeEvent(200), token, 'tile:pubsub:publish');
9898+ assert.strictEqual(grant, null, 'rejected frame must return null');
9999+100100+ // One drift event published with reason=sender-mismatch and
101101+ // structured context.
102102+ assert.strictEqual(drift.events.length, 1, 'exactly one drift event should have been emitted');
103103+ const evt = drift.events[0];
104104+ assert.strictEqual(evt.reason, 'sender-mismatch');
105105+ assert.strictEqual(evt.op, 'tile:pubsub:publish');
106106+ assert.strictEqual(evt.tokenTileId, 'tile-a');
107107+ assert.strictEqual(evt.ownerWcId, 100);
108108+ assert.strictEqual(evt.senderWcId, 200);
109109+ assert.ok(typeof evt.ts === 'number', 'drift event carries timestamp');
110110+ });
111111+112112+ it('rejects a frame with a token that does not exist', () => {
113113+ const grant = verifyTokenSender(fakeEvent(200), 'nope-token', 'tile:pubsub:publish');
114114+ assert.strictEqual(grant, null);
115115+ assert.strictEqual(drift.events.length, 1);
116116+ assert.strictEqual(drift.events[0].reason, 'invalid-token');
117117+ });
118118+119119+ it('rejects a frame with no token', () => {
120120+ const grant = verifyTokenSender(fakeEvent(200), undefined, 'tile:pubsub:publish');
121121+ assert.strictEqual(grant, null);
122122+ assert.strictEqual(drift.events.length, 1);
123123+ assert.strictEqual(drift.events[0].reason, 'missing-token');
124124+ });
125125+126126+ it('trust-on-first-use: first frame binds the owner when none was set', () => {
127127+ // Mint a token without calling setTokenOwner (mimics webview
128128+ // guests where we can't bind eagerly).
129129+ const token = mintToken('webview-tile');
130130+ assert.strictEqual(getTokenOwner(token), undefined);
131131+132132+ const grant = verifyTokenSender(fakeEvent(300), token, 'tile:pubsub:publish');
133133+ assert.ok(grant, 'TOFU bind must return the grant on first use');
134134+ assert.strictEqual(getTokenOwner(token), 300, 'first sender becomes the bound owner');
135135+ assert.strictEqual(drift.events.length, 0);
136136+137137+ // Second frame from a DIFFERENT wc must now be rejected — the
138138+ // TOFU bind made wc 300 the sole legitimate sender.
139139+ const grant2 = verifyTokenSender(fakeEvent(400), token, 'tile:pubsub:publish');
140140+ assert.strictEqual(grant2, null);
141141+ assert.strictEqual(drift.events.length, 1);
142142+ assert.strictEqual(drift.events[0].reason, 'sender-mismatch');
143143+ });
144144+});
···4343 getGrantForToken,
4444 revokeTokensForTile,
4545 clearAllTokens,
4646+ setTokenOwner,
4647} from './tile-tokens.js';
4748import { scopes, publish, getSystemAddress, unsubscribeAll } from './pubsub.js';
4849import { DEBUG, getTilePreloadPath } from './config.js';
···261262262263 const BrowserWindow = getBrowserWindowCtor();
263264 const win = new BrowserWindow(windowOptions);
265265+266266+ // Phase 2 pubsub hardening: bind the capability token to its owning
267267+ // WebContents so every `tile:*` IPC handler can cross-check
268268+ // `event.sender.id === ownerWebContentsId`. Without this a compromised
269269+ // tile that leaks another tile's token could forge IPC frames using
270270+ // it. The token was minted before the window existed (it's injected
271271+ // via additionalArguments), so binding happens here — the first
272272+ // moment `win.webContents.id` is known.
273273+ setTokenOwner(token, win.webContents.id);
264274265275 const key = `${tileId}:${entryId}`;
266276 tileWindows.set(key, win);
···537547): void {
538548 const key = `${tileId}:${entryId}`;
539549 tileWindows.set(key, win);
550550+551551+ // Phase 2 pubsub hardening: bind the trustedBuiltin token to its
552552+ // owning WebContents so the IPC gate's sender-frame cross-check
553553+ // passes for this core renderer. See createTileBrowserWindow for
554554+ // the full rationale.
555555+ setTokenOwner(token, win.webContents.id);
540556541557 win.on('closed', () => {
542558 tileWindows.delete(key);
+59
backend/electron/tile-tokens.ts
···2424 tileEntryId: string;
2525 grant: CapabilityGrant;
2626 createdAt: number;
2727+ /**
2828+ * Electron `WebContents.id` of the renderer that owns this token.
2929+ *
3030+ * Set post-hoc — tokens are minted BEFORE the BrowserWindow is
3131+ * constructed (the token is injected via `additionalArguments`), so
3232+ * the owning wc id is not known at mint time. Callers that have the
3333+ * window in hand (`createTileBrowserWindow`, `registerTrustedBuiltinWindow`,
3434+ * the `window-open` special-case branches) call `setTokenOwner()`
3535+ * right after `new BrowserWindow()`.
3636+ *
3737+ * For renderers where eager binding isn't possible (e.g. `<webview>`
3838+ * guests — `will-attach-webview` doesn't hand back a guest wc id),
3939+ * the sender-frame cross-check falls back to trust-on-first-use:
4040+ * the first `tile:*` IPC frame seen for a token with no recorded
4141+ * owner binds `event.sender.id` atomically; all subsequent frames
4242+ * must match.
4343+ *
4444+ * `undefined` = not yet bound.
4545+ */
4646+ ownerWebContentsId?: number;
2747}
28482949/** Token store: token string -> record */
···7696export function getGrantForToken(token: string): CapabilityGrant | null {
7797 const record = tokenStore.get(token);
7898 return record ? record.grant : null;
9999+}
100100+101101+/**
102102+ * Bind a token to its owning WebContents id.
103103+ *
104104+ * Should be called once, immediately after the BrowserWindow (or webview
105105+ * guest WebContents) for the token is constructed, by the same code that
106106+ * minted the token. Safe to call a second time with the same id
107107+ * (idempotent). Calling with a different id after the first binding is
108108+ * treated as a drift condition — the caller is re-binding a token that
109109+ * belongs to a different frame; we keep the original owner and log.
110110+ *
111111+ * Returns:
112112+ * - `'bound'` — owner was unset; now bound to `webContentsId`.
113113+ * - `'already'` — owner was already this id; no change.
114114+ * - `'conflict'` — owner was a different id; original preserved.
115115+ * - `'unknown'` — no token record exists (caller passed a bad token).
116116+ */
117117+export function setTokenOwner(
118118+ token: string,
119119+ webContentsId: number,
120120+): 'bound' | 'already' | 'conflict' | 'unknown' {
121121+ const record = tokenStore.get(token);
122122+ if (!record) return 'unknown';
123123+ if (record.ownerWebContentsId === undefined) {
124124+ record.ownerWebContentsId = webContentsId;
125125+ return 'bound';
126126+ }
127127+ if (record.ownerWebContentsId === webContentsId) return 'already';
128128+ return 'conflict';
129129+}
130130+131131+/**
132132+ * Get the owning WebContents id for a token.
133133+ * Returns `undefined` if the token doesn't exist OR if the owner hasn't
134134+ * been bound yet (trust-on-first-use path — see `setTokenOwner` docs).
135135+ */
136136+export function getTokenOwner(token: string): number | undefined {
137137+ return tokenStore.get(token)?.ownerWebContentsId;
79138}
8013981140/**