/** * Daemon-internal HMAC auth (PR2 / Route B) — signs & verifies internal * `/__daemon/*` requests sent by botmux daemons to the dashboard process. * * Why a new module instead of `auth.ts:verifyHmac`? * - `/__cli/rotate` signs only `ts:nonce` (auth.ts:43-45) — a captured signature * can be replayed against a DIFFERENT path/method/body. * - Route B signs the full request envelope (ts/nonce/method/pathWithQuery/body), * so each signature is only valid for one specific call. * - We deliberately use the SAME `.dashboard-secret` file as the HMAC key so * operators don't have to manage two secrets; the differing signing material * prevents cross-protocol replay. * * Wire format mirrors the existing `/__cli/rotate` convention: * - sender: `digest('base64url')` and ships sig as base64url string * - receiver: `Buffer.from(sig, 'base64url')` vs raw `.digest()` Buffer with * `timingSafeEqual` (see `auth.ts:43-49`) * * Body rule (B1): request body stream MUST only be read once. `verifyDaemonRequest` * consumes it and returns `bodyRaw`; the dispatcher consumes `bodyRaw`, NEVER * `req` again. */ import type { IncomingMessage } from 'node:http'; /** Window during which a (ts, nonce) tuple is accepted; mirrors the spec ±60s. */ export declare const TS_WINDOW_MS = 60000; /** Nonce time-to-live before it can be reused (10 minutes — well over TS_WINDOW). */ export declare const NONCE_TTL_MS: number; /** Hard cap on body size we'll read into memory for signing. */ export declare const BODY_LIMIT_BYTES: number; /** Canonical input fed into `signDaemonRequest`. All fields participate in the digest. */ export interface SignInput { /** `.dashboard-secret` file contents — opaque string used directly as HMAC key. */ secret: string; /** Epoch milliseconds, as a string (no trimming, no normalisation). */ ts: string; /** One-time random base64url string (32 random bytes recommended). */ nonce: string; /** HTTP method; uppercased before being mixed into the digest. */ method: string; /** Request `url` exactly as the server will receive it (path + query, including '?' and '&'). */ pathWithQuery: string; /** Raw body bytes as a UTF-8 string. Empty body → ''. */ bodyRaw: string; } export interface SignOutput { /** Wire-format signature (base64url, no padding). */ wire: string; /** Raw HMAC digest bytes — used by `timingSafeEqual` on the server side. */ raw: Buffer; } /** * Compute the HMAC signature for one request. Pure — no IO, no clock reads. * * Signing material is the canonical 5-line block: * ts \n nonce \n METHOD \n pathWithQuery \n sha256(bodyRaw) * * Query string order is significant (not canonicalised). Server and client MUST * agree on the exact `pathWithQuery` byte-for-byte. */ export declare function signDaemonRequest(input: SignInput): SignOutput; /** * Timing-safe comparison between a wire-format signature and the raw expected * digest. Returns `false` on any decoding error rather than throwing — the * caller treats a `false` result as `sig_mismatch` without leaking the reason. */ export declare function checkSig(wireSig: string, expectedRaw: Buffer): boolean; /** All-or-nothing loopback predicate, identical to `auth.ts`'s inline check. */ export declare function isLoopback(remoteAddr: string | undefined): boolean; /** Persistent (in-memory) nonce store with lazy GC. */ export interface NonceStore { has(nonce: string): boolean; add(nonce: string, expiresAt: number): void; /** Number of currently tracked nonces. Useful for diagnostics / tests. */ size(): number; } export interface ClockLike { now(): number; } /** Default clock — wraps `Date.now`. Replace in tests via the optional `clock` arg. */ export declare const realClock: ClockLike; /** * Create an in-process nonce store. Each `has()` triggers a lazy sweep of * expired entries so the map cannot grow unbounded across a daemon lifetime. */ export declare function createNonceStore(clock?: ClockLike): NonceStore; /** * Read the body stream into a single UTF-8 string, with a hard byte cap. * Returns `null` when the cap is exceeded (caller maps to 413). * * Body MUST only be read once per request; `verifyDaemonRequest` is the only * site that calls this, and downstream dispatch consumes the returned string. */ export declare function readBodyRaw(req: IncomingMessage, opts?: { maxBytes?: number; }): Promise; /** Reasons the verifier may reject a request. Mirror these in HTTP responses. */ export type VerifyRejection = 'missing_header' | 'remote_not_loopback' | 'ts_malformed' | 'ts_window' | 'replay' | 'sig_mismatch' | 'body_too_large'; export interface VerifyOk { ok: true; /** Self-reported daemon app id (audit only — NOT used for authn / authz). */ appId: string; /** Body raw bytes read by verify; dispatcher MUST consume this, not `req`. */ bodyRaw: string; } export interface VerifyFail { ok: false; reason: VerifyRejection; /** Suggested HTTP status code for the rejection. */ httpStatus: number; } export type VerifyResult = VerifyOk | VerifyFail; export interface VerifyOptions { /** Override the default clock — used by tests to advance time deterministically. */ clock?: ClockLike; /** Override the default body cap (rare; tests use this to assert 413 quickly). */ maxBodyBytes?: number; } /** * Verify an inbound `/__daemon/*` request. Consumes the body stream exactly * once and returns `bodyRaw` for the dispatcher. On success, the nonce is * recorded with a TTL to block replays. */ export declare function verifyDaemonRequest(req: IncomingMessage, secret: string, nonceStore: NonceStore, opts?: VerifyOptions): Promise; //# sourceMappingURL=daemon-internal-auth.d.ts.map