/** * Passkeys (WebAuthn) — biometric / security-key login that AUGMENTS TOTP. * * A verified assertion mints the same session JWT the TOTP path mints; TOTP * stays as the fallback and as the way to authenticate before enrolling the * first passkey. Credentials are stored per rpID (per hostname): a passkey * enrolled at `localhost` (the ssh-tunnel case) works wherever the browser * reaches Nebula as `localhost`, one enrolled at a tunnel domain works there. * * Storage: `/passkeys.json`, mode 0600, next to auth.json. */ import type { IncomingHttpHeaders } from 'http'; import type { AuthenticatorTransportFuture, PublicKeyCredentialCreationOptionsJSON, PublicKeyCredentialRequestOptionsJSON } from '@simplewebauthn/server'; export interface PasskeyCredential { /** Credential ID (base64url) — also what the authenticator presents at login. */ id: string; /** COSE public key, base64url. Never leaves the server. */ publicKey: string; counter: number; transports: AuthenticatorTransportFuture[]; /** Hostname the credential was enrolled at; a passkey is only offered there. */ rpID: string; label: string; createdAt: string; lastUsedAt: string | null; } export interface PasskeyStore { /** Stable opaque user handle (base64url) shared by every credential. */ userId: string | null; credentials: PasskeyCredential[]; } /** What clients may see: everything except the public key. */ export interface PublicPasskey { id: string; rpID: string; label: string; createdAt: string; lastUsedAt: string | null; } export declare const PASSKEYS_FILENAME = "passkeys.json"; export declare const MAX_LABEL_LENGTH = 60; export declare function passkeysFilePath(): string; export declare function toPublicPasskey(c: PasskeyCredential): PublicPasskey; /** Load the store; a missing or unreadable file is an empty store. */ export declare function loadPasskeyStore(): PasskeyStore; /** * Persist the store, private to the user (dir 0700, file 0600) and atomically * (tmp + rename) so a failed write never leaves a truncated credential list. */ export declare function savePasskeyStore(store: PasskeyStore): void; export type ChallengeType = 'login' | 'register'; export declare const CHALLENGE_TTL_MS = 120000; /** * Outstanding WebAuthn challenges: in-memory, single-use, 120 s TTL, typed * (a login challenge cannot complete a registration) and bound to the rpID * they were issued for. The opaque token handed to the client is the map key. */ export declare class PasskeyChallenges { private readonly now; private readonly ttlMs; private readonly entries; constructor(now?: () => number, ttlMs?: number); put(type: ChallengeType, rpID: string, challenge: string): string; /** Consume a challenge. Returns null (and burns the token) on any mismatch. */ take(token: unknown, type: ChallengeType, rpID: string): string | null; get size(): number; private sweep; } export interface RpInfo { /** Hostname only (no port) — what the browser scopes the credential to. */ rpID: string; /** `${proto}://${host[:port]}` as the browser will report it in clientDataJSON. */ origin: string; } export declare class PasskeyRpError extends Error { readonly code = "invalid_rp_id"; } /** * Derive rpID and origin from the request's own Host (or X-Forwarded-Host). * The rpID is the hostname with the port stripped; the origin keeps the port. * Protocol honors X-Forwarded-Proto and otherwise defaults to https — except * for localhost, which browsers treat as a secure context over plain http and * which is the common case (ssh tunnel to localhost:3000). * * WebAuthn forbids IP-literal rpIDs, so `127.0.0.1` is rejected with advice * to use `http://localhost:PORT` instead. */ export declare function deriveRpInfo(headers: IncomingHttpHeaders): RpInfo; export type OptionsResult = { ok: true; token: string; options: T; } | { ok: false; error: string; }; export type LoginResult = { ok: true; credential: PasskeyCredential; } | { ok: false; error: string; }; export type RegisterResult = { ok: true; passkey: PublicPasskey; } | { ok: false; error: string; }; export declare class PasskeyService { readonly challenges: PasskeyChallenges; constructor(challenges?: PasskeyChallenges); /** Credentials enrolled at this rpID. */ credentialsFor(rpID: string, store?: PasskeyStore): PasskeyCredential[]; /** Unauthenticated: a login challenge, or `ok:false` when nothing is enrolled here. */ loginOptions(rp: RpInfo): Promise>; /** * Unauthenticated: verify an assertion against a stored credential. On * success the counter and lastUsedAt are persisted. The caller mints the * session and applies rate limiting. */ login(rp: RpInfo, body: unknown): Promise; /** Authenticated: a registration challenge for this rpID. */ registerOptions(rp: RpInfo): Promise>; /** Authenticated: verify an attestation and store the new credential. */ register(rp: RpInfo, body: unknown): Promise; /** Authenticated: every enrolled passkey, public fields only. */ list(): PublicPasskey[]; /** Authenticated: remove a credential by id. Returns whether one was removed. */ delete(id: string): boolean; } export declare const passkeyService: PasskeyService;