import * as plugins from './plugins.js'; import { AuthError, type IAuthenticationBeginOptions, type IAuthenticationBeginResult, type IAuthenticationFinishOptions, type IAuthenticationResult, type IPasskeyManagerOptions, type ISetupBeginOptions, type ISetupBeginResult, type ISetupFinishOptions, type IStoredPasskeyCredential, type IWebAuthnCeremonyDocument, type IWebAuthnServerImplementation, type IWebAuthnSetupCeremonyDocument, } from './interfaces.auth.js'; const setupCeremonyLifetimeMs = 5 * 60 * 1000; const authenticationCeremonyLifetimeMs = 2 * 60 * 1000; const maxAuthenticationVerificationAttemptsPerWindow = 128; const base64Url32BytePattern = /^[A-Za-z0-9_-]{43}$/; const defaultWebAuthn: IWebAuthnServerImplementation = { generateRegistrationOptions: plugins.simplewebauthnServer.generateRegistrationOptions, verifyRegistrationResponse: plugins.simplewebauthnServer.verifyRegistrationResponse, generateAuthenticationOptions: plugins.simplewebauthnServer.generateAuthenticationOptions, verifyAuthenticationResponse: plugins.simplewebauthnServer.verifyAuthenticationResponse, }; const requirePositiveLimit = ( valueArg: number, labelArg: string, maximumArg = 10_000, ): number => { if (!Number.isSafeInteger(valueArg) || valueArg < 1 || valueArg > maximumArg) { throw new Error(`${labelArg} must be a positive safe integer no greater than ${maximumArg}.`); } return valueArg; }; const requirePeerId = (peerIdArg: string): string => { if ( typeof peerIdArg !== 'string' || peerIdArg.length === 0 || peerIdArg.length > 512 || peerIdArg.trim() !== peerIdArg ) { throw new AuthError('peer_mismatch', 'The connection identity is malformed.'); } return peerIdArg; }; const requireOrigin = (originArg: string): string => { if (typeof originArg !== 'string' || originArg.length === 0 || originArg.length > 2048) { throw new AuthError('origin_mismatch', 'The request origin is malformed.'); } try { const origin = new URL(originArg); if ( origin.origin !== originArg || (origin.protocol !== 'http:' && origin.protocol !== 'https:') || origin.username.length > 0 || origin.password.length > 0 || origin.pathname !== '/' || origin.search.length > 0 || origin.hash.length > 0 ) { throw new Error('not an origin'); } } catch { throw new AuthError('origin_mismatch', 'The request origin is malformed.'); } return originArg; }; const requireCeremonyId = (ceremonyIdArg: string): string => { if (typeof ceremonyIdArg !== 'string' || !base64Url32BytePattern.test(ceremonyIdArg)) { throw new AuthError('ceremony_invalid', 'The WebAuthn ceremony identifier is malformed.'); } return ceremonyIdArg; }; const randomBase64Url = (): string => plugins.crypto.randomBytes(32).toString('base64url'); const base64UrlValuesEqual = (leftArg: string, rightArg: string): boolean => { if (!base64Url32BytePattern.test(leftArg) || !base64Url32BytePattern.test(rightArg)) { return false; } const left = Buffer.from(leftArg, 'base64url'); const right = Buffer.from(rightArg, 'base64url'); return left.byteLength === right.byteLength && plugins.crypto.timingSafeEqual(left, right); }; export class PasskeyManager { private readonly store: IPasskeyManagerOptions['store']; private readonly relyingPartyName: string; private readonly webAuthn: IWebAuthnServerImplementation; private readonly now: () => Date; private readonly setupAttemptLimit: number; private readonly setupAttemptWindowMs: number; private readonly authenticationAttemptLimitPerPeer: number; private readonly authenticationAttemptWindowMs: number; private readonly maxActiveSetupCeremonies: number; private readonly maxActiveAuthenticationCeremonies: number; private readonly setupAttemptTimestamps: number[] = []; private readonly authenticationVerificationAttemptTimestamps: number[] = []; private readonly authenticationAttemptTimestampsByPeer = new Map(); private ceremonyCreationTail: Promise = Promise.resolve(); constructor(optionsArg: IPasskeyManagerOptions) { this.store = optionsArg.store; this.relyingPartyName = optionsArg.relyingPartyName ?? 'Agent Gateway Layer'; if (this.relyingPartyName.trim().length === 0 || this.relyingPartyName.length > 128) { throw new Error('relyingPartyName must contain 1-128 characters.'); } this.webAuthn = optionsArg.webAuthn ?? defaultWebAuthn; this.now = optionsArg.now ?? (() => new Date()); this.setupAttemptLimit = requirePositiveLimit( optionsArg.setupAttemptLimit ?? 10, 'setupAttemptLimit', ); this.setupAttemptWindowMs = requirePositiveLimit( optionsArg.setupAttemptWindowMs ?? 60_000, 'setupAttemptWindowMs', 24 * 60 * 60 * 1000, ); this.authenticationAttemptLimitPerPeer = requirePositiveLimit( optionsArg.authenticationAttemptLimitPerPeer ?? 6, 'authenticationAttemptLimitPerPeer', ); this.authenticationAttemptWindowMs = requirePositiveLimit( optionsArg.authenticationAttemptWindowMs ?? 60_000, 'authenticationAttemptWindowMs', 24 * 60 * 60 * 1000, ); this.maxActiveSetupCeremonies = requirePositiveLimit( optionsArg.maxActiveSetupCeremonies ?? 16, 'maxActiveSetupCeremonies', ); this.maxActiveAuthenticationCeremonies = requirePositiveLimit( optionsArg.maxActiveAuthenticationCeremonies ?? 64, 'maxActiveAuthenticationCeremonies', ); } public async getAuthState(): Promise<{ state: 'setupRequired' | 'ready'; setupCodeExpired: boolean; }> { const state = await this.store.getState(); return { state: state.authState, setupCodeExpired: state.setup !== null && state.setup.expiresAt.getTime() <= this.currentTime().getTime(), }; } public async beginSetup(optionsArg: ISetupBeginOptions): Promise { const peerId = requirePeerId(optionsArg.peerId); const origin = requireOrigin(optionsArg.origin); return this.withCeremonyCreationLock(async () => { const now = this.currentTime(); await this.assertCeremonyCapacity('setup', now); const state = await this.store.getState(); this.assertConfiguredOrigin(origin, state.config.publicOrigin); // the attempt budget must burn BEFORE the code is checked — a limiter // that only counts correct codes throttles the legitimate operator and // leaves guessing unmetered this.recordSetupAttempt(now); await this.store.assertSetupAuthority(optionsArg.setupCode, undefined, now); if (!state.setup) { throw new AuthError('setup_unavailable', 'Passkey setup is no longer available.'); } const registrationOptions = await this.webAuthn.generateRegistrationOptions({ rpName: this.relyingPartyName, rpID: state.config.rpId, userName: 'agl', userDisplayName: 'agl', userID: Buffer.from(state.webauthnUserId, 'base64url'), timeout: setupCeremonyLifetimeMs, attestationType: 'none', excludeCredentials: [], authenticatorSelection: { residentKey: 'required', requireResidentKey: true, userVerification: 'required', }, }); const ceremonyId = randomBase64Url(); const expiresAt = new Date(Math.min( now.getTime() + setupCeremonyLifetimeMs, state.setup.expiresAt.getTime(), )); const ceremony: IWebAuthnSetupCeremonyDocument = { id: ceremonyId, kind: 'setup', state: 'pending', peerId, challenge: registrationOptions.challenge, origin, rpId: state.config.rpId, userId: state.webauthnUserId, setupGeneration: state.setup.generation, setupHash: state.setup.hash, createdAt: now, expiresAt, }; await this.store.createCeremony(ceremony); return { ceremonyId, options: registrationOptions }; }); } public async finishSetup(optionsArg: ISetupFinishOptions): Promise { const peerId = requirePeerId(optionsArg.peerId); const origin = requireOrigin(optionsArg.origin); const ceremonyId = requireCeremonyId(optionsArg.ceremonyId); const now = this.currentTime(); const stateBefore = await this.store.getState(); this.assertConfiguredOrigin(origin, stateBefore.config.publicOrigin); // burn the attempt budget BEFORE consuming the ceremony: an exhausted // budget must reject without destroying a still-valid ceremony, otherwise // the user's authenticator keeps a credential the store never accepted this.recordSetupAttempt(now); const ceremony = await this.store.consumeCeremony({ ceremonyId, kind: 'setup', peerId, origin, rpId: stateBefore.config.rpId, userId: stateBefore.webauthnUserId, now, }); if ( ceremony.kind !== 'setup' || typeof ceremony.setupGeneration !== 'string' || typeof ceremony.setupHash !== 'string' ) { throw new AuthError('ceremony_invalid', 'The WebAuthn ceremony has the wrong kind.'); } const setupCeremony: IWebAuthnSetupCeremonyDocument = { ...ceremony, kind: 'setup', setupGeneration: ceremony.setupGeneration, setupHash: ceremony.setupHash, }; await this.store.assertSetupAuthority(optionsArg.setupCode, setupCeremony, now); let verification: Awaited>; try { verification = await this.webAuthn.verifyRegistrationResponse({ response: optionsArg.response, expectedChallenge: ceremony.challenge, expectedOrigin: ceremony.origin, expectedRPID: ceremony.rpId, requireUserPresence: true, requireUserVerification: true, }); } catch (errorArg) { throw new AuthError('verification_failed', 'Passkey registration verification failed.', { cause: errorArg, }); } if ( !verification.verified || !verification.registrationInfo.userVerified || verification.registrationInfo.credential.id !== optionsArg.response.id ) { throw new AuthError('verification_failed', 'Passkey registration verification failed.'); } const verifiedCredential = verification.registrationInfo.credential; const storedCredential: IStoredPasskeyCredential = { id: verifiedCredential.id, publicKey: Buffer.from(verifiedCredential.publicKey).toString('base64url'), counter: verifiedCredential.counter, transports: [...(verifiedCredential.transports ?? optionsArg.response.response.transports ?? [])], deviceType: verification.registrationInfo.credentialDeviceType, backedUp: verification.registrationInfo.credentialBackedUp, createdAt: now, counterUpdateId: randomBase64Url(), }; await this.store.commitFirstCredential(setupCeremony, storedCredential); return { authenticated: true, credentialId: storedCredential.id }; } public async preflightSetupFinish(optionsArg: ISetupFinishOptions): Promise { const peerId = requirePeerId(optionsArg.peerId); const origin = requireOrigin(optionsArg.origin); const ceremonyId = requireCeremonyId(optionsArg.ceremonyId); const now = this.currentTime(); const state = await this.store.getState(); this.assertConfiguredOrigin(origin, state.config.publicOrigin); const ceremony = await this.store.assertPendingCeremony({ ceremonyId, kind: 'setup', peerId, origin, rpId: state.config.rpId, userId: state.webauthnUserId, now, }); if ( ceremony.kind !== 'setup' || typeof ceremony.setupGeneration !== 'string' || typeof ceremony.setupHash !== 'string' ) { throw new AuthError('ceremony_invalid', 'The WebAuthn ceremony has the wrong kind.'); } // preflight is the first place a wrong code arrives in the controller // flow, so it must burn the attempt budget too this.recordSetupAttempt(now); await this.store.assertSetupAuthority(optionsArg.setupCode, { ...ceremony, kind: 'setup', setupGeneration: ceremony.setupGeneration, setupHash: ceremony.setupHash, }, now); } public async beginAuthentication( optionsArg: IAuthenticationBeginOptions, ): Promise { const peerId = requirePeerId(optionsArg.peerId); const origin = requireOrigin(optionsArg.origin); return this.withCeremonyCreationLock(async () => { const now = this.currentTime(); await this.assertCeremonyCapacity('authentication', now); const state = await this.store.getState(); this.assertConfiguredOrigin(origin, state.config.publicOrigin); if (state.authState !== 'ready' || state.credentials.length === 0) { throw new AuthError('setup_unavailable', 'Passkey authentication is not configured.'); } this.recordAuthenticationAttempt(now, peerId, false); const authenticationOptions = await this.webAuthn.generateAuthenticationOptions({ rpID: state.config.rpId, timeout: authenticationCeremonyLifetimeMs, userVerification: 'required', }); const ceremonyId = randomBase64Url(); const ceremony: IWebAuthnCeremonyDocument = { id: ceremonyId, kind: 'authentication', state: 'pending', peerId, challenge: authenticationOptions.challenge, origin, rpId: state.config.rpId, userId: state.webauthnUserId, createdAt: now, expiresAt: new Date(now.getTime() + authenticationCeremonyLifetimeMs), }; await this.store.createCeremony(ceremony); return { ceremonyId, options: authenticationOptions }; }); } public async finishAuthentication( optionsArg: IAuthenticationFinishOptions, ): Promise { const peerId = requirePeerId(optionsArg.peerId); const origin = requireOrigin(optionsArg.origin); const ceremonyId = requireCeremonyId(optionsArg.ceremonyId); const now = this.currentTime(); const state = await this.store.getState(); this.assertConfiguredOrigin(origin, state.config.publicOrigin); const ceremony = await this.store.consumeCeremony({ ceremonyId, kind: 'authentication', peerId, origin, rpId: state.config.rpId, userId: state.webauthnUserId, now, }); if (state.authState !== 'ready' || state.credentials.length === 0) { throw new AuthError('setup_unavailable', 'Passkey authentication is not configured.'); } const userHandle = optionsArg.response.response.userHandle; if ( typeof userHandle !== 'string' || !base64UrlValuesEqual(userHandle, state.webauthnUserId) ) { throw new AuthError('verification_failed', 'The passkey user handle is invalid.'); } const credential = state.credentials.find( (entry) => entry.id === optionsArg.response.id, ); if (!credential) { throw new AuthError('credential_not_found', 'The passkey credential is unknown.'); } this.recordAuthenticationAttempt(now, peerId, true); let verification: Awaited>; try { verification = await this.webAuthn.verifyAuthenticationResponse({ response: optionsArg.response, expectedChallenge: ceremony.challenge, expectedOrigin: ceremony.origin, expectedRPID: ceremony.rpId, credential: { id: credential.id, publicKey: Buffer.from(credential.publicKey, 'base64url'), counter: credential.counter, transports: [...credential.transports], }, requireUserVerification: true, advancedFIDOConfig: { userVerification: 'required', }, }); } catch (errorArg) { throw new AuthError('verification_failed', 'Passkey authentication verification failed.', { cause: errorArg, }); } if ( !verification.verified || !verification.authenticationInfo.userVerified || verification.authenticationInfo.credentialID !== credential.id || verification.authenticationInfo.origin !== ceremony.origin || verification.authenticationInfo.rpID !== ceremony.rpId ) { throw new AuthError('verification_failed', 'Passkey authentication verification failed.'); } await this.store.updateCounter( credential.id, credential.counter, verification.authenticationInfo.newCounter, ); return { authenticated: true, credentialId: credential.id }; } public async preflightAuthenticationFinish( optionsArg: IAuthenticationFinishOptions, ): Promise { const peerId = requirePeerId(optionsArg.peerId); const origin = requireOrigin(optionsArg.origin); const ceremonyId = requireCeremonyId(optionsArg.ceremonyId); const now = this.currentTime(); const state = await this.store.getState(); this.assertConfiguredOrigin(origin, state.config.publicOrigin); if (state.authState !== 'ready' || state.credentials.length === 0) { throw new AuthError('setup_unavailable', 'Passkey authentication is not configured.'); } const userHandle = optionsArg.response.response.userHandle; if ( typeof userHandle !== 'string' || !base64UrlValuesEqual(userHandle, state.webauthnUserId) ) { throw new AuthError('verification_failed', 'The passkey user handle is invalid.'); } if (!state.credentials.some((entry) => entry.id === optionsArg.response.id)) { throw new AuthError('credential_not_found', 'The passkey credential is unknown.'); } await this.store.assertPendingCeremony({ ceremonyId, kind: 'authentication', peerId, origin, rpId: state.config.rpId, userId: state.webauthnUserId, now, }); } private currentTime(): Date { const now = this.now(); if (!(now instanceof Date) || !Number.isFinite(now.getTime())) { throw new Error('The PasskeyManager clock returned an invalid Date.'); } return new Date(now); } private recordSetupAttempt(nowArg: Date): void { const cutoff = nowArg.getTime() - this.setupAttemptWindowMs; while ( this.setupAttemptTimestamps.length > 0 && this.setupAttemptTimestamps[0] <= cutoff ) { this.setupAttemptTimestamps.shift(); } if (this.setupAttemptTimestamps.length >= this.setupAttemptLimit) { throw new AuthError('rate_limited', 'Too many setup attempts. Try again later.'); } this.setupAttemptTimestamps.push(nowArg.getTime()); } private recordAuthenticationAttempt( nowArg: Date, peerIdArg: string, includeGlobalVerificationLimitArg: boolean, ): void { const now = nowArg.getTime(); const cutoff = now - this.authenticationAttemptWindowMs; while ( this.authenticationVerificationAttemptTimestamps.length > 0 && this.authenticationVerificationAttemptTimestamps[0] <= cutoff ) { this.authenticationVerificationAttemptTimestamps.shift(); } for (const [peerId, timestamps] of this.authenticationAttemptTimestampsByPeer) { while (timestamps.length > 0 && timestamps[0] <= cutoff) timestamps.shift(); if (timestamps.length === 0) this.authenticationAttemptTimestampsByPeer.delete(peerId); } const peerTimestamps = this.authenticationAttemptTimestampsByPeer.get(peerIdArg) ?? []; if (peerTimestamps.length >= this.authenticationAttemptLimitPerPeer) { throw new AuthError('rate_limited', 'Too many authentication attempts. Try again later.'); } if ( includeGlobalVerificationLimitArg && this.authenticationVerificationAttemptTimestamps.length >= maxAuthenticationVerificationAttemptsPerWindow ) { throw new AuthError('rate_limited', 'Too many passkey verification attempts. Try again later.'); } if ( peerTimestamps.length === 0 && this.authenticationAttemptTimestampsByPeer.size >= this.maxActiveAuthenticationCeremonies * 2 ) { throw new AuthError('rate_limited', 'Too many authentication peers. Try again later.'); } if (includeGlobalVerificationLimitArg) { this.authenticationVerificationAttemptTimestamps.push(now); } peerTimestamps.push(now); this.authenticationAttemptTimestampsByPeer.set(peerIdArg, peerTimestamps); } private async assertCeremonyCapacity( kindArg: 'setup' | 'authentication', nowArg: Date, ): Promise { const maximum = kindArg === 'setup' ? this.maxActiveSetupCeremonies : this.maxActiveAuthenticationCeremonies; const active = await this.store.countActiveCeremonies(kindArg, nowArg); if (active >= maximum) { throw new AuthError('ceremony_limit', `Too many active ${kindArg} ceremonies.`); } } private assertConfiguredOrigin(actualArg: string, expectedArg: string): void { if (actualArg !== expectedArg) { throw new AuthError('origin_mismatch', 'The request origin is not trusted.'); } } private async withCeremonyCreationLock( functionArg: () => Promise, ): Promise { const previous = this.ceremonyCreationTail; let release!: () => void; this.ceremonyCreationTail = new Promise((resolve) => { release = resolve; }); await previous; try { return await functionArg(); } finally { release(); } } }