/** * Unified authentication for DKG node interfaces (HTTP API, MCP, WebSocket, etc.). * * Uses bearer tokens stored on disk. Tokens are auto-generated on first start. * Any interface that needs auth calls `verifyToken(token)` against the loaded set. */ import type { IncomingMessage, ServerResponse } from 'node:http'; export interface AuthConfig { /** Master switch — when false, all requests are allowed (default: true). */ enabled?: boolean; /** Pre-configured tokens. If empty, one is auto-generated on first start. */ tokens?: string[]; } /** * Load tokens from disk + config. Auto-generates a token file if none exists. * Returns the set of valid tokens. */ export declare function loadTokens(authConfig?: AuthConfig): Promise>; /** * Verify a bearer token against the loaded token set. * This is the single entry point any interface (HTTP, MCP, WS) should use. * * Performs an mtime-gated hot-reload of the on-disk `auth.token` file * on every call — see `reconcileFileTokens` above for the rationale. */ export declare function verifyToken(token: string | undefined, validTokens: Set): boolean; /** * Generate a fresh token, rewrite `auth.token` so it contains *only* the * new value, and update the supplied in-memory `validTokens` set so the * old file-derived token is invalidated immediately. Config-pinned * tokens (passed via `loadTokens({ tokens: [...] })`) are preserved. * * Returns the new token (never logged — caller decides what to do). */ export declare function rotateToken(validTokens: Set): Promise; /** * Revoke a single token. Returns `true` if the token was previously * known to this auth surface (in-memory or file-backed) and has now * been invalidated; returns `false` if the token was not present at * all. * * the previous revision was a * synchronous `validTokens.delete(token)` only — but `verifyToken()` * calls `reconcileFileTokens()` on every invocation, and that * reconciliation re-adds any token that still appears on disk in * `auth.token`. So calling `revokeToken()` against a file-derived * credential was a no-op the very next request: the in-memory set * was reset from the still-unchanged file. The contract advertised * by the JSDoc ("surgically kill a leaked credential") was therefore * broken for the most common case (the file-backed admin token). * * Fix: persist the removal. If the token was loaded from * `auth.token`, rewrite the file to exclude it (and its snapshot * entry) BEFORE deleting from the in-memory set, so the next * reconcile sees a file that no longer contains the revoked token * and leaves it out. Tokens that were never file-backed (e.g. * config-pinned via `loadTokens({ tokens: [...] })`) take the * original purely-in-memory path — those are not at risk of being * re-added by reconciliation because they are not in the snapshot's * `fileTokens`. */ export declare function revokeToken(token: string, validTokens: Set): Promise; /** * Default ±5 min freshness window for signed requests, matching the * AWS Sig V4 / OAuth 1.0 conventions documented in spec §18. */ export declare const SIGNED_REQUEST_FRESHNESS_WINDOW_MS: number; export interface SignedRequestInput { method: string; path: string; /** Raw request body (Buffer or string). Used to compute the signature payload. */ body: Buffer | string; /** Timestamp string supplied by the client (typically ISO-8601). */ timestamp: string; /** Nonce supplied by the client; rejected on second sighting. */ nonce?: string; /** Hex signature supplied by the client. */ signature: string; /** Bearer token used as the HMAC secret. */ token: string; /** Optional override of the freshness window (for tests / spec changes). */ freshnessWindowMs?: number; /** Optional clock override (for tests). */ now?: number; } export type SignedRequestOutcome = { ok: true; } | { ok: false; reason: 'missing-fields' | 'stale-timestamp' | 'replayed-nonce' | 'bad-signature'; }; /** * Derive the canonical request path bound into the signed-request HMAC. * * binding only `pathname` * left query parameters unsigned — an attacker could swap * `/api/query?graph=...` for `/api/query?graph=...&poison=...` without * invalidating the signature. Several protected daemon routes read * `url.searchParams`, so this was a real tamper surface. * * Now binds `pathname + search` (including the leading `?` when present). * Clients computing the HMAC MUST use this exact representation. The * helper is exported so callers can share it instead of re-implementing * the canonicalisation and drifting. */ export declare function canonicalRequestPath(req: IncomingMessage): string; export declare function canonicalSignedRequestPayload(method: string, path: string, timestamp: string, nonce: string | undefined, body: Buffer | string): string; /** * Verify a signed request per spec §18. * * Required headers (mapped into `SignedRequestInput`): * - `x-dkg-timestamp` ISO-8601 or numeric epoch-ms * - `x-dkg-signature` hex-encoded HMAC-SHA256(token, * canonicalSignedRequestPayload(method, path, ts, * nonce, body)) * - `x-dkg-nonce` REQUIRED — opaque, single-use; rejects replay. * * The HMAC covers METHOD + PATH + TIMESTAMP + NONCE + SHA256(BODY) so: * - a captured signature cannot be replayed against another * endpoint/verb (method + path are bound); * - swapping the nonce to bypass the replay cache does not yield a * valid signature (nonce is bound); * - tampering the body breaks the hash and invalidates the signature. * * Nonce is REQUIRED: a signature without a nonce is rejected as * `missing-fields`. Callers upgrading from the prior * "timestamp + body only" scheme must regenerate signatures. * * Returns a discriminated result describing why a request was refused — * callers can map each `reason` to the appropriate HTTP status (401 * for everything except `missing-fields`, which is 400). */ export declare function verifySignedRequest(input: SignedRequestInput): SignedRequestOutcome; /** * Extract a bearer token from an HTTP Authorization header value. * Accepts: "Bearer " or just "". */ export declare function extractBearerToken(headerValue: string | undefined): string | undefined; /** * CLI-10 /. * * the previous revision of this file * added a coarse `token:method:pathname:content-length` fingerprint * dedup for body-less Bearer requests so a leaked Bearer could not be * silently replayed. That dedup was too aggressive: two consecutive * legitimate `POST /api/local-agent-integrations/:id/refresh` calls * share a fingerprint and the second one was 401-rejected for 60 s. * Similarly, any idempotent body-less `DELETE` retried within a minute * failed with a confusing replay error. * * Replay protection that REJECTS legitimate retries is worse than no * replay protection: it breaks correct clients while still leaving the * strict replay window (60 s) available to an attacker who records the * wire. The proper transport-layer defence against Bearer replay is * the signed-request scheme (x-dkg-timestamp + x-dkg-nonce + * x-dkg-signature) which binds every request to a unique nonce and a * freshness window, and which is already enforced above — including * synchronous zero-body verification. Clients that do not opt into * signed-request mode now get no transport-layer replay defence; they * must handle idempotence at the application layer or upgrade to * signed requests. That is the correct trade-off because: * * 1. Idempotent operations (`refresh`, `DELETE`) MUST be safe to * retry. Transport replay defence must not violate that. * 2. Non-idempotent operations (e.g. `POST /publish`) are body-bearing * in practice, so the old fingerprint never fired for them anyway. * 3. The signed-request scheme provides proper per-request nonce * enforcement for callers that need it. * * The fingerprint cache and its helpers have therefore been removed. * The symbols below stay exported-but-empty for a release so any test * that still references them keeps compiling; the cache is a no-op. */ /** * HTTP auth guard. Returns `true` if the request is allowed to * proceed, `false` if a 401 response was sent. * * For body-carrying signed requests (the only case where the HMAC * cannot be verified synchronously from headers alone) the guard * returns a `Promise` that resolves AFTER the body has been * drained and the HMAC has been verified — so callers that `await` * the result are guaranteed not to run their handler until the * signature is confirmed. The * older response-time guard remains installed as defense-in-depth for * legacy callers that don't `await`, but the supported contract is to * always `await` the return value. * * Usage in the server handler: * if (!(await httpAuthGuard(req, res, authEnabled, validTokens))) return; * * Body-less paths (GET / HEAD / OPTIONS / public paths / unsigned * requests / framing-bodyless signed requests) still resolve * synchronously to a bare `boolean` so existing fast-path callers do * not pay an awaiting cost on hot routes. */ export declare function httpAuthGuard(req: IncomingMessage, res: ServerResponse, authEnabled: boolean, validTokens: Set, corsOrigin?: string | null): boolean | Promise; /** * Pending signed-request auth state attached to the request by * {@link httpAuthGuard} when the client opted into the signed-request * scheme. Route handlers MUST finish the check by calling * {@link verifyHttpSignedRequestAfterBody} once they have buffered the * request body. */ export interface SignedAuthPending { token: string; timestamp: string; nonce: string; signature: string; } /** * Completes signed-request verification started by {@link httpAuthGuard}. * * After a route handler has buffered the request body, it MUST call this * helper to finish the verification that the guard left pending. The * helper reads the stashed auth context from `req.__dkgSignedAuth` and * runs the full {@link verifySignedRequest} check binding method, path, * timestamp, nonce, and body hash. * * Returns `{ ok: true }` if the request does not use signed-request mode * (there is nothing to finish) or if the signature verifies. Otherwise * returns the discriminated outcome describing why the request was * rejected; the caller is expected to translate it into a 401. * * When the verification succeeds the nonce is committed to the seen-nonce * cache, so subsequent replays are rejected even after process restart * (bounded by the freshness window). * * NOTE: Prefer {@link enforceSignedRequestPostBody} from daemon (and any * other HTTP surface that reads request bodies) so the enforcement is * driven centrally from the body-reading helper instead of each route * having to remember to call it. This function is retained because it is * still the lowest-level primitive. */ export declare function verifyHttpSignedRequestAfterBody(req: IncomingMessage, body: Buffer | string): SignedRequestOutcome; /** * Thrown by {@link enforceSignedRequestPostBody} when the signed-request * post-body HMAC verification fails. The HTTP layer maps this to 401. * * the previous revision of * {@link httpAuthGuard} pre-validated the signed-request HEADERS, stashed * `__dkgSignedAuth`, and returned `true`. No call site actually invoked * `verifyHttpSignedRequestAfterBody` — so any request with a fresh * timestamp / nonce and an arbitrary `x-dkg-signature` reached the * handler as long as the bearer token was valid, completely defeating * the body-binding guarantee the HMAC is supposed to provide. The fix * is to enforce the post-body check inside the daemon's body-reading * helpers so EVERY buffered-body route automatically validates. */ export declare class SignedRequestRejectedError extends Error { readonly reason: Exclude['reason']; constructor(reason: Exclude['reason']); } /** * Enforce the post-body signed-request HMAC check. Call this from the * shared body-reading code path after the full body has been buffered * and before the handler sees it. * * No-op when the request did NOT opt into signed-request mode (i.e. * {@link httpAuthGuard} did not stash `__dkgSignedAuth`). When signed * mode is active, throws {@link SignedRequestRejectedError} on any * failure reason — the HTTP layer is expected to catch it and emit a * 401 response. Once a request's signature has been verified it is * marked on `__dkgSignedAuth.verified = true` so subsequent body- * reads (e.g. multipart handlers that call readBody more than once) * are idempotent. */ export declare function enforceSignedRequestPostBody(req: IncomingMessage, body: Buffer | string): void; /** * @internal — test/operator helper to wipe the replay cache. Useful * when an integration test has a legitimate reason to repeat a signed * request and needs a clean slate. Only the per-nonce replay cache * is cleared. */ export declare function _clearReplayCacheForTesting(): void; //# sourceMappingURL=auth.d.ts.map