import { type DeviceRow } from "../db/repositories/devices.repository"; import type { Principal } from "../services/security/capabilities"; import type { TrafficKeys } from "./noise"; import { type Channel, type RecordState } from "./record"; /** * The header a client presents its single-use WebSocket ticket in. * * **A header, never a query parameter** (§10). `?ticket=` lands in every ingress * access log — Cloudflare logs full request URLs — and single-use plus thirty * seconds bounds that damage without removing it. React Native's `WebSocket` * takes custom headers, so the ticket never needs to touch a URL at all; a * browser, which cannot, uses `Sec-WebSocket-Protocol` instead (below). The * property that buys is stronger than redaction: there is nothing to redact, * because `http.request` logs a method, a path and a summarised query, never a * header. * * It lives HERE rather than beside the route because `auth.middleware.ts` reads * it too — a ticketed upgrade authenticates by its ticket (§13) — and a route * module is not something the auth middleware should have to import. */ export declare const TICKET_HEADER = "x-tb-ticket"; /** * The browser spelling of the same ticket: a `Sec-WebSocket-Protocol` offer. * * A browser `WebSocket` cannot set `X-TB-Ticket`, but `new WebSocket(url, * protocols)` sets `Sec-WebSocket-Protocol`, which is still a header — so "never * a query parameter" holds for browsers too. The client offers exactly * `threadbase-e2ee-v1, tb-ticket.`; the server consumes the ticket with * the header path's semantics and selects ONLY `E2EE_WS_SUBPROTOCOL` in the 101 * (`mountWebSocket` pins that), because `ws` would otherwise echo the first * offer and a browser that offered protocols fails a 101 that selects none. * A ticket (22 base64url characters) is a valid RFC 6455 token as it stands. */ export declare const E2EE_WS_SUBPROTOCOL = "threadbase-e2ee-v1"; export declare const TICKET_SUBPROTOCOL_PREFIX = "tb-ticket."; /** * `Sec-WebSocket-Protocol` as a list, or `null` where no socket can follow: an * offer `ws` would refuse (a non-token or a duplicate), or a `tb-ticket.` offer * without `E2EE_WS_SUBPROTOCOL`, for which the 101 selects nothing and a browser * drops the socket. Checked BEFORE a ticket is consumed: `ws` parses the header * only after the app has answered, so either offer would otherwise spend a * ticket, promote its context, and open no socket. */ export declare function parseSubprotocols(header: string | undefined): string[] | null; /** §8: a provisional context, and its ticket, die at 30 s. */ export declare const TICKET_TTL_MS = 30000; /** A socket outlives this only if it is still open; it is a backstop. */ export declare const WS_CONTEXT_TTL_MS: number; /** §8: a REST context is destroyed at 24 h and the client re-opens. */ export declare const REST_CONTEXT_TTL_MS: number; /** §8: cap live contexts per device, evicting by usefulness. */ export declare const MAX_WS_CONTEXTS_PER_DEVICE = 4; export declare const MAX_REST_CONTEXTS_PER_DEVICE = 2; /** * How long an evicted context keeps answering before it is swept (§8). * * "Eviction honours the drain": a context destroyed the instant its * replacement registers kills a request that is in flight on it, which is the * opposite of the short drain §6 promises. Ten seconds is far longer than any * REST round trip on this product and far shorter than the 30 s provisional * TTL, so a drained context never outlives the window it was evicted in. * NONCE-DESIGN names the rule and not a number; this is the number. */ export declare const CONTEXT_DRAIN_MS = 10000; export type ContextKind = "ws" | "rest"; /** * A fresh context handle: 16 random bytes, base64url, 22 characters. * * **Server-assigned, never derived** (§12). The earlier * `HKDF(h_ss, "tb-e2ee-ctx-id", 16)` was fine as a server-side detail and wrong * as a contract: it pinned no salt/info/IKM roles, the client has no HKDF of * that shape, and since the server returns `ctxId` in msg2 anyway a deriving * client would hold a second source of truth to disagree with. It was also * circular in practice — the transcript hash a client would derive from is * computed over the very payload the `ctxId` has to travel in. */ export declare function newCtxId(): { raw: Buffer; id: string; }; /** * When a context of this kind, opened now, stops resolving — before it has been * used for anything. * * Every context starts PROVISIONAL and dies at the ticket TTL (§8). An `IK` * msg1 carries no freshness, so anyone who captured one valid `/open` msg1 can * replay it: each replay passes "fail closed on the device row", because the * static key genuinely is a known device, and allocates a context and a ticket * for two DH and one AEAD. The attacker never gets keys — msg2 needs `D_priv` — * so this is pure allocation, the D-9 class, on a public endpoint. Without this * rule a socket context whose ticket is never consumed has no end of life at * all. */ export declare function provisionalExpiresAt(now: number): number; /** When a context that HAS been used stops resolving. */ export declare function contextExpiresAt(kind: ContextKind, now: number): number; export interface E2eeContext { /** The wire handle: base64url, 22 characters. What `X-TB-Ctx` carries. */ readonly ctxId: string; /** The same value as the 16 raw bytes the AAD binds. */ readonly ctxIdRaw: Buffer; readonly deviceId: string; readonly kind: ContextKind; readonly createdAt: number; /** Moves out to the full lifetime once the context is first used (§8). */ readonly expiresAt: number; /** True until the ticket is consumed or a request unseals under it (§8). */ readonly provisional: boolean; /** Set when the context has been evicted and is draining; null otherwise (§8). */ readonly retireAt: number | null; /** The moment this context stops resolving: its expiry, or its drain deadline. */ deadline(): number; /** The sending half for a channel. Throws for a channel this kind does not carry. */ sendState(channel: Channel): RecordState; /** The receiving half for a channel. Throws for a channel this kind does not carry. */ receiveState(channel: Channel): RecordState; /** REST only: unseal a request and record its counter as answerable (§13(a)). */ unsealRequest(frame: Buffer, target: Buffer): Buffer; /** REST only: seal the one response that request is owed (§13(a)). */ sealResponse(requestCounter: bigint, plaintext: Buffer, target: Buffer): Buffer; /** First authenticated use. Promotes out of provisional. */ markUsed(now?: number): void; } /** What registry destruction reports to the caller (§8). */ export interface DestroyedContexts { /** WS contexts that were still indexed when destruction began. */ socketCtxIds: string[]; restCtxIds: string[]; /** Unconsumed tickets dropped, so a revoked device cannot still upgrade. */ tickets: number; } /** * Every live context on this process, and the WS tickets bound to them. * * In-memory by design (§8). One instance per server — `contextRegistry()` below * — because the `/api/e2ee/open` route writes to it and the WebSocket upgrade * and the REST middleware read from it, and a second registry would mean a * context that exists for one of them and not the others. */ export declare class E2eeContextRegistry { #private; constructor(); /** Live contexts. For tests and for a future diagnostics line — never a key. */ get size(): number; /** Unconsumed, unexpired tickets. Tests only. */ get ticketCount(): number; open(args: { deviceId: string; kind: ContextKind; ctxIdRaw: Buffer; ctxId: string; keys: TrafficKeys; now?: number; }): E2eeContext; /** * Resolve a `ctxId`, or `null` for one that is unknown, expired, or lost to a * restart — all three of which the caller reports as `E2EE_CTX_UNKNOWN`, * because all three are recoverable by one transparent re-handshake and none * of them is a revocation the client must surface (§9). * * The map lookup IS the first thing that runs, and nothing is allocated on * the way to a rejection (§10). This is called before authentication, on a * value an attacker chose. */ get(ctxId: string, now?: number): E2eeContext | null; /** Every live context for a device. */ forDevice(deviceId: string): E2eeContext[]; /** * Destroy one context and any ticket bound to it. * * The socket's close calls this for its own context and NOTHING else: a * device's REST context is unaffected by its socket going away, which is the * whole reason there are two (§8). */ destroy(ctxId: string): boolean; /** * Release one exact context owner without deleting a replacement that reused * the same identifier. */ destroyOwned(context: E2eeContext): boolean; /** * Destroy every context for a device and report what the caller must finish. * * `POST /api/devices/:id/revoke` calls this before the socket owner closes * every hub reference for the device (design.md §4.4, point 3). The returned * ids are accounting, not the close list: a drained context can already be * absent here while its socket is still attached to the hub. */ destroyDevice(deviceId: string): DestroyedContexts; /** * Mint a single-use, 30-second WS ticket bound to a `ctxId`. * * Issued INSIDE the encrypted msg2 payload, so the long-term credential never * appears in a URL again — and §10 asks the client to carry it in a WebSocket * header rather than a query parameter, because a URL lands in every ingress * access log. * * Deliberately independent of whether the context is registered yet: * `/api/e2ee/open` has to name the ticket in the payload it is about to seal, * and the traffic keys the context needs only exist once that message has * been written. A ticket whose context never materialised resolves to a * `ctxId` the registry does not know, which is the ordinary * `E2EE_CTX_UNKNOWN` path. */ issueTicket(ctxId: string, now?: number): string; /** * Spend a ticket. Returns its `ctxId` exactly once; every later call — and * every concurrent one, since this is synchronous and Node runs it to * completion — gets `null`. * * Consuming a ticket IS the socket context's first authenticated use, so it * promotes the context out of provisional (§8). */ consumeTicket(ticket: string, now?: number): string | null; /** Drop everything. A streamer restart does this by existing; tests need a call. */ clear(): void; private ticketsFor; /** Drop every context of one device whose deadline has passed. */ private sweepDevice; private sweepTickets; } export declare function contextRegistry(): E2eeContextRegistry; export interface E2eeRequiredRefusal { status: 426; body: { error: string; code: "E2EE_REQUIRED"; }; } /** * The 426 answer, in ONE place. * * A device that has once paired encrypted is pinned (`e2ee_required`), and a * pinned device must never be served plaintext: it gets `426`, never a `401` * and never a plaintext answer (design.md §6.3, §8). The WebSocket upgrade and * the REST unseal middleware are both later PRs and both consume this — neither * re-implements it, because two copies of a downgrade rule is one copy that can * be forgotten. * * Returns `null` when the request is fine: it was sealed, or the caller is not * a pinned device. An unpinned device and the legacy shared key keep working * exactly as they do today. * * **The limit, stated rather than discovered later.** The pin is per DEVICE, so * this can only enforce it against a caller that resolved to a device * principal. A pinned phone that presents the SHARED api key resolves to * `legacy` — indistinguishable from the owner's laptop — and is let through * here. Closing that is the WebSocket upgrade's job, where `?key=` and a ticket * are separable, and the REST middleware's, where a pinned device has an * `X-TB-Ctx` to be absent. */ export declare function refuseUnsealedIfPinned(args: { principal: Principal | null | undefined; /** Just the lookup — the caller already holds the repository. */ devicesRepo: { get(deviceId: string): DeviceRow | null; } | null | undefined; /** The context this request resolved to; `null` means the request was plaintext. */ context: E2eeContext | null | undefined; }): E2eeRequiredRefusal | null; /** * What a caller needs from `devicesRepo` to authenticate a context. * * Structural, not the concrete repository: the two callers hold it through * different dependency records, and a narrow shape is also what lets a test * drive the refusals without a database. */ export interface DeviceLookup { get(deviceId: string): DeviceRow | null; authenticate(credential: string): DeviceRow | null; } /** * A `Principal` with its `deviceId` present. * * `Principal.deviceId` is optional, because a `legacy` principal has none. A * context always names a device, so this is the honest return type — and it is * what lets a caller read `principal.deviceId` without a cast or a `?.`, which * matters because that field is the one the invariant is stated over. */ export type DevicePrincipal = Principal & { kind: "device"; deviceId: string; }; /** * The verdict `authenticateContext` returns. **Frozen at W1b's merge**: the * REST unseal middleware is built against this exact text, so a variant renamed * here is a coordinated change in two tracks, not a refactor. * * A failure carries a `reason` and nothing else — no status, no body, no * message. The two callers answer the same verdict differently: REST maps it to * an HTTP status, the WebSocket upgrade maps it to a close reason. Putting HTTP * policy inside a two-consumer helper is how one caller's answer silently * becomes the other's. * * The reasons, and what each one covers — stated because each is a fail-open * path a consumer cannot close from its own side once this freezes: * * - **`device-revoked`** — the context names a device whose row is **missing** * OR whose `revoked_at` is set. §10: absent is not the same as invalid, and * neither is success. Do not read "revoked" narrowly and add a row-not-found * success path; a context whose device has vanished authenticates nobody. * - **`credential-mismatch`** — a credential was presented beside the context * and does not name the context's device. **Including one that names NO * device**: the shared API key is a mismatch, not an exemption. "Names * another device" read literally would exclude the case that matters most. * - **`no-device-store`** — there is no device registry, or reading it threw. * A refusal, never a success: *a downgrade guard that defaults to allowing * the downgrade is not a guard.* * * **Its own arm, deliberately, and not folded into `device-revoked`.** The * two are not the same fact: "this device is revoked" is a statement about * the DEVICE, and "I could not consult the store" is a statement about US. * Collapsing them tells the caller — and every log built from the caller — * that a device was revoked when what actually happened is that our own * storage was unreadable. That is precisely the defect §9 split * `E2EE_SEAL_FAILED` from `E2EE_SEQUENCE_VIOLATION` to prevent: a * server-side fault reported as a claim about the peer. Both arms still * refuse and both are terminal, so nothing about behaviour changes — only * what the caller is told, which is the entire point. * * "No such context" is deliberately NOT a reason. Both callers resolve the * context before calling, so a not-found is theirs to answer — and taking the * resolved object also closes a race the `ctxId` shape had, where a context * could expire between the caller's own lookup and the helper's. */ export type E2eeContextAuth = { ok: true; principal: DevicePrincipal; } | { ok: false; reason: "device-revoked" | "credential-mismatch" | "no-device-store"; }; /** * Turn a resolved context into the principal the request runs as — or refuse. * * **The tail every sealed entry point shares**, in one place because it has two * callers: the WebSocket upgrade resolves its context from a single-use * `X-TB-Ticket`, the REST unseal middleware resolves its from `X-TB-Ctx`, and * from that point on the decision is identical. Forking a copy is how the two * would come to disagree about which of them re-checks `revoked_at`. * * The property it exists to create, stated so a test can assert it: * * > **A context-attached connection's `principal.deviceId` always equals its * > `context.deviceId`**, and a credential presented beside a context must name * > that same device or the connection is refused. * * The principal is therefore built from the CONTEXT's own device row and never * from the credential — there is no path here on which the two can differ, * which is stronger than checking that they agree. * * Three refusals, all fail-closed: * * - **no row, or `revoked_at` set** → `403 E2EE_DEVICE_REVOKED`. Re-checked * per connection rather than trusted from the handshake: a device revoked * between `/api/e2ee/open` and its next request holds a handle that is * still valid on its face (§10). Absent and revoked are the same refusal * and neither is success; * - **a credential naming another device** → `401`. "Header device ≠ context * device" must not be undefined behaviour at a trust boundary: two answers * to "who is this" is not a request to resolve by preferring one; * - **the SHARED api key beside a context** → `401` too. It names no device, * so it is a mismatch rather than an exemption — the one reading of this * rule that would otherwise let the stage-3 shared-key problem through a * door that had just been closed. * * **This function has no side effects: it returns a verdict and never applies * one.** The rule its callers follow, which is the reason and not merely the * behaviour: * * > **Destroy a context only when the trigger is a fact in our own database * > that an attacker cannot forge — `revoked_at`. Never on a mismatched * > credential, which is an attacker-supplied header.** * * `X-TB-Ctx` carries the `ctxId` in a PLAINTEXT header on every sealed request, * so it is visible to exactly the on-path party this design assumes exists. * Destroying on a mismatch would let anyone who sees one request kill that * device's context over and over: forge a credential beside the observed id, * watch the victim re-open, read the new id, repeat. The safeguard becomes the * weapon. * * The property has to hold at ANY placement on EITHER channel, so it is this * function's job not to destroy rather than each caller's job to remember. A * refusal reports its `reason`, and a caller destroys on `device-revoked` and * on nothing else. */ export declare function authenticateContext(args: { /** Already resolved by the caller — from a spent ticket, or from `X-TB-Ctx`. */ context: E2eeContext; devicesRepo: DeviceLookup | null | undefined; /** * The credential presented beside the context, if any. * * `undefined` is the ORDINARY case and an ordinary success: §13(b) says a * sealed REST request carries no `Authorization` at all, and a ticketed * upgrade carries none either. The mismatch check is conditional on a * credential being present; its absence is never a special case. */ presented: string | undefined; }): E2eeContextAuth; //# sourceMappingURL=context.d.ts.map