1// Passkey sign-in, with @simplewebauthn/server, for lib/private-server.ts.2//3// The relying party is the forge's own origin, FORGE_BASE_URL: its host is4// the RP ID, and every response must come from that origin. Sign-in is5// usernameless: the browser offers the passkeys it has for the forge (they6// are discoverable), and the credential says whose it is. User verification7// is required, since a passkey is the only factor. A passkey is added only8// through a link `ssh <host> passkey` prints, which works once, for ten9// minutes, and binds the passkey to the ssh user; the code travels in the10// URL's fragment, so it never reaches a server log or a Referer. Supported11// algorithms are the library's: ML-DSA-44 first where the runtime has it12// (Node 24.7 and later), then EdDSA, ES256 and RS256.13//14// Challenges keep nothing on the server until they are used, so asking for15// many can't push anyone else's out: each is a random nonce, its expiry16// (five minutes on) and an HMAC over them with a key the server makes when17// it starts, which also covers what it is for (signing in, or adding a18// passkey with a given code) and the browser that asked. That browser gets19// a short-lived cookie (__Host-forge-challenge) with a random id the HMAC20// covers, so a challenge fetched by one client can't be completed by21// another. A used challenge is remembered until it expires, so each works22// once. A restart forgets the key: a sign-in in flight then fails, and the23// next try works. A signed-in24// browser gets the user's accepted credential ids back, so the page can tell25// the password manager which passkeys still work (the WebAuthn Signal API,26// signalAllAcceptedCredentials), and a passkey the forge doesn't know gets27// signalUnknownCredential.28import type http from 'node:http';29import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';30import { generateAuthenticationOptions, generateRegistrationOptions, verifyAuthenticationResponse, verifyRegistrationResponse } from '@simplewebauthn/server';31import type { AuthenticationResponseJSON, RegistrationResponseJSON } from '@simplewebauthn/server';32import type { Identity, Passkey } from './identity.ts';3334const CHALLENGE_TTL = 5 * 60 * 1000;3536// The cookie that ties a challenge to the browser that asked for it: host37// only, HTTPS only, out of scripts' reach, sent to this site's own fetches only.38export const CHALLENGE_COOKIE = '__Host-forge-challenge';39export const challengeCookie = (value: string): string => `${CHALLENGE_COOKIE}=${value}; Path=/; Max-Age=${CHALLENGE_TTL / 1000}; Secure; HttpOnly; SameSite=Strict`;40// The browser's id from its cookie, if it has a well-formed one.41export function browserId(header: string | undefined): string | null {42 for (const part of (header ?? '').split(';')) {43 const i = part.indexOf('=');44 if (i > 0 && part.slice(0, i).trim() === CHALLENGE_COOKIE) {45 const v = part.slice(i + 1).trim();46 return /^[A-Za-z0-9_-]{22}$/.test(v) ? v : null;47 }48 }49 return null;50}51export const newBrowserId = (): string => randomBytes(16).toString('base64url');5253export interface RelyingParty {54 id: string;55 origin: string;56 name: string;57}5859export function relyingParty(baseUrl: string, name: string): RelyingParty | null {60 if (!baseUrl) return null;61 try {62 const u = new URL(baseUrl);63 return { id: u.hostname, origin: u.origin, name };64 } catch {65 return null;66 }67}6869// "Chrome on macOS": what a session or passkey is listed as.70export function agentName(ua: string | undefined): string {71 const s = ua ?? '';72 const browser = /Edg\//.test(s) ? 'Edge' : /Firefox\//.test(s) ? 'Firefox' : /Chrome\//.test(s) ? 'Chrome' : /Safari\//.test(s) ? 'Safari' : /^curl\//.test(s) ? 'curl' : '';73 const os = /iPhone|iPad/.test(s) ? 'iOS' : /Android/.test(s) ? 'Android' : /Macintosh|Mac OS X/.test(s) ? 'macOS' : /Windows/.test(s) ? 'Windows' : /Linux/.test(s) ? 'Linux' : '';74 return [browser, os].filter(Boolean).join(' on ') || 'a browser';75}7677export class LoginError extends Error {78 data: Record<string, unknown>;79 constructor(message: string, data: Record<string, unknown> = {}) {80 super(message);81 this.data = data;82 }83}8485// What the page needs to tell the password manager which passkeys work.86export interface Signal {87 rpID: string;88 userID: string;89 allAcceptedCredentialIDs: string[];90}9192export class Login {93 rp: RelyingParty;94 private id: () => Identity;95 private isUser: (name: string) => boolean;96 // The HMAC key for challenges, and the nonces of used ones until they expire.97 private key = randomBytes(32);98 private used = new Map<string, number>();99100 constructor(rp: RelyingParty, id: () => Identity, isUser: (name: string) => boolean) {101 this.rp = rp;102 this.id = id;103 this.isUser = isUser;104 }105106 private mac(kind: 'login' | 'enroll', code: string, browser: string, nonce: Buffer, expires: Buffer): Buffer {107 return createHmac('sha256', this.key).update(`forge-challenge\0${kind}\0${code}\0${browser}\0`).update(nonce).update(expires).digest();108 }109110 // A challenge: nonce (16 bytes), expiry (8, ms), HMAC (32).111 private challenge(kind: 'login' | 'enroll', browser: string, code = ''): Uint8Array<ArrayBuffer> {112 const nonce = randomBytes(16);113 const expires = Buffer.alloc(8);114 expires.writeBigUInt64BE(BigInt(Date.now() + CHALLENGE_TTL));115 return new Uint8Array(Buffer.concat([nonce, expires, this.mac(kind, code, browser, nonce, expires)]));116 }117118 // A challenge this server gave this browser for this, not used or119 // expired: used up.120 private take(challenge: string, kind: 'login' | 'enroll', browser: string | null, code = ''): boolean {121 const b = Buffer.from(challenge, 'base64url');122 if (!browser || b.length !== 56) return false;123 const nonce = b.subarray(0, 16);124 const expires = b.subarray(16, 24);125 if (!timingSafeEqual(b.subarray(24), this.mac(kind, code, browser, nonce, expires))) return false;126 const t = Date.now();127 const until = Number(expires.readBigUInt64BE());128 const n = nonce.toString('base64url');129 if (until < t || this.used.has(n)) return false;130 for (const [k, v] of this.used) if (v < t) this.used.delete(k);131 this.used.set(n, until);132 return true;133 }134135 signal(user: string): Signal {136 return { rpID: this.rp.id, userID: this.id().handle(user), allAcceptedCredentialIDs: this.id().passkeys(user).map((p) => p.credential) };137 }138139 // Options for the browser whose id this is (browserId, newBrowserId).140 async loginOptions(browser: string): Promise<unknown> {141 return generateAuthenticationOptions({ rpID: this.rp.id, userVerification: 'required', allowCredentials: [], challenge: this.challenge('login', browser) });142 }143144 // The passkey's user, after checking its signature over our challenge.145 async loginVerify(body: unknown, browser: string | null): Promise<{ user: string; passkey: Passkey }> {146 const res = body as AuthenticationResponseJSON;147 if (!res || typeof res.id !== 'string' || typeof res.response !== 'object') throw new LoginError('not a passkey response');148 const key = this.id().passkey(res.id);149 if (!key || !this.isUser(key.user)) {150 throw new LoginError("The forge doesn't know this passkey (it may have been removed). Use another, or run ssh passkey again for a new one.", {151 unknown: { rpID: this.rp.id, credentialID: res.id },152 });153 }154 const handle = res.response.userHandle;155 if (handle && this.id().userOfHandle(handle) !== key.user) throw new LoginError("This passkey doesn't belong to the account it says");156 let v;157 try {158 v = await verifyAuthenticationResponse({159 response: res, expectedChallenge: (c) => this.take(c, 'login', browser), expectedOrigin: this.rp.origin, expectedRPID: this.rp.id, requireUserVerification: true,160 credential: { id: key.credential, publicKey: new Uint8Array(key.public_key), counter: key.counter, transports: key.transports ? key.transports.split(',') : undefined },161 });162 } catch (e) {163 throw new LoginError(`Sign-in failed: ${(e as Error).message}`);164 }165 if (!v.verified) throw new LoginError('Sign-in failed');166 this.id().usedPasskey(key.credential, v.authenticationInfo.newCounter);167 return { user: key.user, passkey: key };168 }169170 async enrollOptions(code: unknown, browser: string): Promise<unknown> {171 const e = typeof code === 'string' ? this.id().enrollment(code) : null;172 if (!e || !this.isUser(e.user)) throw new LoginError('This link has expired or has been used. Run ssh passkey again for a new one.', { expired: true });173 const opts = await generateRegistrationOptions({174 rpName: this.rp.name, rpID: this.rp.id, userName: e.user, userDisplayName: e.user,175 userID: new Uint8Array(Buffer.from(this.id().handle(e.user), 'base64url')),176 attestationType: 'none',177 excludeCredentials: this.id().passkeys(e.user).map((p) => ({ id: p.credential, transports: p.transports ? p.transports.split(',') : undefined })),178 authenticatorSelection: { residentKey: 'required', userVerification: 'required' },179 challenge: this.challenge('enroll', browser, code as string),180 });181 return opts;182 }183184 // Check the new passkey, then use the link up and keep the passkey.185 async enrollVerify(code: unknown, body: unknown, name: string, browser: string | null): Promise<{ user: string; passkey: Passkey }> {186 const res = body as RegistrationResponseJSON;187 if (typeof code !== 'string' || !res || typeof res.id !== 'string' || typeof res.response !== 'object') throw new LoginError('not a passkey response');188 let v;189 try {190 v = await verifyRegistrationResponse({191 response: res, expectedChallenge: (c) => this.take(c, 'enroll', browser, code), expectedOrigin: this.rp.origin, expectedRPID: this.rp.id, requireUserVerification: true,192 });193 } catch (e) {194 throw new LoginError(`The passkey wasn't added: ${(e as Error).message}`);195 }196 if (!v.verified) throw new LoginError("The passkey wasn't added");197 const c = v.registrationInfo.credential;198 if (this.id().passkey(c.id)) throw new LoginError('This passkey is added already');199 const key = this.id().enrolled(code, { credential: c.id, public_key: c.publicKey, counter: c.counter, transports: (res.response.transports ?? []).join(','), name });200 if (!key) throw new LoginError('This link has expired or has been used. Run ssh passkey again for a new one.', { expired: true });201 return { user: key.user, passkey: key };202 }203}204205// A request body, as JSON or a form, of at most 64K (or max bytes).206export function readBody(req: http.IncomingMessage, max = 65536): Promise<string> {207 return new Promise((ok, no) => {208 const parts: Buffer[] = [];209 let n = 0;210 req.on('data', (d: Buffer) => {211 n += d.length;212 if (n > max) {213 no(new LoginError('request too large'));214 req.destroy();215 } else parts.push(d);216 });217 req.on('end', () => ok(Buffer.concat(parts).toString('utf8')));218 req.on('error', no);219 });220}