diff --git a/bridge/cutter/rea_bridge.py b/bridge/cutter/rea_bridge.py index d7ffd3cb03..931b032d3c 100644 --- a/bridge/cutter/rea_bridge.py +++ b/bridge/cutter/rea_bridge.py @@ -130,6 +130,18 @@ MAX_FRAME_BYTES, BRIDGE_MAX_REQUEST_BYTES_SOURCE = ( ) +def _bridge_source_sha256() -> str | None: + """Digest this plugin file so REA can tell its packaged copy from another one.""" + try: + with open(__file__, "rb") as source: + return hashlib.sha256(source.read()).hexdigest() + except (NameError, OSError): + return None + + +BRIDGE_SOURCE_SHA256 = _bridge_source_sha256() + + def _bridge_max_request_bytes_recovery(): return ( "The effective REA_BRIDGE_MAX_REQUEST_BYTES value is %d bytes " @@ -655,6 +667,7 @@ class _BridgeServer(threading.Thread): "cutter_version": self.dispatcher.cutter_version, "identity_status": "partial", "max_request_bytes": MAX_FRAME_BYTES, + "bridge_source_sha256": BRIDGE_SOURCE_SHA256, } if os.name == "nt": _write_windows_descriptor(self.directory, self.descriptor, json.dumps(payload).encode("utf-8")) diff --git a/docs/reverse-engineering-tools.md b/docs/reverse-engineering-tools.md index 2fa2441ea3..8f1103f654 100644 --- a/docs/reverse-engineering-tools.md +++ b/docs/reverse-engineering-tools.md @@ -269,6 +269,13 @@ they use `%LOCALAPPDATA%\REA\CutterBridge` on Windows when `LOCALAPPDATA` is set and `~/.cache/rea/cutter-bridge` otherwise. The two processes must resolve the same directory for discovery to find the published sessions. +The plugin publishes the SHA-256 of its own file on its session descriptor. +Discovery reports each session's `bridge_source`: `packaged` when it matches +REA's packaged `bridge/cutter/rea_bridge.py`, `modified` when it differs, and +`unknown` when the plugin predates this field. `cutter_command` refuses a +`modified` session before sending anything and names the packaged plugin to +copy into Cutter's `plugins/python` directory. An `unknown` session still runs. + The client and plugin must support the current v2 handshake; older peers are rejected before dispatch because they cannot preserve cross-caller recovery proof. The client checks the final encoded request against diff --git a/src/cutter/CutterBridgeClient.ts b/src/cutter/CutterBridgeClient.ts index 82d063ad94..148ce69c72 100644 --- a/src/cutter/CutterBridgeClient.ts +++ b/src/cutter/CutterBridgeClient.ts @@ -42,6 +42,10 @@ import { } from "../domain/cutterDiscovery.js"; import { err, ok, type Result } from "../domain/result.js"; import { cutterBridgeMac } from "./cutterBridgeMac.js"; +import { + cutterBridgeSource, + packagedCutterPluginPath, +} from "./CutterBridgeSource.js"; import { CutterBridgeResponseFrames } from "./cutterAuthenticatedResponse.js"; import { requireWindowsNativeAuthority } from "../windows/WindowsNativeLoader.js"; import { readBoundedFileBytes } from "../process/BoundedFileBytes.js"; @@ -60,7 +64,12 @@ import { currentAnalysisOperationSnapshot } from "../domain/analysisOperationSna import type { AnalysisSettingsReader } from "../domain/analysisSettings.js"; const descriptorSchema = z.object({ - ...cutterSessionSchema.shape, + ...cutterSessionSchema.omit({ bridge_source: true }).shape, + bridge_source_sha256: z + .string() + .regex(/^[0-9a-f]{64}$/) + .nullable() + .optional(), host: z.literal("127.0.0.1"), port: z.int().min(1).max(65535), token: z.string().min(20), @@ -95,6 +104,17 @@ const responseSchema = z.object({ .regex(/^[0-9a-f]{16}$/) .optional(), }); +/** A session runs a plugin whose bytes differ from the packaged copy. */ +const modifiedPluginRefusal = (): AnalysisCapabilityUnavailableError => { + const remediation = `Copy ${packagedCutterPluginPath()} into Cutter's plugins/python directory, restart Cutter, and list sessions again. No command was sent.`; + return new AnalysisCapabilityUnavailableError( + "cutter", + "cutter_command", + "The Cutter session runs a REA plugin whose source differs from the packaged plugin, so its protocol cannot be trusted to match this REA release.", + { remediationAction: remediation, userMessage: remediation }, + ); +}; + const acknowledgementSchema = z.object({ ok: z.literal(true), acknowledged: z.boolean(), @@ -383,6 +403,12 @@ export class CutterBridgeClient { mismatches.push(advertised); return undefined; } + // Classify outside the per-peer catch: an unreadable packaged plugin + // is REA's own failure and must not read as an absent session. + const bridgeSource = cutterBridgeSource( + descriptor.bridge_source_sha256, + "list_cutter_sessions", + ); try { const status = responseSchema.safeParse( await this.#request( @@ -414,6 +440,7 @@ export class CutterBridgeClient { cutter_version: status.data.cutter_version ?? descriptor.cutter_version, identity_status: "partial" as const, + bridge_source: bridgeSource, }; } catch (cause) { if (cause instanceof AnalysisResourceConstraintError) throw cause; @@ -468,6 +495,13 @@ export class CutterBridgeClient { this.#requestLimit(); const descriptor = await this.#find(input.sessionId, input.signal); assertBridgeActive(input.signal, "cutter_command"); + if ( + cutterBridgeSource( + descriptor.bridge_source_sha256, + "cutter_command", + ) === "modified" + ) + throw modifiedPluginRefusal(); const advertised = descriptor.max_request_bytes ?? DEFAULT_BRIDGE_MAX_REQUEST_BYTES; if (advertised !== this.#requestLimit().maximumBytes) diff --git a/src/cutter/CutterBridgeSource.test.ts b/src/cutter/CutterBridgeSource.test.ts new file mode 100644 index 0000000000..587fb5bc67 --- /dev/null +++ b/src/cutter/CutterBridgeSource.test.ts @@ -0,0 +1,40 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; + +import { expect, it } from "vitest"; + +import { cutterBridgeSource } from "./CutterBridgeSource.js"; + +const packaged = new URL("../../bridge/cutter/rea_bridge.py", import.meta.url); +const packagedSha256 = createHash("sha256") + .update(readFileSync(packaged)) + .digest("hex"); + +it("classifies a session's plugin against the packaged plugin bytes", () => { + expect(cutterBridgeSource(packagedSha256, "list_cutter_sessions")).toBe( + "packaged", + ); + expect(cutterBridgeSource("0".repeat(64), "list_cutter_sessions")).toBe( + "modified", + ); + expect(cutterBridgeSource(undefined, "list_cutter_sessions")).toBe("unknown"); + expect(cutterBridgeSource(null, "list_cutter_sessions")).toBe("unknown"); +}); + +it("reports an unreadable packaged plugin as REA's own typed failure", () => { + const missing = new URL("./missing-rea-bridge.py", import.meta.url); + + expect(() => + cutterBridgeSource("0".repeat(64), "list_cutter_sessions", missing), + ).toThrow( + expect.objectContaining({ + _tag: "AnalysisCapabilityUnavailableError", + operation: "list_cutter_sessions", + remediationAction: expect.stringContaining("Reinstall REA"), + }), + ); + expect( + cutterBridgeSource(undefined, "list_cutter_sessions", missing), + "a plugin without a digest needs no packaged comparison", + ).toBe("unknown"); +}); diff --git a/src/cutter/CutterBridgeSource.ts b/src/cutter/CutterBridgeSource.ts new file mode 100644 index 0000000000..62c5c591a6 --- /dev/null +++ b/src/cutter/CutterBridgeSource.ts @@ -0,0 +1,60 @@ +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js"; +import type { CutterBridgeSource } from "../domain/cutterDiscovery.js"; + +const PACKAGED_PLUGIN = new URL( + "../../bridge/cutter/rea_bridge.py", + import.meta.url, +); + +/** Packaged plugin path a user copies into Cutter's `plugins/python` directory. */ +export const packagedCutterPluginPath = (plugin = PACKAGED_PLUGIN): string => + fileURLToPath(plugin); + +const packagedDigests = new Map(); + +/** + * SHA-256 of the packaged Cutter plugin bytes. The user copies the plugin into + * Cutter, so REA compares the digest the running plugin publishes with this + * one instead of trusting its protocol version alone. An unreadable packaged + * plugin is a broken REA installation, reported as such rather than hiding + * the live session that asked for the comparison. + */ +const packagedCutterPluginDigest = (operation: string, plugin: URL): string => { + const cached = packagedDigests.get(plugin.href); + if (cached !== undefined) return cached; + let bytes: Buffer; + try { + bytes = readFileSync(plugin); + } catch (cause: unknown) { + const remediation = `Reinstall REA so ${packagedCutterPluginPath(plugin)} is present and readable, then retry. REA could not compare the running Cutter plugin with it, and no command was sent.`; + throw new AnalysisCapabilityUnavailableError( + "cutter", + operation, + "REA's packaged Cutter plugin could not be read, so the running plugin's source cannot be identified.", + { cause, remediationAction: remediation, userMessage: remediation }, + ); + } + const digest = createHash("sha256").update(bytes).digest("hex"); + packagedDigests.set(plugin.href, digest); + return digest; +}; + +/** + * Classify the plugin a session runs. A plugin that publishes no digest + * predates source identity, so its source is unknown rather than modified. + */ +export const cutterBridgeSource = ( + advertisedSha256: string | null | undefined, + operation: string, + plugin = PACKAGED_PLUGIN, +): CutterBridgeSource => { + if (advertisedSha256 === undefined || advertisedSha256 === null) + return "unknown"; + return advertisedSha256 === packagedCutterPluginDigest(operation, plugin) + ? "packaged" + : "modified"; +}; diff --git a/src/domain/cutterDiscovery.ts b/src/domain/cutterDiscovery.ts index 272466de35..9ee40329d1 100644 --- a/src/domain/cutterDiscovery.ts +++ b/src/domain/cutterDiscovery.ts @@ -1,5 +1,16 @@ import { z } from "zod"; +/** + * Which plugin source a session runs: the packaged copy, a different one, or + * unknown when the plugin predates source identity. + */ +export const cutterBridgeSourceSchema = z.enum([ + "packaged", + "modified", + "unknown", +]); +export type CutterBridgeSource = z.infer; + /** Observed Cutter session identity without bridge transport credentials. */ export const cutterSessionSchema = z.strictObject({ session_id: z.string().uuid(), @@ -8,6 +19,7 @@ export const cutterSessionSchema = z.strictObject({ current_file: z.string().nullable(), cutter_version: z.string().nullable(), identity_status: z.literal("partial"), + bridge_source: cutterBridgeSourceSchema, }); /** Discovery facts retained independently of descriptor cleanup. */ diff --git a/tests/boundary/providers/CutterBridgeClient.test.ts b/tests/boundary/providers/CutterBridgeClient.test.ts index 12e57a88e0..03f7a44716 100644 --- a/tests/boundary/providers/CutterBridgeClient.test.ts +++ b/tests/boundary/providers/CutterBridgeClient.test.ts @@ -3,6 +3,7 @@ import { writeDescriptor, } from "../../fixtures/cutterBridgeProtocol.js"; import { createServer } from "node:net"; +import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { chmod, @@ -305,6 +306,81 @@ describe("CutterBridgeClient: selected POSIX paths", () => { ); }); +describe("CutterBridgeClient: plugin source identity", () => { + let directory: string | undefined; + let server: ReturnType | undefined; + + afterEach(async () => { + if (server !== undefined) + await new Promise((resolve) => server?.close(() => resolve())); + if (directory !== undefined) + await rm(directory, { recursive: true, force: true }); + server = undefined; + directory = undefined; + }); + + const packagedDigest = createHash("sha256") + .update(readFileSync("bridge/cutter/rea_bridge.py")) + .digest("hex"); + + it.each([ + ["packaged", packagedDigest], + ["modified", "0".repeat(64)], + ["unknown", undefined], + ] as const)( + "reports a %s plugin source in discovery", + async (source, digest) => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-source-")); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + const token = "synthetic-cutter-bridge-token-for-test"; + server = createStatusHarness(token, "/tmp/sample.bin", 4).server; + const port = await listen(server); + await writeDescriptor( + directory, + sessionId, + port, + token, + digest === undefined ? {} : { bridgeSourceSha256: digest }, + ); + + const result = await createClient({ + REA_CUTTER_BRIDGE_DIR: directory, + }).listSessions(); + + expect(result.sessions).toMatchObject([{ bridge_source: source }]); + }, + ); + + it("refuses a command to a modified plugin before sending it", async () => { + directory = await mkdtemp(join(tmpdir(), "rea-cutter-source-")); + const sessionId = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; + const token = "synthetic-cutter-bridge-token-for-test"; + const requests: unknown[] = []; + server = authenticatedServer(token, sessionId, (request, respond) => { + requests.push(request); + respond({ ok: true, execution_state: "complete" }); + }); + const port = await listen(server); + await writeDescriptor(directory, sessionId, port, token, { + bridgeSourceSha256: "0".repeat(64), + }); + + await expect( + createClient({ REA_CUTTER_BRIDGE_DIR: directory }).execute({ + sessionId, + expectedGeneration: 0, + command: "ij", + json: true, + }), + ).rejects.toMatchObject({ + _tag: "AnalysisCapabilityUnavailableError", + operation: "cutter_command", + remediationAction: expect.stringContaining("rea_bridge.py"), + }); + expect(requests).toEqual([]); + }); +}); + describe("CutterBridgeClient: scenario group 1", () => { let directory: string | undefined; let server: ReturnType | undefined; diff --git a/tests/boundary/providers/cutterBridgeProducer.test.ts b/tests/boundary/providers/cutterBridgeProducer.test.ts index c81cf904b3..52be788afe 100644 --- a/tests/boundary/providers/cutterBridgeProducer.test.ts +++ b/tests/boundary/providers/cutterBridgeProducer.test.ts @@ -111,6 +111,10 @@ it("round-trips authenticated status and command bytes across the Python produce const { client, session } = bridge; expect(session.document_generation).toBe(4); expect(session.current_file).toBe("/tmp/cutter-interop-雪.bin"); + expect( + session.bridge_source, + "the real plugin publishes the digest of its packaged bytes", + ).toBe("packaged"); for (let index = 0; index <= 64; index += 1) { await expect( diff --git a/tests/fixtures/cutterBridgeProtocol.ts b/tests/fixtures/cutterBridgeProtocol.ts index 091625ec48..6224f8e270 100644 --- a/tests/fixtures/cutterBridgeProtocol.ts +++ b/tests/fixtures/cutterBridgeProtocol.ts @@ -119,6 +119,7 @@ export const writeDescriptor = async ( readonly currentFile?: string | null; readonly documentGeneration?: number; readonly maxRequestBytes?: number; + readonly bridgeSourceSha256?: string | null; } = {}, ): Promise => writeFile( @@ -136,6 +137,9 @@ export const writeDescriptor = async ( ...(options.maxRequestBytes === undefined ? {} : { max_request_bytes: options.maxRequestBytes }), + ...(options.bridgeSourceSha256 === undefined + ? {} + : { bridge_source_sha256: options.bridgeSourceSha256 }), }), { mode: 0o600 }, ); diff --git a/tests/process-global/commandFinalizationCancellation.test.ts b/tests/process-global/commandFinalizationCancellation.test.ts index 935971b63c..2355273249 100644 --- a/tests/process-global/commandFinalizationCancellation.test.ts +++ b/tests/process-global/commandFinalizationCancellation.test.ts @@ -73,6 +73,7 @@ const cutterDiscoveryFixture = () => current_file: "C:\\Caller files\\token=literal%2F.bin", cutter_version: "2.3.4", identity_status: "partial", + bridge_source: "unknown", }, { session_id: "00000000-0000-4000-8000-000000000002", @@ -81,6 +82,7 @@ const cutterDiscoveryFixture = () => current_file: null, cutter_version: null, identity_status: "partial", + bridge_source: "unknown", }, ], bridge_directory: "/caller/Cutter bridge/token=literal%2F/证据",