/** * Gateway-HMAC bundle canonicalization trio + constant (D192). * * The edge gateway SIGNS a fixed set of forwarded headers; every * downstream service VERIFIES them. Signer and verifier MUST compute the * exact same canonical bytes or every request 401s — so the byte-shape * lives here, in one place, and both sides call the same code. * * Locked canonical form (D192 — byte-for-byte): * * canonical_bytes = concat( lower(name_i) + ":" + value_i + "\n" * for i in 1..len(order) ) * * - header NAMES ASCII-lowercased; header VALUES UTF-8 verbatim * (NO trim, NO normalization). * - separator: single LF (0x0A); trailing LF after the last header. * No CR, no CRLF. * - a name in `order` absent from `headers` is NOT permitted: * `sign` throws, `verify` returns `INCOMPLETE_BUNDLE`. * - signature: base64url(HMAC-SHA-256(secret, canonical_bytes)), * UNPADDED (RFC 4648 § 5 — no trailing `=`). * * STATELESS — no key fetching. The caller reads raw secret bytes from * Secrets Manager (`nodii//gateway/hmac_secret`) and passes them in. * * The header ORDER is NOT caller-owned. It used to be — this file's own * comment said so, and that sentence is why the 14-name list was hand-copied * into 10 repos (edge, auth, tenant, billing, hr, finance, ticketing, kyc, * notification, and obs's Go port). All 10 copies are byte-identical today, * which is the best case, not a safe one: the order is a byte-for-byte * agreement between one signer and every verifier, so a single copy drifting * does not degrade — it 401s every request to that service, with a MISMATCH * that names nothing. It has already happened once (billing sat on the stale * 13-name D353 list after D355 moved the fleet to 14; every gateway request * 401'd until the E2E harness caught it on 2026-06-17). The list now lives * HERE, next to the code that canonicalizes over it, and is the DEFAULT for * sign/verify/canonicalize — so the correct call passes no order at all. */ /** Header carrying the gateway HMAC signature on the wire (D192). */ declare const GATEWAY_HMAC_HEADER = "x-nodii-gateway-signature"; /** * The LOCKED canonical bundle order — D192 (byte-shape) + D353 (the 9 principal * headers, D350-claim order) + D355 (locked 2026-06-15, amends D353: 13 → 14, * inserting `x-nodii-tenant-subdomain` ADJACENT to `x-nodii-tenant-id` at * position 4). * * Signer (nodii-edge) and every verifier MUST canonicalize over this EXACT * order, or every request 401s MISMATCH. The signature header itself * ({@link GATEWAY_HMAC_HEADER}) carries the MAC and is NOT in the canonical * bytes. Edge stamps all 14 UNCONDITIONALLY (absent value == empty string), so * a verifier reads each by name with an empty-string default and still matches * the signer byte-for-byte. * * NOT in the signed set (edge forwards them unsigned): `tracestate` (W3C * diagnostics), and `x-nodii-pii-capabilities` — the D558 per-model masking * matrix is signed INDEPENDENTLY as a sibling precisely because adding a 15th * name here while downstreams reconstruct 14 would mismatch every request in * the fleet. * * CHANGING THIS LIST IS A FLEET-COORDINATED RE-LOCK, not a library change: the * signer and all verifiers must move in the same deploy window. */ declare const GATEWAY_BUNDLE_ORDER: readonly string[]; /** Default rotation grace window: 24h, per D192. */ declare const GATEWAY_HMAC_ROTATION_GRACE_MS: number; /** * Thrown by {@link signGatewayBundle} when a name in `order` is absent * from `headers`. The verifier instead returns `INCOMPLETE_BUNDLE` (it * must not throw on attacker-controlled input). */ declare class GatewayBundleIncompleteError extends Error { /** The first `order` name that had no matching `headers` entry. */ readonly missingHeader: string; constructor(missingHeader: string); } interface CanonicalizeGatewayBundleOptions { /** All request headers (only those named in `order` are consumed). */ headers: Record; /** * Ordered header names to include. Defaults to {@link GATEWAY_BUNDLE_ORDER}, * the fleet-locked 14. Pass an explicit list ONLY to reproduce a historical * order in a test — a production caller that passes its own list has * re-created the drift hazard this default exists to remove. */ order?: readonly string[]; } /** * Produce the raw canonical bytes that go into the HMAC. Exposed for * tests + verifier debugging. * * @throws {GatewayBundleIncompleteError} if any `order` name is absent * from `headers`. */ declare function canonicalizeGatewayBundle(opts: CanonicalizeGatewayBundleOptions): Uint8Array; interface SignGatewayBundleOptions { headers: Record; /** Defaults to {@link GATEWAY_BUNDLE_ORDER}. */ order?: readonly string[]; /** Raw secret bytes (caller reads from Secrets Manager). */ secret: Uint8Array; } /** * Sign the canonical bundle: `base64url(HMAC-SHA-256(secret, canonical))`, * UNPADDED. * * @throws {GatewayBundleIncompleteError} if any `order` name is absent. */ declare function signGatewayBundle(opts: SignGatewayBundleOptions): string; interface GatewayHmacSecrets { /** Active secret. Edge always signs with this. */ current: Uint8Array; /** Prior secret (the value `current` held before the last rotation). */ previous?: Uint8Array | null; /** ISO-8601 timestamp of the last rotation; `null`/absent if never. */ rotatedAt?: string | null; } interface VerifyGatewayBundleOptions { headers: Record; /** Defaults to {@link GATEWAY_BUNDLE_ORDER}. */ order?: readonly string[]; signature: string; secrets: GatewayHmacSecrets; /** Rotation grace window in ms. Default 24h. */ rotationGraceMs?: number; } type VerifyGatewayBundleResult = { ok: true; used: "current" | "previous"; } | { ok: false; reason: "MISMATCH" | "INCOMPLETE_BUNDLE" | "OUT_OF_ROTATION_GRACE" | "BAD_BASE64URL"; }; /** * Verify a gateway HMAC bundle against `current`, falling back to * `previous` within the rotation grace window. * * Never throws on attacker-controlled input; all failure modes are * returned as `{ ok: false, reason }`: * - `INCOMPLETE_BUNDLE` — a name in `order` is absent from `headers`. * - `BAD_BASE64URL` — `signature` is not valid unpadded base64url. * - `MISMATCH` — neither eligible secret reproduces the signature. * - `OUT_OF_ROTATION_GRACE` — `current` mismatched and the only other * candidate (`previous`) is past the grace window (or `rotatedAt` is * unusable), so `previous` was not tried. */ declare function verifyGatewayBundle(opts: VerifyGatewayBundleOptions): VerifyGatewayBundleResult; export { type CanonicalizeGatewayBundleOptions, GATEWAY_BUNDLE_ORDER, GATEWAY_HMAC_HEADER, GATEWAY_HMAC_ROTATION_GRACE_MS, GatewayBundleIncompleteError, type GatewayHmacSecrets, type SignGatewayBundleOptions, type VerifyGatewayBundleOptions, type VerifyGatewayBundleResult, canonicalizeGatewayBundle, signGatewayBundle, verifyGatewayBundle };