fix: stabilize relay E2EE handshake

This commit is contained in:
Mohamed Boudra
2026-02-03 13:57:08 +07:00
parent 6ed70e24c7
commit 4fb93d92ed
7 changed files with 125 additions and 34 deletions
+8
View File
@@ -102,6 +102,14 @@ Get the session ID from the agent JSON file (`persistence.sessionId`), then:
Take screenshots like this: `adb exec-out screencap -p > screenshot.png` Take screenshots like this: `adb exec-out screencap -p > screenshot.png`
## Testing with Playwright MCP
**CRITICAL:** When asked to test the app, you MUST use the Playwright MCP connecting to Metro at `http://localhost:8081`.
Use the Playwright MCP to test the app in Metro web. Navigate to `http://localhost:8081` to interact with the app UI.
**Important:** Do NOT use browser history (back/forward). Always navigate by clicking UI elements or using `browser_navigate` with the full URL. The app uses client-side routing and browser history navigation breaks the state.
## Expo troubleshooting ## Expo troubleshooting
Run `npx expo-doctor` to diagnose version mismatches and native module issues. Run `npx expo-doctor` to diagnose version mismatches and native module issues.
+17 -4
View File
@@ -87,19 +87,28 @@ export async function createDaemonChannel(
reject(new Error("Handshake timeout")); reject(new Error("Handshake timeout"));
}, 10000); }, 10000);
const bufferedMessages: Array<string | ArrayBuffer> = [];
transport.onmessage = async (data) => { transport.onmessage = async (data) => {
try { try {
if (typeof data !== "string") { const helloText =
throw new Error("Expected string hello message"); typeof data === "string" ? data : new TextDecoder().decode(data);
}
const msg = JSON.parse(data) as HelloMessage; const msg = JSON.parse(helloText) as HelloMessage;
if (msg.type !== "hello" || !msg.key) { if (msg.type !== "hello" || !msg.key) {
throw new Error("Invalid hello message"); throw new Error("Invalid hello message");
} }
clearTimeout(timeout); clearTimeout(timeout);
// Buffer any subsequent messages that arrive while we're doing async
// WebCrypto work to derive the shared key. Without this, it's possible
// for the next message (already encrypted) to be misinterpreted as a
// second hello, causing the handshake to fail.
transport.onmessage = (next) => {
bufferedMessages.push(next);
};
const clientPublicKey = await importPublicKey(msg.key); const clientPublicKey = await importPublicKey(msg.key);
const sharedKey = await deriveSharedKey( const sharedKey = await deriveSharedKey(
daemonKeyPair.privateKey, daemonKeyPair.privateKey,
@@ -110,6 +119,10 @@ export async function createDaemonChannel(
channel.setState("open"); channel.setState("open");
events.onopen?.(); events.onopen?.();
for (const buffered of bufferedMessages) {
transport.onmessage?.(buffered);
}
resolve(channel); resolve(channel);
} catch (error) { } catch (error) {
clearTimeout(timeout); clearTimeout(timeout);
+36 -9
View File
@@ -72,15 +72,36 @@ export function createRelayServer(config: NodeRelayServerConfig): RelayServer {
const sessionId = url.searchParams.get("session")!; const sessionId = url.searchParams.get("session")!;
const role = url.searchParams.get("role") as ConnectionRole; const role = url.searchParams.get("role") as ConnectionRole;
const connection = wrapWebSocket(ws); const connection = wrapWebSocket(ws, role);
relay.addConnection(sessionId, role, connection); relay.addConnection(sessionId, role, connection);
ws.on("message", (data) => { ws.on("message", (data, isBinary) => {
const message = if (isBinary) {
data instanceof Buffer const message =
? data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength) data instanceof ArrayBuffer
: String(data); ? data
relay.forward(sessionId, role, message as string | ArrayBuffer); : bufferToArrayBuffer(
data instanceof Buffer
? data
: ArrayBuffer.isView(data)
? Buffer.from(data.buffer, data.byteOffset, data.byteLength)
: Buffer.from(String(data), "utf8")
);
relay.forward(sessionId, role, message);
return;
}
const text =
typeof data === "string"
? data
: data instanceof Buffer
? data.toString("utf8")
: data instanceof ArrayBuffer
? Buffer.from(data).toString("utf8")
: ArrayBuffer.isView(data)
? Buffer.from(data.buffer, data.byteOffset, data.byteLength).toString("utf8")
: String(data);
relay.forward(sessionId, role, text);
}); });
ws.on("close", () => { ws.on("close", () => {
@@ -120,9 +141,9 @@ export function createRelayServer(config: NodeRelayServerConfig): RelayServer {
}; };
} }
function wrapWebSocket(ws: NodeWebSocket): RelayConnection { function wrapWebSocket(ws: NodeWebSocket, role: ConnectionRole): RelayConnection {
return { return {
role: "server", role,
send: (data) => { send: (data) => {
if (ws.readyState === NodeWebSocket.OPEN) { if (ws.readyState === NodeWebSocket.OPEN) {
ws.send(data); ws.send(data);
@@ -133,3 +154,9 @@ function wrapWebSocket(ws: NodeWebSocket): RelayConnection {
}, },
}; };
} }
function bufferToArrayBuffer(buffer: Buffer): ArrayBuffer {
const out = new Uint8Array(buffer.byteLength);
out.set(buffer);
return out.buffer;
}
@@ -152,6 +152,12 @@ describe("Relay transport (E2EE) - daemon E2E", () => {
}); });
expect(received).toEqual({ type: "pong" }); expect(received).toEqual({ type: "pong" });
} catch (err) {
const tail = lines.slice(-50).join("");
// Only prints on failure to help diagnose relay handshake issues.
// eslint-disable-next-line no-console
console.error("daemon logs (tail):\n", tail);
throw err;
} finally { } finally {
await daemon.close(); await daemon.close();
await relay.stop(); await relay.stop();
+1 -1
View File
@@ -66,7 +66,7 @@ export async function loadOrCreateDaemonKeyPair(
privateKeyJwk: privateKeyJwk as Record<string, unknown>, privateKeyJwk: privateKeyJwk as Record<string, unknown>,
}; };
writeFileSync(filePath, JSON.stringify(payload, null, 2) + "\n"); writeFileSync(filePath, JSON.stringify(payload, null, 2) + "\n", { mode: 0o600 });
log?.info({ filePath }, "Saved daemon keypair"); log?.info({ filePath }, "Saved daemon keypair");
return { keyPair, publicKeyB64 }; return { keyPair, publicKeyB64 };
+41 -12
View File
@@ -149,8 +149,8 @@ function createRelayTransportAdapter(socket: WebSocket): RelayTransport {
onerror: null, onerror: null,
}; };
socket.on("message", (data) => { socket.on("message", (data, isBinary) => {
relayTransport.onmessage?.(normalizeMessageData(data)); relayTransport.onmessage?.(normalizeMessageData(data, isBinary));
}); });
socket.on("close", (code, reason) => { socket.on("close", (code, reason) => {
relayTransport.onclose?.(code, reason.toString()); relayTransport.onclose?.(code, reason.toString());
@@ -200,24 +200,53 @@ function createEncryptedSocket(
}; };
} }
function normalizeMessageData(data: unknown): string | ArrayBuffer { function normalizeMessageData(data: unknown, isBinary: boolean): string | ArrayBuffer {
if (typeof data === "string") return data; if (!isBinary) {
if (typeof data === "string") return data;
const buffer = bufferFromWsData(data);
if (buffer) return buffer.toString("utf8");
return String(data);
}
if (data instanceof ArrayBuffer) return data; if (data instanceof ArrayBuffer) return data;
if (ArrayBuffer.isView(data)) {
const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength); const buffer = bufferFromWsData(data);
const out = new Uint8Array(view.byteLength); if (buffer) {
out.set(view); const view = new Uint8Array(buffer.buffer, buffer.byteOffset, buffer.byteLength);
return out.buffer;
}
if (Buffer.isBuffer(data)) {
const view = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
const out = new Uint8Array(view.byteLength); const out = new Uint8Array(view.byteLength);
out.set(view); out.set(view);
return out.buffer; return out.buffer;
} }
return String(data); return String(data);
} }
function bufferFromWsData(data: unknown): Buffer | null {
if (Buffer.isBuffer(data)) return data;
if (Array.isArray(data)) {
const buffers: Buffer[] = [];
for (const part of data) {
if (Buffer.isBuffer(part)) {
buffers.push(part);
} else if (part instanceof ArrayBuffer) {
buffers.push(Buffer.from(part));
} else if (ArrayBuffer.isView(part)) {
buffers.push(Buffer.from(part.buffer, part.byteOffset, part.byteLength));
} else if (typeof part === "string") {
buffers.push(Buffer.from(part, "utf8"));
} else {
return null;
}
}
return Buffer.concat(buffers);
}
if (data instanceof ArrayBuffer) return Buffer.from(data);
if (ArrayBuffer.isView(data)) {
return Buffer.from(data.buffer, data.byteOffset, data.byteLength);
}
return null;
}
function buildRelayWebSocketUrl( function buildRelayWebSocketUrl(
relayEndpoint: string, relayEndpoint: string,
sessionId: string, sessionId: string,
@@ -21,6 +21,19 @@ import type pino from "pino";
export type AgentMcpTransportFactory = () => Promise<Transport>; export type AgentMcpTransportFactory = () => Promise<Transport>;
function bufferFromWsData(data: Buffer | ArrayBuffer | Buffer[] | string): Buffer {
if (typeof data === "string") return Buffer.from(data, "utf8");
if (Array.isArray(data)) {
return Buffer.concat(
data.map((item) =>
Buffer.isBuffer(item) ? item : Buffer.from(item as ArrayBuffer)
)
);
}
if (Buffer.isBuffer(data)) return data;
return Buffer.from(data as ArrayBuffer);
}
type WebSocketLike = { type WebSocketLike = {
readyState: number; readyState: number;
send: (data: string) => void; send: (data: string) => void;
@@ -156,7 +169,8 @@ export class WebSocketSessionBridge {
data: Buffer | ArrayBuffer | Buffer[] | string data: Buffer | ArrayBuffer | Buffer[] | string
): Promise<void> { ): Promise<void> {
try { try {
const parsed = JSON.parse(data.toString()); const buffer = bufferFromWsData(data);
const parsed = JSON.parse(buffer.toString());
const message = WSInboundMessageSchema.parse(parsed); const message = WSInboundMessageSchema.parse(parsed);
const messageSummary = { const messageSummary = {
@@ -203,13 +217,7 @@ export class WebSocketSessionBridge {
let parsedPayload: unknown = null; let parsedPayload: unknown = null;
try { try {
const buffer = Array.isArray(data) const buffer = bufferFromWsData(data);
? Buffer.concat(
data.map((item) => (Buffer.isBuffer(item) ? item : Buffer.from(item as ArrayBuffer)))
)
: Buffer.isBuffer(data)
? data
: Buffer.from(data as ArrayBuffer);
rawPayload = buffer.toString(); rawPayload = buffer.toString();
parsedPayload = JSON.parse(rawPayload); parsedPayload = JSON.parse(rawPayload);
} catch (payloadError) { } catch (payloadError) {