Files
openchamber/packages/web/server/lib/relay/e2ee.js
T

344 lines
12 KiB
JavaScript
Raw Normal View History

// E2EE primitives + responder handshake for the private relay (Layer 2).
// JS mirror of the normative TS implementation in
// packages/ui/src/lib/relay/{protocol,crypto,handshake}.ts — the web server is
// plain JS and cannot import from packages/ui, so the logic is copied verbatim
// (converted to JSDoc'd JS) and MUST stay byte-compatible with those modules.
// WebCrypto only: `globalThis.crypto.subtle` (Node >= 22).
// Spec: .opencode/plans/private-relay/01-protocol-spec.md (Layer 2).
const subtle = globalThis.crypto.subtle;
export const RELAY_PROTOCOL_VERSION = 1;
export const RELAY_HKDF_INFO = 'openchamber-relay-v1';
// Encrypted frame layout: [1 byte version][12 byte IV][ciphertext + 16 byte GCM tag].
export const ENCRYPTED_FRAME_VERSION = 1;
export const ENCRYPTED_FRAME_IV_BYTES = 12;
export const ENCRYPTED_FRAME_HEADER_BYTES = 1 + ENCRYPTED_FRAME_IV_BYTES;
export const MAX_PLAINTEXT_FRAME_BYTES = 64 * 1024;
// Relay-assigned WebSocket close codes (subset the host needs).
export const RelayCloseCode = {
RekeyMismatch: 1008,
ChannelFailure: 1011,
};
const ECDH_PARAMS = { name: 'ECDH', namedCurve: 'P-256' };
const HANDSHAKE_NONCE_BYTES = 16;
const SESSION_KEY_BYTES = 32;
const GCM_TAG_BYTES = 16;
// IV = 4-byte random per-direction prefix || 8-byte big-endian frame counter.
const IV_PREFIX_BYTES = 4;
const IV_COUNTER_BYTES = 8;
export class RelayCryptoError extends Error {
constructor(message) {
super(message);
this.name = 'RelayCryptoError';
}
}
/** @returns {Promise<CryptoKeyPair>} */
export const generateEcdhKeyPair = () => subtle.generateKey(ECDH_PARAMS, true, ['deriveBits']);
/**
* @param {CryptoKey} key
* @returns {Promise<JsonWebKey>} public JWK reduced to the fields that define the point
*/
export const exportPublicKeyJwk = async (key) => {
const jwk = await subtle.exportKey('jwk', key);
return { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y };
};
/** @param {JsonWebKey} jwk */
export const importEcdhPublicKey = async (jwk) => {
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256' || typeof jwk.x !== 'string' || typeof jwk.y !== 'string') {
throw new RelayCryptoError('invalid ECDH public key JWK');
}
try {
return await subtle.importKey(
'jwk',
{ kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y, ext: true },
ECDH_PARAMS,
true,
[],
);
} catch {
throw new RelayCryptoError('invalid ECDH public key JWK');
}
};
/** @param {JsonWebKey} jwk private ECDH JWK (d + point) */
export const importEcdhPrivateKey = async (jwk) => {
try {
return await subtle.importKey('jwk', jwk, ECDH_PARAMS, false, ['deriveBits']);
} catch {
throw new RelayCryptoError('invalid ECDH private key JWK');
}
};
// Stable fingerprint of a public key, used to detect rekey attempts on re-hello.
/** @param {JsonWebKey} jwk */
export const publicKeyJwkFingerprint = (jwk) =>
JSON.stringify({ crv: jwk.crv, kty: jwk.kty, x: jwk.x, y: jwk.y });
export const generateHandshakeNonce = () => {
const nonce = new Uint8Array(HANDSHAKE_NONCE_BYTES);
globalThis.crypto.getRandomValues(nonce);
return nonce;
};
/**
* Both sides call this with their own private key and the peer's public key;
* ECDH yields the same shared secret, so the derived key pair matches.
* @param {CryptoKey} ownPrivateKey
* @param {CryptoKey} peerPublicKey
* @param {Uint8Array} handshakeNonce
* @returns {Promise<{ clientToHost: CryptoKey, hostToClient: CryptoKey }>}
*/
export const deriveSessionKeys = async (ownPrivateKey, peerPublicKey, handshakeNonce) => {
if (handshakeNonce.length !== HANDSHAKE_NONCE_BYTES) {
throw new RelayCryptoError('invalid handshake nonce length');
}
const sharedSecret = await subtle.deriveBits({ name: 'ECDH', public: peerPublicKey }, ownPrivateKey, 256);
const hkdfKey = await subtle.importKey('raw', sharedSecret, 'HKDF', false, ['deriveBits']);
const keyMaterial = new Uint8Array(
await subtle.deriveBits(
{
name: 'HKDF',
hash: 'SHA-256',
salt: handshakeNonce,
info: new TextEncoder().encode(RELAY_HKDF_INFO),
},
hkdfKey,
SESSION_KEY_BYTES * 2 * 8,
),
);
const importAesKey = (bytes, usage) => subtle.importKey('raw', bytes, { name: 'AES-GCM' }, false, usage);
return {
clientToHost: await importAesKey(keyMaterial.slice(0, SESSION_KEY_BYTES), ['encrypt', 'decrypt']),
hostToClient: await importAesKey(keyMaterial.slice(SESSION_KEY_BYTES), ['encrypt', 'decrypt']),
};
};
const writeCounter = (target, offset, counter) => {
for (let i = IV_COUNTER_BYTES - 1; i >= 0; i -= 1) {
target[offset + i] = Number(counter & 0xffn);
counter >>= 8n;
}
};
const readCounter = (source, offset) => {
let value = 0n;
for (let i = 0; i < IV_COUNTER_BYTES; i += 1) {
value = (value << 8n) | BigInt(source[offset + i]);
}
return value;
};
/** @param {CryptoKey} key AES-256-GCM key for this direction */
export const createFrameEncryptor = (key) => {
const ivPrefix = new Uint8Array(IV_PREFIX_BYTES);
globalThis.crypto.getRandomValues(ivPrefix);
let counter = 0n;
return {
/** @param {Uint8Array} plaintext */
async encrypt(plaintext) {
if (plaintext.length > MAX_PLAINTEXT_FRAME_BYTES) {
throw new RelayCryptoError('plaintext frame exceeds maximum size');
}
counter += 1n;
const iv = new Uint8Array(ENCRYPTED_FRAME_IV_BYTES);
iv.set(ivPrefix, 0);
writeCounter(iv, IV_PREFIX_BYTES, counter);
const ciphertext = new Uint8Array(await subtle.encrypt({ name: 'AES-GCM', iv }, key, plaintext));
const frame = new Uint8Array(ENCRYPTED_FRAME_HEADER_BYTES + ciphertext.length);
frame[0] = ENCRYPTED_FRAME_VERSION;
frame.set(iv, 1);
frame.set(ciphertext, ENCRYPTED_FRAME_HEADER_BYTES);
return frame;
},
};
};
// Enforces strictly increasing per-direction counters: the relay WS preserves
// ordering, so any regression or replay means tampering and must fail closed.
/** @param {CryptoKey} key AES-256-GCM key for this direction */
export const createFrameDecryptor = (key) => {
let lastCounter = 0n;
return {
/** @param {Uint8Array} frame */
async decrypt(frame) {
if (frame.length < ENCRYPTED_FRAME_HEADER_BYTES + GCM_TAG_BYTES) {
throw new RelayCryptoError('encrypted frame too short');
}
if (frame[0] !== ENCRYPTED_FRAME_VERSION) {
throw new RelayCryptoError('unsupported encrypted frame version');
}
const iv = frame.slice(1, ENCRYPTED_FRAME_HEADER_BYTES);
const counter = readCounter(iv, IV_PREFIX_BYTES);
if (counter <= lastCounter) {
throw new RelayCryptoError('frame counter regression');
}
let plaintext;
try {
plaintext = await subtle.decrypt({ name: 'AES-GCM', iv }, key, frame.slice(ENCRYPTED_FRAME_HEADER_BYTES));
} catch {
throw new RelayCryptoError('frame decryption failed');
}
lastCounter = counter;
return new Uint8Array(plaintext);
},
};
};
const BASE64URL_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
/** @param {Uint8Array} bytes */
export const bytesToBase64Url = (bytes) => {
let out = '';
for (let i = 0; i < bytes.length; i += 3) {
const b0 = bytes[i];
const b1 = i + 1 < bytes.length ? bytes[i + 1] : undefined;
const b2 = i + 2 < bytes.length ? bytes[i + 2] : undefined;
out += BASE64URL_ALPHABET[b0 >> 2];
out += BASE64URL_ALPHABET[((b0 & 0x03) << 4) | ((b1 ?? 0) >> 4)];
if (b1 !== undefined) out += BASE64URL_ALPHABET[((b1 & 0x0f) << 2) | ((b2 ?? 0) >> 6)];
if (b2 !== undefined) out += BASE64URL_ALPHABET[b2 & 0x3f];
}
return out;
};
/** @param {string} value */
export const base64UrlToBytes = (value) => {
if (!/^[A-Za-z0-9_-]*$/.test(value) || value.length % 4 === 1) {
throw new RelayCryptoError('invalid base64url input');
}
const out = new Uint8Array(Math.floor((value.length * 3) / 4));
let outIndex = 0;
let buffer = 0;
let bits = 0;
for (const char of value) {
buffer = (buffer << 6) | BASE64URL_ALPHABET.indexOf(char);
bits += 6;
if (bits >= 8) {
bits -= 8;
out[outIndex] = (buffer >> bits) & 0xff;
outIndex += 1;
}
}
return out;
};
// ---------------------------------------------------------------------------
// Responder handshake state machine (host side). Mirror of createHostHandshake
// in packages/ui/src/lib/relay/handshake.ts.
//
// Fail-closed rules (from the spec):
// - a repeated identical `hello` re-sends `ready` (client retry race);
// - a `hello` with a DIFFERENT key on an established channel is a rekey
// attack -> close 1008, never rekey in place;
// - plaintext after `ready`, or any decrypt failure -> close 1011.
// ---------------------------------------------------------------------------
const parseHandshakeMessage = (raw) => {
let parsed;
try {
parsed = JSON.parse(raw);
} catch {
return null;
}
if (typeof parsed !== 'object' || parsed === null) return null;
if (parsed.v !== RELAY_PROTOCOL_VERSION) return null;
// Unknown/missing capability flag = false = legacy behavior.
const batch = parsed.batch === true;
const flowControl = parsed.flowControl === true;
if (parsed.t === 'ready') {
return { t: 'ready', v: RELAY_PROTOCOL_VERSION, batch, flowControl };
}
if (parsed.t === 'hello' && typeof parsed.nonce === 'string' && typeof parsed.clientPubJwk === 'object' && parsed.clientPubJwk !== null) {
return { t: 'hello', v: RELAY_PROTOCOL_VERSION, clientPubJwk: parsed.clientPubJwk, nonce: parsed.nonce, batch, flowControl };
}
return null;
};
const failClosed = (reason) => ({
type: 'fail',
closeCode: RelayCloseCode.ChannelFailure,
reason,
});
/**
* Host (responder) handshake. Feed every inbound text frame to `handleText`;
* it returns one of:
* { type: 'send-text', text } — send this plaintext frame
* { type: 'established', channel, replyText } — send replyText first, then switch to encrypted frames
* { type: 'ignore' } — drop the frame
* { type: 'fail', closeCode, reason } — close the socket with closeCode
* @param {CryptoKey} hostEncPrivateKey long-lived ECDH private key
* @param {{ batch?: boolean, flowControl?: boolean }} [options] Capabilities default true.
*/
export const createHostHandshake = (hostEncPrivateKey, options = {}) => {
const localBatch = options.batch !== false;
let established = false;
let acceptedClientKeyFingerprint = null;
let readyText = null;
let negotiatedBatch = false;
return {
get established() {
return established;
},
/** @param {string} raw */
async handleText(raw) {
const message = parseHandshakeMessage(raw);
if (message?.t !== 'hello') {
if (established) {
return failClosed('plaintext frame on established channel');
}
return { type: 'ignore' };
}
const fingerprint = publicKeyJwkFingerprint(message.clientPubJwk);
if (acceptedClientKeyFingerprint !== null) {
if (fingerprint === acceptedClientKeyFingerprint && readyText !== null) {
// Client retried `hello` before our `ready` arrived — answer again.
return { type: 'send-text', text: readyText };
}
return { type: 'fail', closeCode: RelayCloseCode.RekeyMismatch, reason: 'rekey mismatch' };
}
let clientPublicKey;
let nonce;
try {
clientPublicKey = await importEcdhPublicKey(message.clientPubJwk);
nonce = base64UrlToBytes(message.nonce);
} catch {
return failClosed('malformed hello');
}
let keys;
try {
keys = await deriveSessionKeys(hostEncPrivateKey, clientPublicKey, nonce);
} catch {
return failClosed('key derivation failed');
}
acceptedClientKeyFingerprint = fingerprint;
// Batching runs only if both peers advertised it.
negotiatedBatch = localBatch && message.batch === true;
const flowControl = options.flowControl !== false && message.flowControl === true;
const ready = { t: 'ready', v: RELAY_PROTOCOL_VERSION };
if (negotiatedBatch) ready.batch = true;
if (flowControl) ready.flowControl = true;
readyText = JSON.stringify(ready);
established = true;
return {
type: 'established',
batch: negotiatedBatch,
flowControl,
replyText: readyText,
channel: {
encryptor: createFrameEncryptor(keys.hostToClient),
decryptor: createFrameDecryptor(keys.clientToHost),
},
};
},
};
};