/** * 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'; import { DkgHomeFiles } from './config.js'; 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[]; } /** HTTP identity established by the CLI daemon's authentication boundary. */ export type RequestPrincipal = { readonly kind: 'agent'; readonly agentAddress: string; } | { readonly kind: 'nodeOperator'; } | { readonly kind: 'anonymous'; }; type AcceptedHttpIdentity = { readonly acceptedToken: string; readonly principal: Extract; } | { readonly acceptedToken: string; readonly principal: Extract; }; type AnonymousHttpIdentity = { readonly acceptedToken: undefined; readonly principal: Extract; }; /** * One correlated allow decision. Authenticated mode always carries an accepted credential, while * an absent credential can only be anonymous. Public/disabled requests may still carry a valid * optional bearer, but that bearer is classified at the same boundary before this value exists. */ export type AllowedHttpAuthentication = { readonly allowed: true; readonly presentedToken: string | undefined; } & (({ readonly mode: 'authenticated'; } & AcceptedHttpIdentity) | ({ readonly mode: 'disabled' | 'public'; } & (AcceptedHttpIdentity | AnonymousHttpIdentity))); export type HttpAuthenticationResult = { readonly allowed: false; } | AllowedHttpAuthentication; /** * Build the one correlated authentication value carried by request context. * Tests use the same factory so impossible principal/capability combinations cannot be assembled * by independently mutating fixture fields. */ type CreateAllowedHttpAuthenticationInput = { readonly mode: 'disabled' | 'public'; readonly presentedToken?: string; readonly acceptedToken?: undefined; readonly resolveAgentByToken?: never; } | { readonly mode: AllowedHttpAuthentication['mode']; readonly presentedToken?: string; readonly acceptedToken: string; readonly resolveAgentByToken: (token: string) => string | undefined; }; export declare function createAllowedHttpAuthentication(input: CreateAllowedHttpAuthenticationInput): AllowedHttpAuthentication; /** Node administration is a projection of the correlated authentication decision, never state. */ export declare function canAdministerNode(authentication: AllowedHttpAuthentication): boolean; /** Authenticated agent identity projection shared by every route. */ export declare function authenticatedAgentAddress(authentication: AllowedHttpAuthentication): string | undefined; /** Select the first file token without registering daemon hot-reload state. */ export declare function loadApiClientToken(homeFiles: DkgHomeFiles): Promise; /** Load daemon tokens from config and disk, generating only when both are empty. */ 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; /** * Compatibility adapter for callers that only need an allow/deny result. The canonical evaluator * still returns one explicit credential decision; this wrapper projects that decision to boolean. */ export declare function httpAuthGuard(req: IncomingMessage, res: ServerResponse, authEnabled: boolean, validTokens: Set, corsOrigin?: string | null): boolean | Promise; /** * Canonical daemon authentication boundary. Credential acceptance, identity and authority all * derive from the same explicit decision; route code never re-authenticates the request. */ export declare function authenticateHttpRequest(input: { readonly req: IncomingMessage; readonly res: ServerResponse; readonly authEnabled: boolean; readonly validTokens: Set; readonly resolveAgentByToken: (token: string) => string | undefined; readonly corsOrigin?: string | null; }): 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; export {}; //# sourceMappingURL=auth.d.ts.map