import { type KeyObject } from "crypto"; import { type E2eeRejectionCode } from "./protocol"; /** Client → server. The value is the first 4 bytes of every c2s nonce. */ export declare const DIRECTION_C2S = 1; /** Server → client. */ export declare const DIRECTION_S2C = 2; export type Direction = typeof DIRECTION_C2S | typeof DIRECTION_S2C; export declare const CHANNEL_WS = 1; export declare const CHANNEL_REST_REQUEST = 2; export declare const CHANNEL_REST_RESPONSE = 3; export type Channel = typeof CHANNEL_WS | typeof CHANNEL_REST_REQUEST | typeof CHANNEL_REST_RESPONSE; export declare const KEY_BYTES = 32; export declare const CTX_ID_BYTES = 16; export declare const TAG_BYTES = 16; export declare const NONCE_BYTES = 12; /** `version(1) || ctxId(16) || direction(4) || counter(8) || channel(1)` (§4). */ export declare const HEADER_BYTES: number; /** The REST AAD suffix: `sha256(method || "\n" || path || "\n" || query)` (§4). */ export declare const TARGET_HASH_BYTES = 32; /** * The counter ceiling. A sender AT this value refuses rather than wrapping (§7). * * Unreachable in practice — at D-3's measured ~1.6 MB/s budget it is on the * order of 10^11 years — and asserted precisely so it can never become a silent * wrap. The cost of refusing at `2^64 - 1` rather than after it is one unused * counter value out of 2^64. * * The refusal leaves the state unchanged, so there is no recovery that keeps * the context: the caller destroys it and the client opens a new one (§7). */ export declare const MAX_COUNTER: bigint; /** * Ceiling on a frame, checked before anything is parsed or decrypted. * * NONCE-DESIGN does not name a number; this one is a size no legitimate frame * reaches (the largest thing the hub sends is a terminal replay, tens of KB) * while staying far enough above it that a bound is never the reason a real * message fails. Same shape as `NOISE_MAX_MESSAGE_BYTES`: bound first, allocate * second, never allocate in proportion to an attacker-supplied length (D-9). * * §10 is explicit that on the WebSocket this is a check AFTER allocation — * `@hono/node-ws` assembles the frame with `ws`'s 100 MiB default before any of * this runs — and that closing that gap is W1b's, with its own per-direction * ceilings. This constant is not that bound and must not be mistaken for it. */ export declare const MAX_RECORD_BYTES: number; export declare class RecordError extends Error { readonly code: E2eeRejectionCode; constructor(code: E2eeRejectionCode, message: string); } /** * `direction(4) || counter(8)`, big-endian, and never random (§2). * * A counter makes nonce reuse an invariant a test asserts on rather than a * birthday bound argued about in review (D-2). Each direction has its own key * AND its own label, so a record can never be reflected back at its sender: the * reflected frame is decrypted with the wrong key *and* carries the wrong * direction in both its nonce and its AAD. */ export declare function recordNonce(direction: Direction, counter: bigint): Buffer; /** * The REST AAD suffix (§4): `sha256(method || "\n" || path || "\n" || query)`. * * Paths and query stay plaintext (D-7), so without this nothing in the AAD * binds *what a sealed body is for*: an on-path attacker re-points a sealed * `POST /api/sessions/A/input` at session B, the body authenticates, and the * server runs the user's own keystrokes against a different session. Same for * `/cancel`, `/stop`, `/permission/answer` and `prune_all`. * * It is computed by both sides from the request line and never transmitted, so * the wire header stays 30 bytes. * * `query` is the raw query string WITHOUT the leading `?`, empty when there is * none — the two sides must agree on that spelling exactly or every sealed * request fails to authenticate with no other diagnostic. */ export declare function restTargetHash(method: string, path: string, query: string): Buffer; /** * The target hash for a request, taken from the RAW wire request-target. * * **This is the `ctxId`-encoding trap one layer down** (§4), so the inputs are * pinned rather than described: an implementation that normalises anything here * rejects a legitimate request with `E2EE_SEAL_FAILED` and nothing else to * debug it. * * method upper-case ASCII, as sent * path the raw request-target path — percent-encoding PRESERVED, never * decoded, never normalised. `/api/conversations/a%2Fb` and * `/api/conversations/a/b` are different targets and hash differently * query the raw substring after `?`, verbatim: original parameter order, * original `+` vs `%20`, duplicates kept, nothing sorted or * re-serialised. The empty string when there is no `?` * * The server MUST read this from `c.env.incoming.url` — the bytes Node received * — and NEVER from Hono's `c.req.path`, which is percent-decoded, nor from a * re-serialised `URLSearchParams`, whose ordering and escaping do not round * trip. * * **The client hashes the ORIGIN-FORM target, not the absolute URL it fetches.** * That is `/api/sessions?limit=50`, never `https://host/api/sessions?limit=50`: * scheme, host and port are not in the hash. A client that passes the URL it is * about to fetch produces a different digest for every request, and each one * fails with `E2EE_SEAL_FAILED` and nothing else to debug — the precise trap §4 * exists to prevent, reintroduced by a sentence. The fixture pins it: * `restTargetCanonicalization.hashInputUtf8` in * `__tests__/fixtures/e2ee-record-vectors.json` begins with the method and a * bare `/`. */ export declare function restTargetHashFromUrl(method: string, rawUrl: string): Buffer; export interface RecordHeader { version: number; /** * Any byte view, not only a `Buffer`. A `@stablelib`-based client hands over * plain `Uint8Array`s and §13 has it calling this builder directly. */ ctxId: Uint8Array; direction: Direction; counter: bigint; channel: Channel; } /** * The AAD: the 30-byte plaintext header, plus the 32-byte target hash on the * REST channels (§4). * * The header travels in the clear and is authenticated, so an intermediary can * neither rewrite a sequence number nor re-point a record at another context. * * Exported because the interop fixtures publish it and a client implementation * has to reproduce it byte for byte. */ export declare function recordHeader(header: RecordHeader): Buffer; /** * The AAD: the 30-byte header, plus the 32-byte target hash on the REST * channels (§4). * * **This function enforces the target rule itself.** The client track consumes * the AAD BUILDER, not the wrapper one layer up, so a rule checked only in * `assertTarget` is a rule that implementation never receives — and a forgotten * target then yields a silently unbound AAD on the two channels that exist to * bind one. `recordHeader` above is the wire bytes and carries no such rule, * because a header is not an AAD. */ export declare function recordAad(header: RecordHeader, target?: Uint8Array): Buffer; /** Whether a channel's records bind a request target (§4). */ export declare function channelBindsTarget(channel: Channel): boolean; export interface RecordStateOptions { /** 32-byte traffic key for THIS direction, or one already imported. */ key: Buffer | KeyObject; /** The context handle, raw 16 bytes. */ ctxId: Buffer; direction: Direction; channel: Channel; /** * INTERNAL — tests only. A construction-time counter seed, and the one narrow * exception NONCE-DESIGN §5 R4 states explicitly: the §7 exhaustion test has * to place a counter near `2^64 - 1`, which it cannot do a frame at a time. * * This is not the forbidden shape. The seed sets a starting point ONCE, at * construction; `seal` and `unseal` still take no counter and remain the sole * advancers. A `seal(counter, …)` signature stays forbidden. */ initialCounter?: bigint; } /** * One direction of one channel of one context: a key, a label, and the counter * that belongs to them. * * A state both seals and unseals with its single counter, because a state is * one direction — the sending side calls `seal`, the receiving side calls * `unseal`, and neither ever calls the other. Two states per channel, built by * `context.ts`, is what keeps the two counters independent. */ export declare class RecordState { #private; /** * Public by design — the AAD binds it and callers read it. * * UNPOOLED, therefore. A pooled public Buffer hands out a window onto the * shared 8 KiB allocation its neighbours live in, which is how a registry * walk reached live key bytes without touching a key-bearing class at all. */ readonly ctxId: Buffer; readonly direction: Direction; readonly channel: Channel; constructor(options: RecordStateOptions); /** The next counter this state will use. Read-only: nothing outside sets it. */ get counter(): bigint; /** * Seal one record. The counter advances by exactly 1 AFTER success, never * before (§5 R1). * * Returns `header(30) || ciphertext || tag(16)`. */ seal(plaintext: Buffer, target?: Buffer): Buffer; /** * Unseal one record, or throw. * * **Authenticate first, then compare the counter (§5 R2 ordering).** The * nonce is built from the header either way, so the AEAD can run before the * sequence check — and it must. Checking the counter first would make * `E2EE_SEQUENCE_VIOLATION` an *unauthenticated verdict about the peer*: * anyone who can inject a frame reads `ctxId` from a previous plaintext * header, sends garbage with a wrong counter, and the server logs a sequence * violation naming a device that did nothing and closes its socket. It buys * no DoS protection either — the same attacker can as cheaply send a frame * with the *right* counter, which is authenticated anyway. * * Strict once authenticated: `counter == expected` exactly, no window (§5 R2). * A WebSocket runs over one TCP connection, so it is ordered and gap-free by * construction; a repeat, a gap or a reorder is a protocol violation. * * A rejected frame advances NOTHING (§5 R3). */ unseal(frame: Buffer, target?: Buffer): Buffer; /** * The AEAD step WITHOUT the sequence check — the sanctioned seam for the REST * sliding-window receiver (§13). * * `unseal` enforces strict `expected`, which is right for the socket and * wrong for a channel React Query drives concurrently. The REST track needs * the authenticated plaintext *and* the counter the frame claimed so its * 1024-bit window can decide acceptance. Exposing that here rather than * leaving it to be improvised is the whole point: the alternative is a second * implementation of nonce and AAD assembly in another module, which is * exactly how two implementations come to disagree. * * Three properties this deliberately keeps: * * - it is the REST REQUEST channel only. On the socket a window is a * protocol violation (§5 R2), and a seam that could relax it there would * be a hole in the rule this layer exists to hold; * - it advances NOTHING. The window owns acceptance and replay bookkeeping, * so a state that advanced here would be two authorities on one counter; * - every other check still runs — bounds, version, `ctxId`, direction, * channel, the target hash and the tag. Only the sequence rule is the * caller's. * * The caller must therefore still refuse a counter its window rejects, and * must never seal a response for one (§13(a)). */ unsealUnchecked(frame: Buffer, target: Buffer): { plaintext: Buffer; counter: bigint; }; /** * Everything both receive paths share: bounds, header checks, and the AEAD. * * One parser, two policies. The sequence rule is the only thing that differs * between the socket and REST, so it is the only thing left to the callers — * a second copy of the header checks is how the two channels would drift into * disagreeing about what a frame even is. */ private openFrame; } /** * Seals a REST response under the counter of the request it answers (§13(a)). * * **This is the one sanctioned `seal(counter, …)` shape** and §5 R4 says why it * is not the forbidden one: R4 governs *sequence* counters — a value the sender * chooses and advances — and a response echo is not one. The value is dictated * by a request that was already accepted, and this is a distinct class from * `RecordState`, so a caller cannot reach a sequence counter through it. * * Nonce uniqueness for `(k_s2c, 2‖counter)` rests entirely on the rule below: * * > **At most one sealed response per accepted request counter.** A request * > rejected by the window or by the AEAD gets a PLAINTEXT error and never a * > sealed body — including through the framework's error path. * * So `accept()` is called only by a successful request unseal, and `seal()` * spends that acceptance. A response for a counter that was never accepted, or * a second response for one, is refused here rather than trusted to a caller. * * The alternative — a second sender counter for responses — was rejected: the * response would then not be bound to its request at all, and because the * client issues concurrent requests an on-path attacker could swap two in-flight * sealed responses within one context, both authenticating with fresh counters. */ export declare class RestResponseSealer { #private; /** * How far behind the high-water mark a counter is still tracked. * * Well above any realistic concurrency, and the same width as the 1024-bit * REST receive window — deliberately, because a counter that window will * still accept must be one this can still answer. */ static readonly WINDOW_COUNTERS = 1024; /** @deprecated Kept as the old name for one release; same number. */ static readonly MAX_OUTSTANDING = 1024; constructor(options: { key: Buffer | KeyObject; ctxId: Buffer; }); /** * Arm exactly one response for a request counter that was just accepted. * * A second acceptance of one counter is an upstream bug, and it must never * mint a second nonce: it is refused here rather than trusted to whatever * sits above. */ accept(counter: bigint): void; /** Whether a response may still be sealed for this counter. */ isOutstanding(counter: bigint): boolean; seal(requestCounter: bigint, plaintext: Buffer, target: Buffer): Buffer; private bit; private belowWindow; /** * Whether this counter is recorded as answered. * * **Only meaningful at or below the high-water mark.** Bits are indexed * modulo the window width and are cleared as the window slides forward, so a * counter ABOVE the mark reads a bit belonging to a position the window has * not reached yet — 1024 counters ago, not this one. Reading it unguarded is * how the first draft of this class refused a perfectly fresh counter as * "already answered", which its own test caught. */ private isAnswered; private markAnswered; private advanceTo; } /** * Build a record state. * * The factory rather than the constructor is what callers use, so the * `initialCounter` seam stays visible as a named option in one place * (NONCE-DESIGN §5 R4) instead of spreading through `new RecordState(...)` call * sites. */ export declare function createRecordState(options: RecordStateOptions): RecordState; //# sourceMappingURL=record.d.ts.map