|
|
|
@@ -199,169 +199,176 @@ async function stopRelayProcess(relayProcess: ChildProcess): Promise<void> {
|
|
|
|
|
}
|
|
|
|
|
}, SHUTDOWN_TIMEOUT_MS);
|
|
|
|
|
|
|
|
|
|
it("full flow: daemon and client exchange encrypted messages through relay", {
|
|
|
|
|
timeout: 90_000,
|
|
|
|
|
}, async () => {
|
|
|
|
|
const serverId = "test-session-" + Date.now();
|
|
|
|
|
const connectionId = "clt_test_" + Date.now() + "_" + Math.random().toString(36).slice(2);
|
|
|
|
|
it(
|
|
|
|
|
"full flow: daemon and client exchange encrypted messages through relay",
|
|
|
|
|
{
|
|
|
|
|
timeout: 90_000,
|
|
|
|
|
},
|
|
|
|
|
async () => {
|
|
|
|
|
const serverId = "test-session-" + Date.now();
|
|
|
|
|
const connectionId = "clt_test_" + Date.now() + "_" + Math.random().toString(36).slice(2);
|
|
|
|
|
|
|
|
|
|
// === DAEMON SIDE ===
|
|
|
|
|
// Generate keypair (public key goes in QR)
|
|
|
|
|
const daemonKeyPair = await generateKeyPair();
|
|
|
|
|
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
|
|
|
|
|
// === DAEMON SIDE ===
|
|
|
|
|
// Generate keypair (public key goes in QR)
|
|
|
|
|
const daemonKeyPair = await generateKeyPair();
|
|
|
|
|
const daemonPubKeyB64 = await exportPublicKey(daemonKeyPair.publicKey);
|
|
|
|
|
|
|
|
|
|
// QR would contain: { serverId, daemonPubKeyB64, relay: { endpoint } }
|
|
|
|
|
// QR would contain: { serverId, daemonPubKeyB64, relay: { endpoint } }
|
|
|
|
|
|
|
|
|
|
// Daemon connects to relay as "server" control role
|
|
|
|
|
const daemonControlWs = new WebSocket(
|
|
|
|
|
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&v=2`,
|
|
|
|
|
);
|
|
|
|
|
// Daemon connects to relay as "server" control role
|
|
|
|
|
const daemonControlWs = new WebSocket(
|
|
|
|
|
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&v=2`,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
|
daemonControlWs.on("open", resolve);
|
|
|
|
|
daemonControlWs.on("error", reject);
|
|
|
|
|
});
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
|
daemonControlWs.on("open", resolve);
|
|
|
|
|
daemonControlWs.on("error", reject);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// === CLIENT SIDE ===
|
|
|
|
|
// Client scans QR, gets daemon's public key and session ID
|
|
|
|
|
// Client generates own keypair
|
|
|
|
|
const clientKeyPair = await generateKeyPair();
|
|
|
|
|
const clientPubKeyB64 = await exportPublicKey(clientKeyPair.publicKey);
|
|
|
|
|
// === CLIENT SIDE ===
|
|
|
|
|
// Client scans QR, gets daemon's public key and session ID
|
|
|
|
|
// Client generates own keypair
|
|
|
|
|
const clientKeyPair = await generateKeyPair();
|
|
|
|
|
const clientPubKeyB64 = await exportPublicKey(clientKeyPair.publicKey);
|
|
|
|
|
|
|
|
|
|
// Client imports daemon's public key and derives shared secret
|
|
|
|
|
const daemonPubKeyOnClient = await importPublicKey(daemonPubKeyB64);
|
|
|
|
|
const clientSharedKey = await deriveSharedKey(clientKeyPair.secretKey, daemonPubKeyOnClient);
|
|
|
|
|
// Client imports daemon's public key and derives shared secret
|
|
|
|
|
const daemonPubKeyOnClient = await importPublicKey(daemonPubKeyB64);
|
|
|
|
|
const clientSharedKey = await deriveSharedKey(clientKeyPair.secretKey, daemonPubKeyOnClient);
|
|
|
|
|
|
|
|
|
|
const waitForClientSeen = new Promise<void>((resolve, reject) => {
|
|
|
|
|
const timeout = setTimeout(() => reject(new Error("timed out waiting for connected")), 5000);
|
|
|
|
|
const onMessage = (raw: unknown) => {
|
|
|
|
|
try {
|
|
|
|
|
const text =
|
|
|
|
|
typeof raw === "string"
|
|
|
|
|
? raw
|
|
|
|
|
: raw && typeof (raw as any).toString === "function"
|
|
|
|
|
? (raw as any).toString()
|
|
|
|
|
: "";
|
|
|
|
|
const msg = JSON.parse(text);
|
|
|
|
|
if (msg?.type === "connected" && msg.connectionId === connectionId) {
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
daemonControlWs.off("message", onMessage);
|
|
|
|
|
resolve();
|
|
|
|
|
return;
|
|
|
|
|
const waitForClientSeen = new Promise<void>((resolve, reject) => {
|
|
|
|
|
const timeout = setTimeout(
|
|
|
|
|
() => reject(new Error("timed out waiting for connected")),
|
|
|
|
|
5000,
|
|
|
|
|
);
|
|
|
|
|
const onMessage = (raw: unknown) => {
|
|
|
|
|
try {
|
|
|
|
|
const text =
|
|
|
|
|
typeof raw === "string"
|
|
|
|
|
? raw
|
|
|
|
|
: raw && typeof (raw as any).toString === "function"
|
|
|
|
|
? (raw as any).toString()
|
|
|
|
|
: "";
|
|
|
|
|
const msg = JSON.parse(text);
|
|
|
|
|
if (msg?.type === "connected" && msg.connectionId === connectionId) {
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
daemonControlWs.off("message", onMessage);
|
|
|
|
|
resolve();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
msg?.type === "sync" &&
|
|
|
|
|
Array.isArray(msg.connectionIds) &&
|
|
|
|
|
msg.connectionIds.includes(connectionId)
|
|
|
|
|
) {
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
daemonControlWs.off("message", onMessage);
|
|
|
|
|
resolve();
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
if (
|
|
|
|
|
msg?.type === "sync" &&
|
|
|
|
|
Array.isArray(msg.connectionIds) &&
|
|
|
|
|
msg.connectionIds.includes(connectionId)
|
|
|
|
|
) {
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
daemonControlWs.off("message", onMessage);
|
|
|
|
|
resolve();
|
|
|
|
|
}
|
|
|
|
|
} catch {
|
|
|
|
|
// ignore
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
daemonControlWs.on("message", onMessage);
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
daemonControlWs.on("message", onMessage);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Client connects to relay as "client" role (must include connectionId)
|
|
|
|
|
const clientWs = new WebSocket(
|
|
|
|
|
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&connectionId=${connectionId}&v=2`,
|
|
|
|
|
);
|
|
|
|
|
// Client connects to relay as "client" role (must include connectionId)
|
|
|
|
|
const clientWs = new WebSocket(
|
|
|
|
|
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=client&connectionId=${connectionId}&v=2`,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
|
clientWs.on("open", resolve);
|
|
|
|
|
clientWs.on("error", reject);
|
|
|
|
|
});
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
|
clientWs.on("open", resolve);
|
|
|
|
|
clientWs.on("error", reject);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
await waitForClientSeen;
|
|
|
|
|
await waitForClientSeen;
|
|
|
|
|
|
|
|
|
|
const daemonWs = new WebSocket(
|
|
|
|
|
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&connectionId=${connectionId}&v=2`,
|
|
|
|
|
);
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
|
daemonWs.on("open", resolve);
|
|
|
|
|
daemonWs.on("error", reject);
|
|
|
|
|
});
|
|
|
|
|
const daemonWs = new WebSocket(
|
|
|
|
|
`ws://127.0.0.1:${relayPort}/ws?serverId=${serverId}&role=server&connectionId=${connectionId}&v=2`,
|
|
|
|
|
);
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
|
|
|
|
daemonWs.on("open", resolve);
|
|
|
|
|
daemonWs.on("error", reject);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Client sends hello with its public key (this message is NOT encrypted - it's the handshake)
|
|
|
|
|
const helloMsg = JSON.stringify({ type: "hello", key: clientPubKeyB64 });
|
|
|
|
|
clientWs.send(helloMsg);
|
|
|
|
|
// Client sends hello with its public key (this message is NOT encrypted - it's the handshake)
|
|
|
|
|
const helloMsg = JSON.stringify({ type: "hello", key: clientPubKeyB64 });
|
|
|
|
|
clientWs.send(helloMsg);
|
|
|
|
|
|
|
|
|
|
// === DAEMON RECEIVES HELLO ===
|
|
|
|
|
const daemonReceivedHello = await new Promise<string>((resolve) => {
|
|
|
|
|
daemonWs.once("message", (data) => resolve(data.toString()));
|
|
|
|
|
});
|
|
|
|
|
// === DAEMON RECEIVES HELLO ===
|
|
|
|
|
const daemonReceivedHello = await new Promise<string>((resolve) => {
|
|
|
|
|
daemonWs.once("message", (data) => resolve(data.toString()));
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const hello = JSON.parse(daemonReceivedHello);
|
|
|
|
|
expect(hello.type).toBe("hello");
|
|
|
|
|
expect(hello.key).toBe(clientPubKeyB64);
|
|
|
|
|
const hello = JSON.parse(daemonReceivedHello);
|
|
|
|
|
expect(hello.type).toBe("hello");
|
|
|
|
|
expect(hello.key).toBe(clientPubKeyB64);
|
|
|
|
|
|
|
|
|
|
// Daemon imports client's public key and derives shared secret
|
|
|
|
|
const clientPubKeyOnDaemon = await importPublicKey(hello.key);
|
|
|
|
|
const daemonSharedKey = await deriveSharedKey(daemonKeyPair.secretKey, clientPubKeyOnDaemon);
|
|
|
|
|
// Daemon imports client's public key and derives shared secret
|
|
|
|
|
const clientPubKeyOnDaemon = await importPublicKey(hello.key);
|
|
|
|
|
const daemonSharedKey = await deriveSharedKey(daemonKeyPair.secretKey, clientPubKeyOnDaemon);
|
|
|
|
|
|
|
|
|
|
// === VERIFY BOTH HAVE SAME KEY - Exchange encrypted messages ===
|
|
|
|
|
// === VERIFY BOTH HAVE SAME KEY - Exchange encrypted messages ===
|
|
|
|
|
|
|
|
|
|
// Daemon sends encrypted "ready" message
|
|
|
|
|
const readyPlaintext = JSON.stringify({ type: "ready" });
|
|
|
|
|
const readyCiphertext = await encrypt(daemonSharedKey, readyPlaintext);
|
|
|
|
|
daemonWs.send(Buffer.from(readyCiphertext));
|
|
|
|
|
// Daemon sends encrypted "ready" message
|
|
|
|
|
const readyPlaintext = JSON.stringify({ type: "ready" });
|
|
|
|
|
const readyCiphertext = await encrypt(daemonSharedKey, readyPlaintext);
|
|
|
|
|
daemonWs.send(Buffer.from(readyCiphertext));
|
|
|
|
|
|
|
|
|
|
// Client receives and decrypts
|
|
|
|
|
const clientReceivedReady = await new Promise<Buffer>((resolve) => {
|
|
|
|
|
clientWs.once("message", (data) => resolve(data as Buffer));
|
|
|
|
|
});
|
|
|
|
|
const decryptedReady = await decrypt(
|
|
|
|
|
clientSharedKey,
|
|
|
|
|
clientReceivedReady.buffer.slice(
|
|
|
|
|
clientReceivedReady.byteOffset,
|
|
|
|
|
clientReceivedReady.byteOffset + clientReceivedReady.byteLength,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(JSON.parse(decryptedReady as string)).toEqual({ type: "ready" });
|
|
|
|
|
// Client receives and decrypts
|
|
|
|
|
const clientReceivedReady = await new Promise<Buffer>((resolve) => {
|
|
|
|
|
clientWs.once("message", (data) => resolve(data as Buffer));
|
|
|
|
|
});
|
|
|
|
|
const decryptedReady = await decrypt(
|
|
|
|
|
clientSharedKey,
|
|
|
|
|
clientReceivedReady.buffer.slice(
|
|
|
|
|
clientReceivedReady.byteOffset,
|
|
|
|
|
clientReceivedReady.byteOffset + clientReceivedReady.byteLength,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(JSON.parse(decryptedReady as string)).toEqual({ type: "ready" });
|
|
|
|
|
|
|
|
|
|
// Client sends encrypted message
|
|
|
|
|
const clientMessage = "Hello from client!";
|
|
|
|
|
const clientCiphertext = await encrypt(clientSharedKey, clientMessage);
|
|
|
|
|
clientWs.send(Buffer.from(clientCiphertext));
|
|
|
|
|
// Client sends encrypted message
|
|
|
|
|
const clientMessage = "Hello from client!";
|
|
|
|
|
const clientCiphertext = await encrypt(clientSharedKey, clientMessage);
|
|
|
|
|
clientWs.send(Buffer.from(clientCiphertext));
|
|
|
|
|
|
|
|
|
|
// Daemon receives and decrypts
|
|
|
|
|
const daemonReceivedMsg = await new Promise<Buffer>((resolve) => {
|
|
|
|
|
daemonWs.once("message", (data) => resolve(data as Buffer));
|
|
|
|
|
});
|
|
|
|
|
const decryptedClientMsg = await decrypt(
|
|
|
|
|
daemonSharedKey,
|
|
|
|
|
daemonReceivedMsg.buffer.slice(
|
|
|
|
|
daemonReceivedMsg.byteOffset,
|
|
|
|
|
daemonReceivedMsg.byteOffset + daemonReceivedMsg.byteLength,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(decryptedClientMsg).toBe(clientMessage);
|
|
|
|
|
// Daemon receives and decrypts
|
|
|
|
|
const daemonReceivedMsg = await new Promise<Buffer>((resolve) => {
|
|
|
|
|
daemonWs.once("message", (data) => resolve(data as Buffer));
|
|
|
|
|
});
|
|
|
|
|
const decryptedClientMsg = await decrypt(
|
|
|
|
|
daemonSharedKey,
|
|
|
|
|
daemonReceivedMsg.buffer.slice(
|
|
|
|
|
daemonReceivedMsg.byteOffset,
|
|
|
|
|
daemonReceivedMsg.byteOffset + daemonReceivedMsg.byteLength,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(decryptedClientMsg).toBe(clientMessage);
|
|
|
|
|
|
|
|
|
|
// Daemon sends encrypted response
|
|
|
|
|
const daemonMessage = "Hello from daemon!";
|
|
|
|
|
const daemonCiphertext = await encrypt(daemonSharedKey, daemonMessage);
|
|
|
|
|
daemonWs.send(Buffer.from(daemonCiphertext));
|
|
|
|
|
// Daemon sends encrypted response
|
|
|
|
|
const daemonMessage = "Hello from daemon!";
|
|
|
|
|
const daemonCiphertext = await encrypt(daemonSharedKey, daemonMessage);
|
|
|
|
|
daemonWs.send(Buffer.from(daemonCiphertext));
|
|
|
|
|
|
|
|
|
|
// Client receives and decrypts
|
|
|
|
|
const clientReceivedMsg = await new Promise<Buffer>((resolve) => {
|
|
|
|
|
clientWs.once("message", (data) => resolve(data as Buffer));
|
|
|
|
|
});
|
|
|
|
|
const decryptedDaemonMsg = await decrypt(
|
|
|
|
|
clientSharedKey,
|
|
|
|
|
clientReceivedMsg.buffer.slice(
|
|
|
|
|
clientReceivedMsg.byteOffset,
|
|
|
|
|
clientReceivedMsg.byteOffset + clientReceivedMsg.byteLength,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(decryptedDaemonMsg).toBe(daemonMessage);
|
|
|
|
|
// Client receives and decrypts
|
|
|
|
|
const clientReceivedMsg = await new Promise<Buffer>((resolve) => {
|
|
|
|
|
clientWs.once("message", (data) => resolve(data as Buffer));
|
|
|
|
|
});
|
|
|
|
|
const decryptedDaemonMsg = await decrypt(
|
|
|
|
|
clientSharedKey,
|
|
|
|
|
clientReceivedMsg.buffer.slice(
|
|
|
|
|
clientReceivedMsg.byteOffset,
|
|
|
|
|
clientReceivedMsg.byteOffset + clientReceivedMsg.byteLength,
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
expect(decryptedDaemonMsg).toBe(daemonMessage);
|
|
|
|
|
|
|
|
|
|
// Cleanup
|
|
|
|
|
daemonWs.close();
|
|
|
|
|
clientWs.close();
|
|
|
|
|
});
|
|
|
|
|
// Cleanup
|
|
|
|
|
daemonWs.close();
|
|
|
|
|
clientWs.close();
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
it("relay only sees opaque bytes after handshake", { timeout: 90_000 }, async () => {
|
|
|
|
|
const serverId = "opaque-test-" + Date.now();
|
|
|
|
|