From 9d88585b889c2347c879e26f4f6fdea82798939b Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 20 Oct 2026 08:20:05 -0700 Subject: [PATCH 1/4] Key the Windows peer-link pipe name by the shared token On Windows the pipe every VS Code window contends for was named from the extension's storage path alone, in the machine-wide pipe namespace, so another local user could compute it and create it first; every window's bind then failed and the peer link and remote control stayed down. The name is now an HMAC over the storage path keyed by the peer token. A Windows broker also listens on the path-only name older builds bind, and a window that wins the keyed pipe while an older build's broker holds that name joins it as a client, so the two builds share one broker during an update. Unix paths are unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/specs/vscode.md | 6 +- docs/specs/vscode.rationale.md | 4 + scripts/spec-word-budgets.json | 2 +- vscode-ext/src/peer-link.ts | 124 +++++++++++++++++++++++++----- vscode-ext/test/helpers.ts | 23 +++--- vscode-ext/test/peer-link.test.ts | 118 +++++++++++++++++++++++++++- 6 files changed, 244 insertions(+), 33 deletions(-) diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 5d2b24582..0938d7fe8 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -135,16 +135,16 @@ The shared `BurrowService` (`docs/specs/relay.md` → "Burrow side", which owns Source of truth: `VsCodeBurrowStateStore` in `vscode-ext/src/burrow-store.ts`, `markOneTimeServing` and `contendIfServing` in `vscode-ext/src/burrow.ts`. -**Which window: bind-as-lease.** **The bind is the lease**, so the windows' extension hosts never fight over the enrollment's one `/ws/burrow` socket (rationale). Every contending window binds one fixed path — `.sock` in a per-user `dormouse-peer-` directory in the temp dir, or `\\.\pipe\dormouse-peer-` on Windows — **the hash is the first 12 hex characters of SHA-256 over `context.globalStorageUri.fsPath`**, so every window, whatever its build, computes the same path (hashed for the platform path cap; rationale). The winner is the broker and runs the service; every other window connects as a client once the broker's welcome verifies. +**Which window: bind-as-lease.** **The bind is the lease**, so the windows' extension hosts never fight over the enrollment's one `/ws/burrow` socket (rationale). Every contending window binds one fixed path, so every window, whatever its build, computes the same one: on unix `.sock` in a per-user `dormouse-peer-` directory in the temp dir, **the hash the first 12 hex characters of SHA-256 over `context.globalStorageUri.fsPath`** (hashed for the platform path cap; rationale); on Windows `\\.\pipe\dormouse-peer-`, **the key the first 32 hex characters of HMAC-SHA256 over that path, keyed by the peer token**, so another local user cannot name the machine-wide pipe before a window binds it (rationale). **A Windows broker also listens on the pipe builds before the key bound, `dormouse-peer-`, and a window that wins the keyed pipe while an older build's broker holds that name stands down and becomes its client**, so the two builds share one broker while both run (`joinLegacyBroker`). The winner is the broker and runs the service; every other window connects as a client once the broker's welcome verifies. - **Roles never flip downward**: no `onRole(false)` after a `true` (rationale). - **Contend on broker death, not on a timer**: `bind` picks exactly one; no TTL, heartbeat file, or watcher. - **Never unlink on the first refusal; a reclaimed bind is re-checked (`stillOurs`) before it serves** (rationale). - **A bind is not a role until it is believed**: **an unverified bind reads as unsettled**, and a command landing mid-verification is held for the verdict (rationale). -**Trust.** The socket path is derived, not secret, so two layers stand between it and this installation's terminals. +**Trust.** The unix socket path is derived, not secret, so two layers stand between it and this installation's terminals. -*The directory.* On unix every bind and connect is held to `peerDirIsSafe()`, the predicate the `dor` control socket uses (`docs/specs/security-local.md` → "The dor control socket"). A loose directory we own is tightened; anything else is somebody else's and **the peer link stands down for good**, releasing the callers waiting on the contention. Windows named pipes carry their own ACL and skip this layer. +*The directory.* On unix every bind and connect is held to `peerDirIsSafe()`, the predicate the `dor` control socket uses (`docs/specs/security-local.md` → "The dor control socket"). A loose directory we own is tightened; anything else is somebody else's and **the peer link stands down for good**, releasing the callers waiting on the contention. Windows has no such directory and skips this layer; the token key in the pipe's name stands in for it, and an older build's path-only name keeps only the handshake. *The handshake.* The shared secret is a mode-0600 `burrow.peer-token` in `globalStorageUri`, **created once with an exclusive `wx` write rather than a rename** so two windows starting together agree on one token. **Treat an empty read as *not yet written*, never as the token**, waiting it out with a bounded retry (rationale); **exhausting that wait latches the same permanent stand-down** as an unsafe directory. The token **never crosses the socket**; three frames prove mutual knowledge of it: diff --git a/docs/specs/vscode.rationale.md b/docs/specs/vscode.rationale.md index 2cc011d45..c7eb323f1 100644 --- a/docs/specs/vscode.rationale.md +++ b/docs/specs/vscode.rationale.md @@ -48,6 +48,10 @@ **Why the socket path is hashed.** macOS caps a unix socket path near 104 bytes, and `context.globalStorageUri.fsPath` is most of that on its own — joining a name onto it overflows. +**Why the Windows pipe name is keyed by the token.** The pipe namespace is machine-wide, and `peerDirIsSafe` has no directory to check there, so a name derived from the storage path alone is one another local user can compute and create first. The handshake still keeps them from being served, but every window's bind then fails and the peer link and remote control stay down. Keyed by the token, the name is known only to processes that can read `burrow.peer-token`, which sits under the user's profile. + +**Why a Windows broker still answers the path-only name.** An extension update reaches each VS Code window only when that window restarts its extension host, so old and new builds run side by side. Without the shared name each would elect its own broker, and two brokers displace each other on the Relay (above). The path-only name stays squattable, which leaves an older build where it was; the keyed pipe serves regardless. + **Why the enrollment is memoized.** `SecretStorage` is a keychain round trip, and both the activation probe and the service want the same answer. **Why the memo must be dropped on any window's change.** Without it a window promoted to broker could resurrect an enrollment another window had cleared, or never see one another window had just created — `SecretStorage` is shared across all of an extension's windows. diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 53f806eaa..93771c402 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -40,7 +40,7 @@ "docs/specs/tiling-engine.md": 2500, "docs/specs/transport.md": 4450, "docs/specs/tutorial.md": 1750, - "docs/specs/vscode.md": 4300, + "docs/specs/vscode.md": 4400, "docs/specs/webgl-text.md": 750, "docs/specs/website-docs.md": 3100 } diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index 16c06bbef..b5919f64a 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -7,7 +7,7 @@ import { chmod, lstat, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { statSync, type BigIntStats } from 'node:fs'; -import { createHash, randomUUID } from 'node:crypto'; +import { createHash, createHmac, randomUUID } from 'node:crypto'; import { createConnection, createServer, type Server, type Socket } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -181,19 +181,44 @@ async function peerDirIsSafe(): Promise { * The one path every window of this installation contends for. * * Hashed rather than joined: macOS caps a unix socket path near 104 bytes and - * the extension's globalStorage path is most of that on its own. Derived from - * that path rather than random precisely because it must be *the same* in every - * window — the bind is the arbitration. + * the extension's globalStorage path is most of that on its own. Derived rather + * than random precisely because it must be *the same* in every window — the + * bind is the arbitration. + * + * On unix the per-user directory ({@link peerDirIsSafe}) keeps another user from + * creating the path first, so the storage path alone names it. Windows has no + * such directory: the pipe namespace is machine-wide, and a name any local user + * can compute is one they can take before we bind it. So there the name is an + * HMAC over the storage path keyed by the shared token, which only a process + * that can read {@link TOKEN_FILE} can produce. + */ +export function peerSocketPath( + storagePath: string, + token: string, + platform: NodeJS.Platform = process.platform, +): string { + if (platform !== 'win32') { + const id = createHash('sha256').update(storagePath).digest('hex').slice(0, 12); + return join(peerDirPath(), `${id}.sock`); + } + const id = createHmac('sha256', token).update(storagePath).digest('hex').slice(0, 32); + return `\\\\.\\pipe\\dormouse-peer-${id}`; +} + +/** + * The pipe builds before {@link peerSocketPath} keyed the name contended for on + * Windows: the storage path's hash alone. A broker still binds it, best-effort, + * and a window that won the keyed pipe checks it for an older build's broker + * before serving ({@link joinLegacyBroker}), so the two builds share one broker + * while both run. `null` off Windows, where the name never changed. */ -function socketPath(): string | null { - if (!context) return null; - const id = createHash('sha256') - .update(context.globalStorageUri.fsPath) - .digest('hex') - .slice(0, 12); - return process.platform === 'win32' - ? `\\\\.\\pipe\\dormouse-peer-${id}` - : join(peerDirPath(), `${id}.sock`); +export function legacyPeerPipePath( + storagePath: string, + platform: NodeJS.Platform = process.platform, +): string | null { + if (platform !== 'win32') return null; + const id = createHash('sha256').update(storagePath).digest('hex').slice(0, 12); + return `\\\\.\\pipe\\dormouse-peer-${id}`; } /** @@ -303,6 +328,14 @@ let serverToken: string | null = null; * closing it (see `tryBind`). */ let boundSocketFile: SocketFileIdentity | null = null; +/** The path `server` is bound to, claimed and cleared with it. */ +let boundPath: string | null = null; +/** + * Windows only: the same server's second listener, on the name older builds + * contend for ({@link legacyPeerPipePath}), so an older window dials this + * broker rather than electing its own. Opened and closed with `server`. + */ +let legacyServer: Server | null = null; const clients = new Set(); /** Provider-local route handle → the peer window that owns it. */ const routes = new Map(); @@ -769,9 +802,9 @@ export function listenServer(nextServer: Server, path: string): Promise { }); } -/** Take the socket path, or report that somebody else holds it. */ -async function tryBind(path: string, token: string): Promise { - const nextServer = createServer((socket) => { +/** A listener that challenges and then serves every window that connects. */ +function createPeerServer(): Server { + return createServer((socket) => { const client: PeerLinkClient = { socket, decoder: new FrameDecoder(), @@ -795,6 +828,11 @@ async function tryBind(path: string, token: string): Promise { // of the token must not volunteer one into whatever bound this path. send(client, { kind: 'challenge', nonce: client.challenge }); }); +} + +/** Take the socket path, or report that somebody else holds it. */ +async function tryBind(path: string, token: string): Promise { + const nextServer = createPeerServer(); try { await listenServer(nextServer, path); } catch { @@ -805,6 +843,7 @@ async function tryBind(path: string, token: string): Promise { return false; } server = nextServer; + boundPath = path; // Synchronous, and before the first `await` past the bind: `listen` binds // inside the call and resolves on a nextTick, so no thread-pool callback of // *this* process can interleave. A competing window is a separate extension @@ -1168,8 +1207,8 @@ function settle(broker: boolean): void { * mid-startup) and the loop retries. */ async function attempt(): Promise { - const path = socketPath(); - if (!path) return false; + if (!context) return false; + const storagePath = context.globalStorageUri.fsPath; if (!(await peerDirIsSafe())) { log.error( `[peer-link] ${peerDirPath()} is not a private directory of this user; the peer link is off`, @@ -1196,6 +1235,7 @@ async function attempt(): Promise { settle(false); return true; } + const path = peerSocketPath(storagePath, token); if (await tryBind(path, token)) { // Disposal can land inside any of the awaits above; a socket bound after it @@ -1204,6 +1244,11 @@ async function attempt(): Promise { await closeServer(true); return true; } + if (await joinLegacyBroker(storagePath, token)) return true; + if (disposed) { + await closeServer(true); + return true; + } log.info('[peer-link] serving peers'); // Nothing can displace an uncontested bind, so it is believed immediately. brokerConfirmed = true; @@ -1232,6 +1277,11 @@ async function attempt(): Promise { return true; } if (await stillOurs(path)) { + if (await joinLegacyBroker(storagePath, token)) return true; + if (disposed) { + await closeServer(true); + return true; + } log.info('[peer-link] took over a socket its broker left behind'); // Only now: until the verification returns, this window may still be // the one that stands down ({@link brokerConfirmed}). @@ -1258,6 +1308,39 @@ async function attempt(): Promise { return false; } +/** + * Windows only, between winning the keyed pipe and serving on it: hold the name + * older builds contend for as well, or, when an older build's broker already + * holds it, stand down and become that broker's client. Either way the two + * builds share one broker — two would both connect `/ws/burrow` and displace + * each other on the Relay. A holder that cannot prove the token is a squatter, + * and the keyed pipe serves regardless. Returns whether this window stood down. + * + * Still unconfirmed here ({@link brokerConfirmed}), so standing down is not a + * role flipping downward. + */ +async function joinLegacyBroker(storagePath: string, token: string): Promise { + const path = legacyPeerPipePath(storagePath); + if (!path) return false; + const nextServer = createPeerServer(); + try { + await listenServer(nextServer, path); + if (server) legacyServer = nextServer; + else nextServer.close(() => {}); + return false; + } catch { + // The same callback form as `tryBind`, for the same reason. + nextServer.close(() => {}); + } + if ((await tryConnect(path, token)) !== 'connected') return false; + log.info('[peer-link] an older build of this extension holds the Burrow; joined it'); + await closeServer(false); + // The same as `attempt`'s own connect: nobody is left to close it. + if (disposed) disconnectClient(); + else settle(false); + return true; +} + /** How long to let a competing reclaim land before believing we won it. */ const RECLAIM_VERIFY_MS = 250; /** Spread over which a stampede of orphaned clients clears one corpse. */ @@ -1348,11 +1431,14 @@ async function closeServer(unlink: boolean): Promise { brokerConfirmed = false; serverToken = null; boundSocketFile = null; + const path = boundPath; + boundPath = null; + if (legacyServer?.listening) legacyServer.close(); + legacyServer = null; for (const peer of [...clients]) dropClient(peer); if (!closing) return; if (closing.listening) closing.close(); if (!unlink) return; - const path = socketPath(); if (path) await rm(path, { force: true }).catch(() => {}); } diff --git a/vscode-ext/test/helpers.ts b/vscode-ext/test/helpers.ts index b50711b99..f23de7473 100644 --- a/vscode-ext/test/helpers.ts +++ b/vscode-ext/test/helpers.ts @@ -5,7 +5,8 @@ */ import { vi } from 'vitest'; -import { createHash } from 'node:crypto'; +import { createHash, createHmac } from 'node:crypto'; +import { readFileSync } from 'node:fs'; import { access, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -26,16 +27,20 @@ export async function tempStorageDir(): Promise { /** * The one path every window of an installation contends for, mirroring - * `socketPath()` and `peerDirPath()`. Duplicated on purpose: a derivation that - * drifted would silently give each window its own lease and its own Burrow, and - * the per-user directory is the layer that keeps another OS user from creating - * the path first. + * `peerSocketPath()` and `peerDirPath()`. Duplicated on purpose: a derivation + * that drifted would silently give each window its own lease and its own + * Burrow, and the per-user directory (unix) or the token key (Windows) is the + * layer that keeps another OS user from creating the path first. On Windows the + * name needs the installation's token, so it must be written first. */ export function derivedSocketPath(storageDir: string): string { - const id = createHash('sha256').update(storageDir).digest('hex').slice(0, 12); - return process.platform === 'win32' - ? `\\\\.\\pipe\\dormouse-peer-${id}` - : join(tmpdir(), `dormouse-peer-${process.getuid?.() ?? 0}`, `${id}.sock`); + if (process.platform !== 'win32') { + const id = createHash('sha256').update(storageDir).digest('hex').slice(0, 12); + return join(tmpdir(), `dormouse-peer-${process.getuid?.() ?? 0}`, `${id}.sock`); + } + const token = readFileSync(join(storageDir, 'burrow.peer-token'), 'utf8').trim(); + const id = createHmac('sha256', token).update(storageDir).digest('hex').slice(0, 32); + return `\\\\.\\pipe\\dormouse-peer-${id}`; } export async function removeDir(dir: string): Promise { diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index d4767111b..3475547b8 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { spawn } from 'node:child_process'; -import { createHmac } from 'node:crypto'; +import { createHash, createHmac } from 'node:crypto'; import { access, chmod, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { createConnection, createServer, Socket, type Server } from 'node:net'; import { dirname, join } from 'node:path'; @@ -173,6 +173,7 @@ describe('bind-as-lease', () => { it.skipIf(process.platform !== 'win32')('reclaims a dead named pipe and elects exactly one broker for racing windows', async () => { // Windows removes the pipe when its process dies; there is no Unix corpse // inode to unlink. Exercise the actual transport, not a fake socket path. + await writeFile(join(dir, 'burrow.peer-token'), 'test-peer-token', { mode: 0o600 }); const corpse = spawn(process.execPath, ['-e', "require('node:net').createServer().listen(process.argv[1], () => process.send('listening'))", derivedSocketPath()], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }); @@ -222,6 +223,9 @@ describe('bind-as-lease', () => { // libuv callback with nothing to catch it. const mod = await openWindow(fakeWindow()); const server = createServer(); + if (process.platform === 'win32') { + await writeFile(join(dir, 'burrow.peer-token'), 'test-peer-token', { mode: 0o600 }); + } const path = derivedSocketPath(); if (process.platform !== 'win32') await mkdir(dirname(path), { recursive: true, mode: 0o700 }); await mod.listenServer(server, path); @@ -1086,6 +1090,118 @@ describe('bind-as-lease', () => { * so anything on the machine can compute it; these are the properties that make * knowing it worthless. */ +describe('the contended path', () => { + it('keys the Windows pipe name by the shared token, so the storage path alone cannot name it', async () => { + const mod = await openWindow(fakeWindow()); + const keyed = mod.peerSocketPath(dir, 'token-a', 'win32'); + expect(keyed).toMatch(/^\\\\\.\\pipe\\dormouse-peer-[0-9a-f]{32}$/); + expect(mod.peerSocketPath(dir, 'token-a', 'win32')).toBe(keyed); + expect(mod.peerSocketPath(dir, 'token-b', 'win32')).not.toBe(keyed); + // What any local user can compute from the storage path is not it. + const pathOnly = createHash('sha256').update(dir).digest('hex').slice(0, 12); + expect(keyed).not.toContain(pathOnly); + expect(mod.legacyPeerPipePath(dir, 'win32')).toBe(`\\\\.\\pipe\\dormouse-peer-${pathOnly}`); + }); + + it('names the unix socket from the storage path alone, under the per-user directory', async () => { + const mod = await openWindow(fakeWindow()); + expect(mod.peerSocketPath(dir, 'token-a', 'linux')).toBe(mod.peerSocketPath(dir, 'token-b', 'linux')); + expect(mod.legacyPeerPipePath(dir, 'linux')).toBeNull(); + if (process.platform !== 'win32') { + expect(mod.peerSocketPath(dir, 'token-a', process.platform)).toBe(derivedSocketPath()); + } + }); + + /** A process holding the path-only pipe that an older build would contend for. */ + async function holdLegacyPipe(onSocket: (socket: Socket) => void): Promise<() => Promise> { + const mod = await openWindow(fakeWindow()); + const path = mod.legacyPeerPipePath(dir); + if (!path) throw new Error('only Windows has a legacy pipe'); + const live = new Set(); + const server = createServer((socket) => { + live.add(socket); + socket.on('close', () => live.delete(socket)); + onSocket(socket); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(path, () => resolve()); + }); + await mod.disposePeerLink(); + return async () => { + for (const socket of live) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + }; + } + + it.skipIf(process.platform !== 'win32')('brokers on the keyed pipe while a squatter holds the path-only name', async () => { + const release = await holdLegacyPipe((socket) => socket.destroy()); + try { + const { broker, peer } = await linkedPair(); + expect(broker.isPeerBroker()).toBe(true); + expect(peer.isPeerLinkSettled()).toBe(true); + } finally { + await release(); + } + }); + + it.skipIf(process.platform !== 'win32')('joins an older build\'s broker on the path-only name rather than electing a second', async () => { + const token = 'test-peer-token'; + await writeFile(join(dir, 'burrow.peer-token'), token, { mode: 0o600 }); + let welcomed = 0; + const release = await holdLegacyPipe((socket) => { + const decoder = new FrameDecoder(); + socket.setEncoding('utf8'); + socket.write(encodeFrame({ kind: 'challenge', nonce: 'old-broker' })); + socket.on('data', (chunk: string) => { + for (const frame of decoder.push(chunk)) { + const message = frame as { kind?: string; nonce?: string }; + if (message.kind !== 'hello') continue; + welcomed += 1; + socket.write(encodeFrame({ kind: 'welcome', proof: proof(token, PEER_SERVER_PROOF_DOMAIN, String(message.nonce)) })); + } + }); + }); + try { + const roles: boolean[] = []; + const mod = await openWindow(fakeWindow()); + await mod.ensurePeerNet((held) => roles.push(held)); + expect(roles).toEqual([]); + expect(mod.isPeerBroker()).toBe(false); + expect(mod.isPeerLinkSettled()).toBe(true); + expect(welcomed).toBe(1); + } finally { + await release(); + } + }); + + it.skipIf(process.platform !== 'win32')('answers an older build dialing the path-only name', async () => { + const { broker } = await linkedPair(); + const path = broker.legacyPeerPipePath(dir); + if (!path) throw new Error('only Windows has a legacy pipe'); + const token = await readToken(); + const socket = createConnection({ path }); + const decoder = new FrameDecoder(); + const nonce = 'older-window'; + const welcome = await new Promise<{ kind?: string; proof?: string }>((resolve, reject) => { + socket.setEncoding('utf8'); + socket.once('error', reject); + socket.on('data', (chunk: string) => { + for (const frame of decoder.push(chunk)) { + const message = frame as { kind?: string; nonce?: string; proof?: string }; + if (message.kind === 'challenge') { + socket.write(encodeFrame({ kind: 'hello', nonce, proof: proof(token, PEER_CLIENT_PROOF_DOMAIN, String(message.nonce)) })); + } else { + resolve(message); + } + } + }); + }); + socket.destroy(); + expect(welcome).toEqual({ kind: 'welcome', proof: proof(token, PEER_SERVER_PROOF_DOMAIN, nonce) }); + }); +}); + describe('peer handshake', () => { /** Read one frame at a time off a raw socket. */ function frameReader(socket: import('node:net').Socket) { From 20b038c80ca6f8b6680a42920347641778f6cc4e Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 20 Oct 2026 08:29:39 -0700 Subject: [PATCH 2/4] Record the Windows peer-link pipe squat as not defended, and keep the path-only name Windows lists every pipe to every account, so a token-keyed name is secret only until a broker first binds it, and from then on it is fixed. It adds a second listener for older builds without closing the squat. Restore the path-only name and record the squat next to the `dor` pipe entry in security.md "What is not defended". Co-Authored-By: Claude Opus 5.5 --- docs/specs/security.md | 1 + docs/specs/vscode.md | 6 +- docs/specs/vscode.rationale.md | 4 - scripts/spec-word-budgets.json | 4 +- vscode-ext/src/peer-link.ts | 124 +++++------------------------- vscode-ext/test/helpers.ts | 23 +++--- vscode-ext/test/peer-link.test.ts | 118 +--------------------------- 7 files changed, 35 insertions(+), 245 deletions(-) diff --git a/docs/specs/security.md b/docs/specs/security.md index 76ffbf2a7..5ff2e98a7 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -44,6 +44,7 @@ Source of truth: root scripts in `package.json`; native jobs in `.github/workflo - **A process running as you.** `dor`, its socket, and every file mode bound other local accounts, never a program already running under your own account; an agent holding `dor` has exactly the power of the person at the keyboard, and the Tool trust prompt is no boundary against it ([The dor control socket](./security-local.md#the-dor-control-socket)). - **The Windows `dor` pipe carries no ACL of ours.** A named pipe has no directory to harden, so an unguessable name and the token handshake are the whole of it ([The dor control socket](./security-local.md#the-dor-control-socket)). +- **Another Windows account can take the VS Code peer-link pipe's name first.** Every window must bind the same name, so it is derived rather than random, and Windows lists every pipe to every account; a pipe created under that name first keeps VS Code's remote control and peer surfaces down, though the token handshake serves it nothing ([Persisted state](./security-local.md#persisted-state)). - **A local HTML or SVG document you open, or confirm from a document link, can send its contents off the machine.** The file viewer's content policy confines what the page loads, not where its scripts navigate ([Local-file viewer](./security-local.md#local-file-viewer)). - **A mermaid sanitizer bypass in a Markdown document you open.** Its diagrams reach the editor page through mermaid's strict sanitizer, not the editor's allowlist, so a bypass would run script holding the editor's save, image, and document-open routes; an open of a page that runs its own scripts still waits on Dormouse's own dialog ([Local-file viewer](./security-local.md#local-file-viewer), [Terminal output](./security-local.md#terminal-output)). - **A Tool trust grant on an upstream URL trusts the URL a checkout claims.** Any directory whose own `.git/config` claims an already-granted upstream shares that grant; a folder-only grant is not shared ([Dor Tool configuration](./security-local.md#dor-tool-configuration)). diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index 0938d7fe8..dfb63385f 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -135,16 +135,16 @@ The shared `BurrowService` (`docs/specs/relay.md` → "Burrow side", which owns Source of truth: `VsCodeBurrowStateStore` in `vscode-ext/src/burrow-store.ts`, `markOneTimeServing` and `contendIfServing` in `vscode-ext/src/burrow.ts`. -**Which window: bind-as-lease.** **The bind is the lease**, so the windows' extension hosts never fight over the enrollment's one `/ws/burrow` socket (rationale). Every contending window binds one fixed path, so every window, whatever its build, computes the same one: on unix `.sock` in a per-user `dormouse-peer-` directory in the temp dir, **the hash the first 12 hex characters of SHA-256 over `context.globalStorageUri.fsPath`** (hashed for the platform path cap; rationale); on Windows `\\.\pipe\dormouse-peer-`, **the key the first 32 hex characters of HMAC-SHA256 over that path, keyed by the peer token**, so another local user cannot name the machine-wide pipe before a window binds it (rationale). **A Windows broker also listens on the pipe builds before the key bound, `dormouse-peer-`, and a window that wins the keyed pipe while an older build's broker holds that name stands down and becomes its client**, so the two builds share one broker while both run (`joinLegacyBroker`). The winner is the broker and runs the service; every other window connects as a client once the broker's welcome verifies. +**Which window: bind-as-lease.** **The bind is the lease**, so the windows' extension hosts never fight over the enrollment's one `/ws/burrow` socket (rationale). Every contending window binds one fixed path — `.sock` in a per-user `dormouse-peer-` directory in the temp dir, or `\\.\pipe\dormouse-peer-` on Windows — **the hash is the first 12 hex characters of SHA-256 over `context.globalStorageUri.fsPath`**, so every window, whatever its build, computes the same path (hashed for the platform path cap; rationale). The winner is the broker and runs the service; every other window connects as a client once the broker's welcome verifies. - **Roles never flip downward**: no `onRole(false)` after a `true` (rationale). - **Contend on broker death, not on a timer**: `bind` picks exactly one; no TTL, heartbeat file, or watcher. - **Never unlink on the first refusal; a reclaimed bind is re-checked (`stillOurs`) before it serves** (rationale). - **A bind is not a role until it is believed**: **an unverified bind reads as unsettled**, and a command landing mid-verification is held for the verdict (rationale). -**Trust.** The unix socket path is derived, not secret, so two layers stand between it and this installation's terminals. +**Trust.** The socket path is derived, not secret, so two layers stand between it and this installation's terminals. -*The directory.* On unix every bind and connect is held to `peerDirIsSafe()`, the predicate the `dor` control socket uses (`docs/specs/security-local.md` → "The dor control socket"). A loose directory we own is tightened; anything else is somebody else's and **the peer link stands down for good**, releasing the callers waiting on the contention. Windows has no such directory and skips this layer; the token key in the pipe's name stands in for it, and an older build's path-only name keeps only the handshake. +*The directory.* On unix every bind and connect is held to `peerDirIsSafe()`, the predicate the `dor` control socket uses (`docs/specs/security-local.md` → "The dor control socket"). A loose directory we own is tightened; anything else is somebody else's and **the peer link stands down for good**, releasing the callers waiting on the contention. Windows pipes have no directory and skip this layer (`docs/specs/security.md` -> "What is not defended"). *The handshake.* The shared secret is a mode-0600 `burrow.peer-token` in `globalStorageUri`, **created once with an exclusive `wx` write rather than a rename** so two windows starting together agree on one token. **Treat an empty read as *not yet written*, never as the token**, waiting it out with a bounded retry (rationale); **exhausting that wait latches the same permanent stand-down** as an unsafe directory. The token **never crosses the socket**; three frames prove mutual knowledge of it: diff --git a/docs/specs/vscode.rationale.md b/docs/specs/vscode.rationale.md index c7eb323f1..2cc011d45 100644 --- a/docs/specs/vscode.rationale.md +++ b/docs/specs/vscode.rationale.md @@ -48,10 +48,6 @@ **Why the socket path is hashed.** macOS caps a unix socket path near 104 bytes, and `context.globalStorageUri.fsPath` is most of that on its own — joining a name onto it overflows. -**Why the Windows pipe name is keyed by the token.** The pipe namespace is machine-wide, and `peerDirIsSafe` has no directory to check there, so a name derived from the storage path alone is one another local user can compute and create first. The handshake still keeps them from being served, but every window's bind then fails and the peer link and remote control stay down. Keyed by the token, the name is known only to processes that can read `burrow.peer-token`, which sits under the user's profile. - -**Why a Windows broker still answers the path-only name.** An extension update reaches each VS Code window only when that window restarts its extension host, so old and new builds run side by side. Without the shared name each would elect its own broker, and two brokers displace each other on the Relay (above). The path-only name stays squattable, which leaves an older build where it was; the keyed pipe serves regardless. - **Why the enrollment is memoized.** `SecretStorage` is a keychain round trip, and both the activation probe and the service want the same answer. **Why the memo must be dropped on any window's change.** Without it a window promoted to broker could resurrect an enrollment another window had cleared, or never see one another window had just created — `SecretStorage` is shared across all of an extension's windows. diff --git a/scripts/spec-word-budgets.json b/scripts/spec-word-budgets.json index 93771c402..e27043192 100644 --- a/scripts/spec-word-budgets.json +++ b/scripts/spec-word-budgets.json @@ -30,7 +30,7 @@ "docs/specs/security-local.md": 5900, "docs/specs/security-remote.md": 7050, "docs/specs/security-supply-chain.md": 1650, - "docs/specs/security.md": 2600, + "docs/specs/security.md": 2650, "docs/specs/shortcuts.md": 1150, "docs/specs/standalone.md": 6450, "docs/specs/terminal-context.md": 950, @@ -40,7 +40,7 @@ "docs/specs/tiling-engine.md": 2500, "docs/specs/transport.md": 4450, "docs/specs/tutorial.md": 1750, - "docs/specs/vscode.md": 4400, + "docs/specs/vscode.md": 4300, "docs/specs/webgl-text.md": 750, "docs/specs/website-docs.md": 3100 } diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index b5919f64a..16c06bbef 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -7,7 +7,7 @@ import { chmod, lstat, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { statSync, type BigIntStats } from 'node:fs'; -import { createHash, createHmac, randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { createConnection, createServer, type Server, type Socket } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -181,44 +181,19 @@ async function peerDirIsSafe(): Promise { * The one path every window of this installation contends for. * * Hashed rather than joined: macOS caps a unix socket path near 104 bytes and - * the extension's globalStorage path is most of that on its own. Derived rather - * than random precisely because it must be *the same* in every window — the - * bind is the arbitration. - * - * On unix the per-user directory ({@link peerDirIsSafe}) keeps another user from - * creating the path first, so the storage path alone names it. Windows has no - * such directory: the pipe namespace is machine-wide, and a name any local user - * can compute is one they can take before we bind it. So there the name is an - * HMAC over the storage path keyed by the shared token, which only a process - * that can read {@link TOKEN_FILE} can produce. - */ -export function peerSocketPath( - storagePath: string, - token: string, - platform: NodeJS.Platform = process.platform, -): string { - if (platform !== 'win32') { - const id = createHash('sha256').update(storagePath).digest('hex').slice(0, 12); - return join(peerDirPath(), `${id}.sock`); - } - const id = createHmac('sha256', token).update(storagePath).digest('hex').slice(0, 32); - return `\\\\.\\pipe\\dormouse-peer-${id}`; -} - -/** - * The pipe builds before {@link peerSocketPath} keyed the name contended for on - * Windows: the storage path's hash alone. A broker still binds it, best-effort, - * and a window that won the keyed pipe checks it for an older build's broker - * before serving ({@link joinLegacyBroker}), so the two builds share one broker - * while both run. `null` off Windows, where the name never changed. + * the extension's globalStorage path is most of that on its own. Derived from + * that path rather than random precisely because it must be *the same* in every + * window — the bind is the arbitration. */ -export function legacyPeerPipePath( - storagePath: string, - platform: NodeJS.Platform = process.platform, -): string | null { - if (platform !== 'win32') return null; - const id = createHash('sha256').update(storagePath).digest('hex').slice(0, 12); - return `\\\\.\\pipe\\dormouse-peer-${id}`; +function socketPath(): string | null { + if (!context) return null; + const id = createHash('sha256') + .update(context.globalStorageUri.fsPath) + .digest('hex') + .slice(0, 12); + return process.platform === 'win32' + ? `\\\\.\\pipe\\dormouse-peer-${id}` + : join(peerDirPath(), `${id}.sock`); } /** @@ -328,14 +303,6 @@ let serverToken: string | null = null; * closing it (see `tryBind`). */ let boundSocketFile: SocketFileIdentity | null = null; -/** The path `server` is bound to, claimed and cleared with it. */ -let boundPath: string | null = null; -/** - * Windows only: the same server's second listener, on the name older builds - * contend for ({@link legacyPeerPipePath}), so an older window dials this - * broker rather than electing its own. Opened and closed with `server`. - */ -let legacyServer: Server | null = null; const clients = new Set(); /** Provider-local route handle → the peer window that owns it. */ const routes = new Map(); @@ -802,9 +769,9 @@ export function listenServer(nextServer: Server, path: string): Promise { }); } -/** A listener that challenges and then serves every window that connects. */ -function createPeerServer(): Server { - return createServer((socket) => { +/** Take the socket path, or report that somebody else holds it. */ +async function tryBind(path: string, token: string): Promise { + const nextServer = createServer((socket) => { const client: PeerLinkClient = { socket, decoder: new FrameDecoder(), @@ -828,11 +795,6 @@ function createPeerServer(): Server { // of the token must not volunteer one into whatever bound this path. send(client, { kind: 'challenge', nonce: client.challenge }); }); -} - -/** Take the socket path, or report that somebody else holds it. */ -async function tryBind(path: string, token: string): Promise { - const nextServer = createPeerServer(); try { await listenServer(nextServer, path); } catch { @@ -843,7 +805,6 @@ async function tryBind(path: string, token: string): Promise { return false; } server = nextServer; - boundPath = path; // Synchronous, and before the first `await` past the bind: `listen` binds // inside the call and resolves on a nextTick, so no thread-pool callback of // *this* process can interleave. A competing window is a separate extension @@ -1207,8 +1168,8 @@ function settle(broker: boolean): void { * mid-startup) and the loop retries. */ async function attempt(): Promise { - if (!context) return false; - const storagePath = context.globalStorageUri.fsPath; + const path = socketPath(); + if (!path) return false; if (!(await peerDirIsSafe())) { log.error( `[peer-link] ${peerDirPath()} is not a private directory of this user; the peer link is off`, @@ -1235,7 +1196,6 @@ async function attempt(): Promise { settle(false); return true; } - const path = peerSocketPath(storagePath, token); if (await tryBind(path, token)) { // Disposal can land inside any of the awaits above; a socket bound after it @@ -1244,11 +1204,6 @@ async function attempt(): Promise { await closeServer(true); return true; } - if (await joinLegacyBroker(storagePath, token)) return true; - if (disposed) { - await closeServer(true); - return true; - } log.info('[peer-link] serving peers'); // Nothing can displace an uncontested bind, so it is believed immediately. brokerConfirmed = true; @@ -1277,11 +1232,6 @@ async function attempt(): Promise { return true; } if (await stillOurs(path)) { - if (await joinLegacyBroker(storagePath, token)) return true; - if (disposed) { - await closeServer(true); - return true; - } log.info('[peer-link] took over a socket its broker left behind'); // Only now: until the verification returns, this window may still be // the one that stands down ({@link brokerConfirmed}). @@ -1308,39 +1258,6 @@ async function attempt(): Promise { return false; } -/** - * Windows only, between winning the keyed pipe and serving on it: hold the name - * older builds contend for as well, or, when an older build's broker already - * holds it, stand down and become that broker's client. Either way the two - * builds share one broker — two would both connect `/ws/burrow` and displace - * each other on the Relay. A holder that cannot prove the token is a squatter, - * and the keyed pipe serves regardless. Returns whether this window stood down. - * - * Still unconfirmed here ({@link brokerConfirmed}), so standing down is not a - * role flipping downward. - */ -async function joinLegacyBroker(storagePath: string, token: string): Promise { - const path = legacyPeerPipePath(storagePath); - if (!path) return false; - const nextServer = createPeerServer(); - try { - await listenServer(nextServer, path); - if (server) legacyServer = nextServer; - else nextServer.close(() => {}); - return false; - } catch { - // The same callback form as `tryBind`, for the same reason. - nextServer.close(() => {}); - } - if ((await tryConnect(path, token)) !== 'connected') return false; - log.info('[peer-link] an older build of this extension holds the Burrow; joined it'); - await closeServer(false); - // The same as `attempt`'s own connect: nobody is left to close it. - if (disposed) disconnectClient(); - else settle(false); - return true; -} - /** How long to let a competing reclaim land before believing we won it. */ const RECLAIM_VERIFY_MS = 250; /** Spread over which a stampede of orphaned clients clears one corpse. */ @@ -1431,14 +1348,11 @@ async function closeServer(unlink: boolean): Promise { brokerConfirmed = false; serverToken = null; boundSocketFile = null; - const path = boundPath; - boundPath = null; - if (legacyServer?.listening) legacyServer.close(); - legacyServer = null; for (const peer of [...clients]) dropClient(peer); if (!closing) return; if (closing.listening) closing.close(); if (!unlink) return; + const path = socketPath(); if (path) await rm(path, { force: true }).catch(() => {}); } diff --git a/vscode-ext/test/helpers.ts b/vscode-ext/test/helpers.ts index f23de7473..b50711b99 100644 --- a/vscode-ext/test/helpers.ts +++ b/vscode-ext/test/helpers.ts @@ -5,8 +5,7 @@ */ import { vi } from 'vitest'; -import { createHash, createHmac } from 'node:crypto'; -import { readFileSync } from 'node:fs'; +import { createHash } from 'node:crypto'; import { access, mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -27,20 +26,16 @@ export async function tempStorageDir(): Promise { /** * The one path every window of an installation contends for, mirroring - * `peerSocketPath()` and `peerDirPath()`. Duplicated on purpose: a derivation - * that drifted would silently give each window its own lease and its own - * Burrow, and the per-user directory (unix) or the token key (Windows) is the - * layer that keeps another OS user from creating the path first. On Windows the - * name needs the installation's token, so it must be written first. + * `socketPath()` and `peerDirPath()`. Duplicated on purpose: a derivation that + * drifted would silently give each window its own lease and its own Burrow, and + * the per-user directory is the layer that keeps another OS user from creating + * the path first. */ export function derivedSocketPath(storageDir: string): string { - if (process.platform !== 'win32') { - const id = createHash('sha256').update(storageDir).digest('hex').slice(0, 12); - return join(tmpdir(), `dormouse-peer-${process.getuid?.() ?? 0}`, `${id}.sock`); - } - const token = readFileSync(join(storageDir, 'burrow.peer-token'), 'utf8').trim(); - const id = createHmac('sha256', token).update(storageDir).digest('hex').slice(0, 32); - return `\\\\.\\pipe\\dormouse-peer-${id}`; + const id = createHash('sha256').update(storageDir).digest('hex').slice(0, 12); + return process.platform === 'win32' + ? `\\\\.\\pipe\\dormouse-peer-${id}` + : join(tmpdir(), `dormouse-peer-${process.getuid?.() ?? 0}`, `${id}.sock`); } export async function removeDir(dir: string): Promise { diff --git a/vscode-ext/test/peer-link.test.ts b/vscode-ext/test/peer-link.test.ts index 3475547b8..d4767111b 100644 --- a/vscode-ext/test/peer-link.test.ts +++ b/vscode-ext/test/peer-link.test.ts @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { spawn } from 'node:child_process'; -import { createHash, createHmac } from 'node:crypto'; +import { createHmac } from 'node:crypto'; import { access, chmod, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'; import { createConnection, createServer, Socket, type Server } from 'node:net'; import { dirname, join } from 'node:path'; @@ -173,7 +173,6 @@ describe('bind-as-lease', () => { it.skipIf(process.platform !== 'win32')('reclaims a dead named pipe and elects exactly one broker for racing windows', async () => { // Windows removes the pipe when its process dies; there is no Unix corpse // inode to unlink. Exercise the actual transport, not a fake socket path. - await writeFile(join(dir, 'burrow.peer-token'), 'test-peer-token', { mode: 0o600 }); const corpse = spawn(process.execPath, ['-e', "require('node:net').createServer().listen(process.argv[1], () => process.send('listening'))", derivedSocketPath()], { stdio: ['ignore', 'ignore', 'pipe', 'ipc'] }); @@ -223,9 +222,6 @@ describe('bind-as-lease', () => { // libuv callback with nothing to catch it. const mod = await openWindow(fakeWindow()); const server = createServer(); - if (process.platform === 'win32') { - await writeFile(join(dir, 'burrow.peer-token'), 'test-peer-token', { mode: 0o600 }); - } const path = derivedSocketPath(); if (process.platform !== 'win32') await mkdir(dirname(path), { recursive: true, mode: 0o700 }); await mod.listenServer(server, path); @@ -1090,118 +1086,6 @@ describe('bind-as-lease', () => { * so anything on the machine can compute it; these are the properties that make * knowing it worthless. */ -describe('the contended path', () => { - it('keys the Windows pipe name by the shared token, so the storage path alone cannot name it', async () => { - const mod = await openWindow(fakeWindow()); - const keyed = mod.peerSocketPath(dir, 'token-a', 'win32'); - expect(keyed).toMatch(/^\\\\\.\\pipe\\dormouse-peer-[0-9a-f]{32}$/); - expect(mod.peerSocketPath(dir, 'token-a', 'win32')).toBe(keyed); - expect(mod.peerSocketPath(dir, 'token-b', 'win32')).not.toBe(keyed); - // What any local user can compute from the storage path is not it. - const pathOnly = createHash('sha256').update(dir).digest('hex').slice(0, 12); - expect(keyed).not.toContain(pathOnly); - expect(mod.legacyPeerPipePath(dir, 'win32')).toBe(`\\\\.\\pipe\\dormouse-peer-${pathOnly}`); - }); - - it('names the unix socket from the storage path alone, under the per-user directory', async () => { - const mod = await openWindow(fakeWindow()); - expect(mod.peerSocketPath(dir, 'token-a', 'linux')).toBe(mod.peerSocketPath(dir, 'token-b', 'linux')); - expect(mod.legacyPeerPipePath(dir, 'linux')).toBeNull(); - if (process.platform !== 'win32') { - expect(mod.peerSocketPath(dir, 'token-a', process.platform)).toBe(derivedSocketPath()); - } - }); - - /** A process holding the path-only pipe that an older build would contend for. */ - async function holdLegacyPipe(onSocket: (socket: Socket) => void): Promise<() => Promise> { - const mod = await openWindow(fakeWindow()); - const path = mod.legacyPeerPipePath(dir); - if (!path) throw new Error('only Windows has a legacy pipe'); - const live = new Set(); - const server = createServer((socket) => { - live.add(socket); - socket.on('close', () => live.delete(socket)); - onSocket(socket); - }); - await new Promise((resolve, reject) => { - server.once('error', reject); - server.listen(path, () => resolve()); - }); - await mod.disposePeerLink(); - return async () => { - for (const socket of live) socket.destroy(); - await new Promise((resolve) => server.close(() => resolve())); - }; - } - - it.skipIf(process.platform !== 'win32')('brokers on the keyed pipe while a squatter holds the path-only name', async () => { - const release = await holdLegacyPipe((socket) => socket.destroy()); - try { - const { broker, peer } = await linkedPair(); - expect(broker.isPeerBroker()).toBe(true); - expect(peer.isPeerLinkSettled()).toBe(true); - } finally { - await release(); - } - }); - - it.skipIf(process.platform !== 'win32')('joins an older build\'s broker on the path-only name rather than electing a second', async () => { - const token = 'test-peer-token'; - await writeFile(join(dir, 'burrow.peer-token'), token, { mode: 0o600 }); - let welcomed = 0; - const release = await holdLegacyPipe((socket) => { - const decoder = new FrameDecoder(); - socket.setEncoding('utf8'); - socket.write(encodeFrame({ kind: 'challenge', nonce: 'old-broker' })); - socket.on('data', (chunk: string) => { - for (const frame of decoder.push(chunk)) { - const message = frame as { kind?: string; nonce?: string }; - if (message.kind !== 'hello') continue; - welcomed += 1; - socket.write(encodeFrame({ kind: 'welcome', proof: proof(token, PEER_SERVER_PROOF_DOMAIN, String(message.nonce)) })); - } - }); - }); - try { - const roles: boolean[] = []; - const mod = await openWindow(fakeWindow()); - await mod.ensurePeerNet((held) => roles.push(held)); - expect(roles).toEqual([]); - expect(mod.isPeerBroker()).toBe(false); - expect(mod.isPeerLinkSettled()).toBe(true); - expect(welcomed).toBe(1); - } finally { - await release(); - } - }); - - it.skipIf(process.platform !== 'win32')('answers an older build dialing the path-only name', async () => { - const { broker } = await linkedPair(); - const path = broker.legacyPeerPipePath(dir); - if (!path) throw new Error('only Windows has a legacy pipe'); - const token = await readToken(); - const socket = createConnection({ path }); - const decoder = new FrameDecoder(); - const nonce = 'older-window'; - const welcome = await new Promise<{ kind?: string; proof?: string }>((resolve, reject) => { - socket.setEncoding('utf8'); - socket.once('error', reject); - socket.on('data', (chunk: string) => { - for (const frame of decoder.push(chunk)) { - const message = frame as { kind?: string; nonce?: string; proof?: string }; - if (message.kind === 'challenge') { - socket.write(encodeFrame({ kind: 'hello', nonce, proof: proof(token, PEER_CLIENT_PROOF_DOMAIN, String(message.nonce)) })); - } else { - resolve(message); - } - } - }); - }); - socket.destroy(); - expect(welcome).toEqual({ kind: 'welcome', proof: proof(token, PEER_SERVER_PROOF_DOMAIN, nonce) }); - }); -}); - describe('peer handshake', () => { /** Read one frame at a time off a raw socket. */ function frameReader(socket: import('node:net').Socket) { From 6f43d37a6716cb4d1785ce5eb3af81494b11a349 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 20 Oct 2026 09:05:02 -0700 Subject: [PATCH 3/4] Tighten the peer-link pipe entry and match the arrow style around it Co-Authored-By: Claude Opus 5.5 --- docs/specs/security.md | 2 +- docs/specs/vscode.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/specs/security.md b/docs/specs/security.md index 5ff2e98a7..e89032295 100644 --- a/docs/specs/security.md +++ b/docs/specs/security.md @@ -44,7 +44,7 @@ Source of truth: root scripts in `package.json`; native jobs in `.github/workflo - **A process running as you.** `dor`, its socket, and every file mode bound other local accounts, never a program already running under your own account; an agent holding `dor` has exactly the power of the person at the keyboard, and the Tool trust prompt is no boundary against it ([The dor control socket](./security-local.md#the-dor-control-socket)). - **The Windows `dor` pipe carries no ACL of ours.** A named pipe has no directory to harden, so an unguessable name and the token handshake are the whole of it ([The dor control socket](./security-local.md#the-dor-control-socket)). -- **Another Windows account can take the VS Code peer-link pipe's name first.** Every window must bind the same name, so it is derived rather than random, and Windows lists every pipe to every account; a pipe created under that name first keeps VS Code's remote control and peer surfaces down, though the token handshake serves it nothing ([Persisted state](./security-local.md#persisted-state)). +- **Another Windows account can take the VS Code peer-link pipe's name first.** Every window must bind the same name, so it is derived, not random, and Windows lists every pipe to every account; whoever creates it first keeps remote control and peer surfaces down, though the token handshake serves them nothing ([Persisted state](./security-local.md#persisted-state)). - **A local HTML or SVG document you open, or confirm from a document link, can send its contents off the machine.** The file viewer's content policy confines what the page loads, not where its scripts navigate ([Local-file viewer](./security-local.md#local-file-viewer)). - **A mermaid sanitizer bypass in a Markdown document you open.** Its diagrams reach the editor page through mermaid's strict sanitizer, not the editor's allowlist, so a bypass would run script holding the editor's save, image, and document-open routes; an open of a page that runs its own scripts still waits on Dormouse's own dialog ([Local-file viewer](./security-local.md#local-file-viewer), [Terminal output](./security-local.md#terminal-output)). - **A Tool trust grant on an upstream URL trusts the URL a checkout claims.** Any directory whose own `.git/config` claims an already-granted upstream shares that grant; a folder-only grant is not shared ([Dor Tool configuration](./security-local.md#dor-tool-configuration)). diff --git a/docs/specs/vscode.md b/docs/specs/vscode.md index dfb63385f..1d8e462f2 100644 --- a/docs/specs/vscode.md +++ b/docs/specs/vscode.md @@ -144,7 +144,7 @@ Source of truth: `VsCodeBurrowStateStore` in `vscode-ext/src/burrow-store.ts`, ` **Trust.** The socket path is derived, not secret, so two layers stand between it and this installation's terminals. -*The directory.* On unix every bind and connect is held to `peerDirIsSafe()`, the predicate the `dor` control socket uses (`docs/specs/security-local.md` → "The dor control socket"). A loose directory we own is tightened; anything else is somebody else's and **the peer link stands down for good**, releasing the callers waiting on the contention. Windows pipes have no directory and skip this layer (`docs/specs/security.md` -> "What is not defended"). +*The directory.* On unix every bind and connect is held to `peerDirIsSafe()`, the predicate the `dor` control socket uses (`docs/specs/security-local.md` → "The dor control socket"). A loose directory we own is tightened; anything else is somebody else's and **the peer link stands down for good**, releasing the callers waiting on the contention. Windows pipes have no directory and skip this layer (`docs/specs/security.md` → "What is not defended"). *The handshake.* The shared secret is a mode-0600 `burrow.peer-token` in `globalStorageUri`, **created once with an exclusive `wx` write rather than a rename** so two windows starting together agree on one token. **Treat an empty read as *not yet written*, never as the token**, waiting it out with a bounded retry (rationale); **exhausting that wait latches the same permanent stand-down** as an unsafe directory. The token **never crosses the socket**; three frames prove mutual knowledge of it: From 6ba37aeec9ca79b27e247a16e079f368877c6605 Mon Sep 17 00:00:00 2001 From: Ned Twigg Date: Tue, 20 Oct 2026 09:19:27 -0700 Subject: [PATCH 4/4] Say what the Windows skip in peerDirIsSafe leaves open Co-Authored-By: Claude Opus 5.5 --- vscode-ext/src/peer-link.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/vscode-ext/src/peer-link.ts b/vscode-ext/src/peer-link.ts index 16c06bbef..62fb7ec79 100644 --- a/vscode-ext/src/peer-link.ts +++ b/vscode-ext/src/peer-link.ts @@ -145,8 +145,9 @@ function peerDirPath(): string { * possibly on purpose, and no amount of retrying makes it ours — so the caller * stands the peer link down for good rather than spinning against it. * - * Windows named pipes are not filesystem objects and carry their own ACL, so - * there is nothing here for them to check. + * Windows named pipes are not filesystem objects, so there is no directory + * here to check; the squat that leaves open is recorded in + * `docs/specs/security.md` → "What is not defended". * * The same predicate is duplicated as `ensureControlDir()` in * standalone/sidecar/dor-control-server.js (sync fs, returns the directory