import type { WebSocket } from "ws"; import { type E2eeContext } from "./e2ee/context"; import type { WSMessage } from "./types"; /** * The one interval both liveness signals run on. It must stay under * `CLIENT_SILENCE_TIMEOUT_MS`. * * Two pings leave this timer per sweep and they are **not** redundant. The * WebSocket PROTOCOL ping proves the TCP connection is alive to the socket * layer, and is handled below `onmessage` — React Native's JS layer never sees * it. The app-level `{ type: "ping" }` frame is the only liveness signal the * client's silence timer can observe. Without it an idle-but-alive session * redials every `CLIENT_SILENCE_TIMEOUT_MS`: measured on hardware at 3.1 * context opens per minute against a limit of 5 per device per minute, 62 % of * the budget spent while nobody touched the phone (tb-mobile #946). * * **One timer rather than two.** A single schedule cannot drift against itself, * there is one place to change the cadence, and the ordering argument on * `sendTo` has to hold for one call site instead of two. */ export declare const PING_INTERVAL_MS = 30000; /** * The client's silence timer, mirrored here because this file's cadence is only * correct relative to it — `WS_SILENCE_TIMEOUT_MS` in tb-mobile * `hooks/useTerminalStream.ts`. A client that receives nothing for this long * calls `forceReconnect`, and against a pinned server every reconnect is a * fresh Noise handshake charged to a 5-per-minute-per-device limit. * * Nothing reads it at runtime. It is here so a test can assert * `PING_INTERVAL_MS` stays under it: raising the cadence past this window * silently restores the churn the app-level ping was added to stop, and no * other part of the server would notice. */ export declare const CLIENT_SILENCE_TIMEOUT_MS = 45000; /** * How long a sealed socket has to prove it holds the keys (NONCE-DESIGN §10). * * The clock starts at the 101 and stops on the FIRST frame that unseals — **any * valid sealed inbound frame, never a particular message type.** The client * contract says a socket sends `register` promptly, and that is one way for a * client to satisfy this; making it the server's condition would tie the server * to a message name it does not need and break the day a client legitimately * sends something else first. * * It exists because of the ticket thief. An intermediary that spends the * client's ticket first holds a socket bound to a context whose keys it does not * have: it gets no plaintext — every frame is sealed to keys it lacks — but it * occupies a hub slot and receives sealed broadcasts, and **the existing ping * reaper cannot evict it, because answering a pong costs it nothing.** Silence * is precisely what it is good at. This is the only clock that runs on a socket * that never speaks. * * **Ten seconds, and 15 s — the client's own connect timeout — is the only * permitted relaxation. Never lower**: a real phone on a bad network has to fit * an upgrade and one frame inside it. */ export declare const WS_FIRST_FRAME_DEADLINE_MS = 10000; export declare class WSHub { private clients; private pingTimer; private pongTimers; /** * The E2EE context of every sealed socket. Absent for a legacy `?key=` client. * * PER SOCKET, not per device (NONCE-DESIGN §8). That is what forces N seals * for N sockets below: the three send paths used to `JSON.stringify` once and * hand the same bytes to every client, and the same bytes cannot be sealed to * N different keys under N independent counters. * * A socket absent from this map is a legacy plaintext client and keeps * working exactly as it does today — dual paths are the whole reason a * released app survives this change. */ private contexts; /** * Every socket that has EVER held a context. * * `contexts` is cleared the moment a socket is closed for a policy violation * or a revocation, and `close()` is not instantaneous — a frame already in * the receive buffer still arrives. Without this set that frame would find no * context and be handled as a LEGACY PLAINTEXT one: a sealed socket * downgrading itself to cleartext in the window after it was cut off, which * is the one thing a dual-path design must never do. */ private everSealed; /** Per-socket "prove you hold the keys" deadline; cleared on the first unseal. */ private unprovenTimers; private readonly firstFrameMs; /** * `firstFrameMs` is a test seam and nothing else: the deadline it defaults to * is the contract, and a suite cannot spend ten seconds per case proving a * socket went away. A test asserts the DEFAULT is the contract's value, so * lowering the constant is still caught. * * **Read with `own()`, never `??`.** `server.ts` constructs `new WSHub()`, so * `options` is `{}` — an object that carries `Object.prototype`, and `??` * reads straight through it. A single `Object.prototype.firstFrameMs` * anywhere in the process then sets this deadline, in either direction and * both behavioural: lengthened, the ticket-thief defence stops firing * entirely, because the ping reaper cannot evict a socket that answers * pongs; shortened below the floor, every legitimate socket is reaped before * a phone on a bad network can fit an upgrade and one frame inside it. * * `own()` asks the object and nothing above it. `context.ts` reads `now` this * way and `record.ts` reads `initialCounter` this way, for exactly this * reason; this call site is the one that did not. */ constructor(options?: { firstFrameMs?: number; }); addClient(ws: WebSocket, context?: E2eeContext): void; broadcast(message: WSMessage): void; broadcastToClients(clients: Iterable, message: WSMessage): void; unicast(ws: WebSocket, message: WSMessage): void; /** * Seal for THIS socket, then send. One synchronous step, with no `await` * anywhere between the two (NONCE-DESIGN §14). * * An await between `seal` and `ws.send` reorders frames — two sends resumed * in the wrong order put counter 8 on the wire before counter 7 — and the * peer's strict `counter == expected` (§5 R2) then closes the socket. The * `await getOutputLines(...)` on the `terminal_replay` path is upstream of * this method for exactly that reason: it finishes, and only then is anything * sealed. * * Returns false when the caller should drop this client. * * The plaintext Buffer is memoised across a broadcast because it is the same * bytes for every recipient; the SEAL is not, and cannot be — N sockets means * N keys, N counters and N distinct nonces. `seal` does not mutate its * argument, so one Buffer feeds every seal. * * An app-level `{ type: "ping" }` frame — emitted by `startPing` — is sealed * here like every other frame and consumes a counter. That is correct and * costs nothing, and it is written down because WebSocket PROTOCOL pings are * invisible to React Native's JS layer — the client's silence timer depends on * the app-level ping continuing to exist, so nobody should optimise it away on * the grounds that the protocol already has one (NONCE-DESIGN §18). */ private sendTo; /** * Decode one client→server frame: unseal it for a sealed socket, pass it * through for a legacy one. * * Returns null when the frame was refused — the socket has already been * closed with the §9 code that says why, and the caller drops the frame. A * refusal is never silent: `E2EE_SEQUENCE_VIOLATION` is a claim about the * peer, `E2EE_SEAL_FAILED` is a fault on this side, and the two must not * arrive as the same "nothing happened". */ receive(ws: WebSocket, raw: unknown): string | null; /** Close every socket bound to one of these explicit context handles. */ closeContexts(ctxIds: Iterable): number; /** Close every sealed socket the hub owns for one device. */ closeDevice(deviceId: string): number; /** * A socket's close destroys ITS OWN context and nothing else. * * Never the device's REST context (§8): the 2 s HTTP replay fallback runs * precisely when the socket is down, so a REST context that died with the * socket would take the fallback with it. A reconnect is a new * `POST /api/e2ee/open`, not a resurrection of this one. */ private clearUnproven; private forgetContext; private closeForE2ee; get connectionCount(): number; /** Sealed sockets. Tests and diagnostics — never a key, never a `ctxId`. */ get sealedCount(): number; dispose(): void; /** * The maintenance sweep: both liveness pings, and the stale-context check. * * **The callback must stay synchronous.** `sendTo` seals and sends in one * synchronous step precisely so no `await` can sit between the two and reorder * counters (see `sendTo`), and that guarantee is what makes a periodic sender * safe at all: a synchronous block runs to completion, so two sends cannot * interleave and counter order is wire order. Making this callback `async` * would create the hazard `sendTo` documents rather than inherit its absence. * * **Ordering inside the loop is load-bearing.** The app-level ping is emitted * only for a socket that has already passed the stale-context and `readyState` * guards. Sealing on a registry-invalidated context throws, so emitting above * the stale-context guard would attempt a send on a socket this sweep has * already decided to close, and report the refusal as `phase: "send"` rather * than `phase: "maintenance"` — the sweep's own verdict, logged as if the * frame had been at fault. The §9 code is unaffected either way, since * `sendState` on an invalidated context raises `E2EE_CTX_UNKNOWN` itself. */ private startPing; } //# sourceMappingURL=ws-hub.d.ts.map