diff --git a/public/passkey-helper.zip b/public/passkey-helper.zip new file mode 100644 index 0000000000..8e95bfdb44 Binary files /dev/null and b/public/passkey-helper.zip differ diff --git a/src/api/controllers/instance.controller.ts b/src/api/controllers/instance.controller.ts index 6a69106881..84c4bc89a3 100644 --- a/src/api/controllers/instance.controller.ts +++ b/src/api/controllers/instance.controller.ts @@ -5,6 +5,7 @@ import { PrismaRepository } from '@api/repository/repository.service'; import { channelController, eventManager } from '@api/server.module'; import { CacheService } from '@api/services/cache.service'; import { WAMonitoringService } from '@api/services/monitor.service'; +import { buildPasskeyOpenURL, passkeyCeremonyStore } from '@api/services/passkey-ceremony.store'; import { SettingsService } from '@api/services/settings.service'; import { Events, Integration, wa } from '@api/types/wa.types'; import { Auth, Chatwoot, ConfigService, HttpServer, WaBusiness } from '@config/env.config'; @@ -391,12 +392,27 @@ export class InstanceController { } public async connectionState({ instanceName }: InstanceDto) { - return { + const instance = this.waMonitor.waInstances[instanceName]; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result: any = { instance: { instanceName: instanceName, - state: this.waMonitor.waInstances[instanceName]?.connectionStatus?.state, + state: instance?.connectionStatus?.state, }, }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const instanceId = (instance as any)?.instanceId; + if (instanceId) { + const active = passkeyCeremonyStore.stateByInstance(instanceId); + if (active) { + result.instance.passkeyStage = active.state.stage; + result.instance.passkeyCode = active.state.code || ''; + result.instance.passkeyOpenUrl = buildPasskeyOpenURL(active.token); + } + } + + return result; } public async fetchInstances({ instanceName, instanceId, number }: InstanceDto, key: string) { diff --git a/src/api/controllers/passkey.controller.ts b/src/api/controllers/passkey.controller.ts new file mode 100644 index 0000000000..d465351dc8 --- /dev/null +++ b/src/api/controllers/passkey.controller.ts @@ -0,0 +1,71 @@ +import { passkeyCeremonyStore } from '@api/services/passkey-ceremony.store'; +import { waMonitor } from '@api/server.module'; +import { Logger } from '@config/logger.config'; + +export class PasskeyController { + private readonly logger = new Logger('PasskeyController'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public getCeremony(token: string): { status: number; body: any } { + if (!token) return { status: 400, body: { error: 'token is required' } }; + + const found = passkeyCeremonyStore.lookup(token); + if (!found) return { status: 404, body: { error: 'ceremony not found or expired' } }; + + const { state } = found; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const body: any = { stage: state.stage, skipHandoffUX: state.skipHandoffUX }; + if (state.publicKey) body.publicKey = state.publicKey; + if (state.code) body.code = state.code; + if (state.error) body.error = state.error; + return { status: 200, body }; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public async submitResponse(token: string, webAuthnResponse: any): Promise<{ status: number; body: any }> { + if (!token) return { status: 400, body: { error: 'token is required' } }; + + const instanceId = passkeyCeremonyStore.instanceForToken(token); + if (!instanceId) return { status: 404, body: { error: 'ceremony not found or expired' } }; + + const instance = this.resolveInstance(instanceId); + if (!instance) return { status: 404, body: { error: 'instance not found' } }; + + try { + await instance.submitPasskeyResponse(webAuthnResponse); + return { status: 200, body: { ok: true } }; + } catch (error) { + this.logger.error(`submitResponse: ${(error as Error).message}`); + return { status: 500, body: { error: (error as Error).message } }; + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public async confirm(token: string): Promise<{ status: number; body: any }> { + if (!token) return { status: 400, body: { error: 'token is required' } }; + + const instanceId = passkeyCeremonyStore.instanceForToken(token); + if (!instanceId) return { status: 404, body: { error: 'ceremony not found or expired' } }; + + const instance = this.resolveInstance(instanceId); + if (!instance) return { status: 404, body: { error: 'instance not found' } }; + + try { + await instance.confirmPasskey(); + return { status: 200, body: { ok: true } }; + } catch (error) { + this.logger.error(`confirm: ${(error as Error).message}`); + return { status: 500, body: { error: (error as Error).message } }; + } + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private resolveInstance(instanceId: string): any { + for (const name of Object.keys(waMonitor.waInstances)) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const inst: any = waMonitor.waInstances[name]; + if (inst?.instanceId === instanceId) return inst; + } + return undefined; + } +} diff --git a/src/api/integrations/channel/whatsapp/passkey/passkey-ceremony.crypto.ts b/src/api/integrations/channel/whatsapp/passkey/passkey-ceremony.crypto.ts new file mode 100644 index 0000000000..cad43d5a27 --- /dev/null +++ b/src/api/integrations/channel/whatsapp/passkey/passkey-ceremony.crypto.ts @@ -0,0 +1,100 @@ +// Protocol reference: whatsmeow's pair-passkey.go (tulir/whatsmeow, MPL-2.0), independently reimplemented. +import { createCipheriv, createHash, createHmac, hkdfSync, randomBytes } from 'crypto'; + +function varint(nInput: number): Buffer { + let n = nInput; + const bytes: number[] = []; + while (n > 0x7f) { + bytes.push((n & 0x7f) | 0x80); + n = Math.floor(n / 128); + } + bytes.push(n & 0x7f); + return Buffer.from(bytes); +} +function lenField(field: number, data: Buffer): Buffer { + return Buffer.concat([Buffer.from([(field << 3) | 2]), varint(data.length), data]); +} +function varintField(field: number, value: number): Buffer { + return Buffer.concat([Buffer.from([(field << 3) | 0]), varint(value)]); +} + +export function encodeCompanionEphemeralIdentity(publicKey: Buffer, deviceType: number, ref: string): Buffer { + return Buffer.concat([lenField(1, publicKey), varintField(2, deviceType), lenField(3, Buffer.from(ref, 'utf8'))]); +} +export function encodeProloguePayload(companionEphemeralIdentity: Buffer, commitmentHash: Buffer): Buffer { + const commitment = lenField(1, commitmentHash); + return Buffer.concat([lenField(1, companionEphemeralIdentity), lenField(2, commitment)]); +} +export function encodePairingRequest(companionPublicKey: Buffer, companionIdentityKey: Buffer, advSecret: Buffer): Buffer { + return Buffer.concat([lenField(1, companionPublicKey), lenField(2, companionIdentityKey), lenField(3, advSecret)]); +} +export function encodeEncryptedPairingRequest(encryptedPayload: Buffer, iv: Buffer): Buffer { + return Buffer.concat([lenField(1, encryptedPayload), lenField(2, iv)]); +} + +export function decodePrimaryEphemeralIdentity(buf: Buffer): { publicKey: Buffer; nonce: Buffer } { + let publicKey = Buffer.alloc(0); + let nonce = Buffer.alloc(0); + let i = 0; + while (i < buf.length) { + const key = buf[i++]; + const field = key >> 3; + const wire = key & 0x7; + if (wire !== 2) throw new Error(`field ${field} has unexpected wire type ${wire}`); + let len = 0; + let shift = 0; + for (;;) { + const b = buf[i++]; + len |= (b & 0x7f) << shift; + if (!(b & 0x80)) break; + shift += 7; + } + const data = buf.subarray(i, i + len); + i += len; + if (field === 1) publicKey = Buffer.from(data); + else if (field === 2) nonce = Buffer.from(data); + } + if (publicKey.length !== 32) throw new Error(`primary publicKey len ${publicKey.length} != 32`); + if (nonce.length !== 32) throw new Error(`primary nonce len ${nonce.length} != 32`); + return { publicKey, nonce }; +} + +export const sha256 = (buf: Buffer): Buffer => createHash('sha256').update(buf).digest(); +export const hmacSha256 = (key: Buffer, buf: Buffer): Buffer => createHmac('sha256', key).update(buf).digest(); +export const hkdf256 = (ikm: Buffer, salt: Buffer, info: string, len = 32): Buffer => + Buffer.from(hkdfSync('sha256', ikm, salt, Buffer.from(info, 'utf8'), len)); + +export function aesGcmEncrypt(key: Buffer, iv: Buffer, plaintext: Buffer): Buffer { + const cipher = createCipheriv('aes-256-gcm', key, iv); + const ct = Buffer.concat([cipher.update(plaintext), cipher.final()]); + return Buffer.concat([ct, cipher.getAuthTag()]); +} + +export const deriveHandoffKey = (advSecretKey: Buffer): Buffer => + hkdf256(advSecretKey, Buffer.alloc(32, 0), 'shortcake-passkey-handoff-v1', 32); + +export const buildCommitment = (identBytes: Buffer, companionNonce: Buffer): Buffer => + sha256(Buffer.concat([identBytes, companionNonce])); + +export const deriveEncryptionKey = (sharedSecret: Buffer, deviceType: number, pairingRef: string): Buffer => + hkdf256( + sharedSecret, + Buffer.from(`Companion Pairing ${deviceType} with ref ${pairingRef}`, 'utf8'), + 'Pairing Information Encryption Key', + 32, + ); + +const LINKING_BASE32 = '123456789ABCDEFGHJKLMNPQRSTVWXYZ'; + +export function derivePairingCode(companionNonce: Buffer, primaryPublicKey: Buffer, primaryNonce: Buffer): string { + const digest = sha256(Buffer.concat([companionNonce, primaryPublicKey])); + const codeBytes = Buffer.alloc(5); + for (let i = 0; i < 5; i++) codeBytes[i] = primaryNonce[i] ^ digest[i]; + let bits = 0n; + for (const b of codeBytes) bits = (bits << 8n) | BigInt(b); + let encoded = ''; + for (let g = 7; g >= 0; g--) encoded += LINKING_BASE32[Number((bits >> BigInt(g * 5)) & 0x1fn)]; + return `${encoded.slice(0, 4)}-${encoded.slice(4)}`; +} + +export { randomBytes }; diff --git a/src/api/integrations/channel/whatsapp/passkey/passkey-ceremony.orchestrator.ts b/src/api/integrations/channel/whatsapp/passkey/passkey-ceremony.orchestrator.ts new file mode 100644 index 0000000000..010900bddf --- /dev/null +++ b/src/api/integrations/channel/whatsapp/passkey/passkey-ceremony.orchestrator.ts @@ -0,0 +1,162 @@ +import { Curve, getBinaryNodeChild, S_WHATSAPP_NET } from 'baileys'; + +import { buildPasskeyOpenURL, passkeyCeremonyStore } from '@api/services/passkey-ceremony.store'; + +import * as pk from './passkey-ceremony.crypto'; + +const SERVER_JID = S_WHATSAPP_NET; +const HANDOFF_TTL_MS = 5 * 60 * 1000; + +interface CeremonyDeps { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + sock: any; + instanceId: string; + deviceType: number; + logger: { info: (m: string) => void; warn: (m: string) => void; error: (m: string) => void }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + getCreds: () => any; + saveCreds: () => void | Promise; +} + +export class PasskeyCeremony { + private handoffKey: Buffer | null = null; + private handoffTs = 0; + private skipHandoffUX = false; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private cache: { keyPair: any; companionNonce: Buffer; pairingRef: string; encryptionKey?: Buffer } | null = null; + + constructor(private readonly deps: CeremonyDeps) {} + + attach(): void { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.deps.sock.ws.on('CB:notification', (node: any) => { + const type = node?.attrs?.type; + if (type === 'passkey_prologue_request') { + this.onPrologueRequest(node).catch((e) => this.fail(e as Error)); + } else if (type === 'crsc_continuation') { + this.onContinuation(node).catch((e) => this.fail(e as Error)); + } + }); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private fromServer(node: any): boolean { + const from: string = node?.attrs?.from; + return !from || from === SERVER_JID || from.endsWith('s.whatsapp.net'); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async onPrologueRequest(node: any): Promise { + if (!this.fromServer(node)) return; + const opts = getBinaryNodeChild(node, 'passkey_request_options'); + const content = opts?.content; + if (!Buffer.isBuffer(content)) throw new Error('passkey_request_options missing/unexpected'); + const publicKey = JSON.parse(content.toString('utf8')); + + const existing = passkeyCeremonyStore.stateByInstance(this.deps.instanceId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (existing && (existing.state.publicKey as any)?.challenge === publicKey?.challenge) { + return; + } + + const creds = this.deps.getCreds(); + this.handoffKey = pk.deriveHandoffKey(Buffer.from(creds.advSecretKey, 'base64')); + this.handoffTs = Date.now(); + creds.advSecretKey = pk.randomBytes(32).toString('base64'); + await this.deps.saveCreds(); + + const token = passkeyCeremonyStore.start(this.deps.instanceId, publicKey); + this.deps.logger.info(`[Passkey] prologue_request received. Open in the browser helper: ${buildPasskeyOpenURL(token)}`); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async submitResponse(webAuthnResponse: any): Promise { + const ref = await this.getCompanionRef(); + const keyPair = Curve.generateKeyPair(); + const companionNonce = pk.randomBytes(32); + const ident = pk.encodeCompanionEphemeralIdentity(Buffer.from(keyPair.public), this.deps.deviceType, ref); + const commitment = pk.buildCommitment(ident, companionNonce); + const prologuePayload = pk.encodeProloguePayload(ident, commitment); + this.cache = { keyPair, companionNonce, pairingRef: ref }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const children: any[] = [ + { tag: 'credential_id', content: Buffer.from(String(webAuthnResponse.rawId), 'base64url') }, + { tag: 'webauthn_assertion', content: Buffer.from(JSON.stringify(webAuthnResponse), 'utf8') }, + { tag: 'prologue_payload', content: prologuePayload }, + ]; + if (this.handoffKey && Date.now() - this.handoffTs < HANDOFF_TTL_MS) { + children.push({ tag: 'pairing_handoff_proof', content: pk.hmacSha256(this.handoffKey, prologuePayload) }); + this.skipHandoffUX = true; + } else { + this.skipHandoffUX = false; + } + + await this.deps.sock.query({ + tag: 'iq', + attrs: { to: SERVER_JID, type: 'set', xmlns: 'md' }, + content: [{ tag: 'passkey_prologue', content: children }], + }); + this.handoffKey = null; + passkeyCeremonyStore.setAwaitingConfirmation(this.deps.instanceId); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private async onContinuation(node: any): Promise { + if (!this.fromServer(node)) return; + if (!this.cache) throw new Error('continuation without linking cache'); + const child = getBinaryNodeChild(node, 'primary_ephemeral_identity'); + const content = child?.content; + if (!Buffer.isBuffer(content)) throw new Error('primary_ephemeral_identity missing/unexpected'); + const primary = pk.decodePrimaryEphemeralIdentity(content); + + const shared = Buffer.from(Curve.sharedKey(this.cache.keyPair.private, primary.publicKey)); + + await this.deps.sock.query({ + tag: 'iq', + attrs: { to: SERVER_JID, type: 'set', xmlns: 'md' }, + content: [{ tag: 'companion_nonce', content: this.cache.companionNonce }], + }); + + this.cache.encryptionKey = pk.deriveEncryptionKey(shared, this.deps.deviceType, this.cache.pairingRef); + const code = pk.derivePairingCode(this.cache.companionNonce, primary.publicKey, primary.nonce); + passkeyCeremonyStore.setConfirmation(this.deps.instanceId, code, false); + } + + async confirm(): Promise { + if (!this.cache?.encryptionKey) throw new Error('ceremony has no encryption key yet'); + const creds = this.deps.getCreds(); + const req = pk.encodePairingRequest( + Buffer.from(creds.noiseKey.public), + Buffer.from(creds.signedIdentityKey.public), + Buffer.from(creds.advSecretKey, 'base64'), + ); + const iv = pk.randomBytes(12); + const wrapped = pk.encodeEncryptedPairingRequest(pk.aesGcmEncrypt(this.cache.encryptionKey, iv, req), iv); + + await this.deps.sock.query({ + tag: 'iq', + attrs: { to: SERVER_JID, type: 'set', xmlns: 'md' }, + content: [{ tag: 'encrypted_pairing_request', content: wrapped }], + }); + passkeyCeremonyStore.setConfirmed(this.deps.instanceId); + this.cache = null; + } + + private async getCompanionRef(): Promise { + const res = await this.deps.sock.query({ + tag: 'iq', + attrs: { to: SERVER_JID, type: 'get', xmlns: 'md' }, + content: [{ tag: 'ref' }], + }); + const ref = getBinaryNodeChild(res, 'ref'); + const content = ref?.content; + if (!content) throw new Error(' missing in response'); + return Buffer.isBuffer(content) ? content.toString('utf8') : String(content); + } + + private fail(e: Error): void { + this.deps.logger.error(`[Passkey] ceremony failed: ${e.message}`); + passkeyCeremonyStore.setError(this.deps.instanceId, e.message); + } +} diff --git a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts index 60e857fcc1..6b538db986 100644 --- a/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts +++ b/src/api/integrations/channel/whatsapp/whatsapp.baileys.service.ts @@ -153,6 +153,7 @@ import { PassThrough, Readable } from 'stream'; import { v4 } from 'uuid'; import { BaileysMessageProcessor } from './baileysMessage.processor'; +import { PasskeyCeremony } from './passkey/passkey-ceremony.orchestrator'; import { useVoiceCallsBaileys } from './voiceCalls/useVoiceCallsBaileys'; export interface ExtendedIMessageKey extends proto.IMessageKey { @@ -715,6 +716,23 @@ export class BaileysStartupService extends ChannelStartupService { this.sendDataWebhook(Events.CALL, payload, true, ['websocket']); }); + if (process.env.PASSKEY_CEREMONY_ENABLED === 'true') { + const platformName = String(session.NAME || '').toUpperCase(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const deviceType = (proto.DeviceProps.PlatformType as any)[platformName] ?? proto.DeviceProps.PlatformType.CHROME; + this.passkeyCeremony = new PasskeyCeremony({ + sock: this.client, + instanceId: this.instanceId, + deviceType, + logger: this.logger, + getCreds: () => this.instance.authState.state.creds, + saveCreds: async () => { + await this.instance.authState.saveCreds(); + }, + }); + this.passkeyCeremony.attach(); + } + this.phoneNumber = number; return this.client; @@ -748,6 +766,23 @@ export class BaileysStartupService extends ChannelStartupService { } } + private passkeyCeremony?: PasskeyCeremony; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + public async submitPasskeyResponse(webAuthnResponse: any): Promise { + if (!this.passkeyCeremony) { + throw new BadRequestException('Passkey ceremony is disabled. Set PASSKEY_CEREMONY_ENABLED=true to enable it.'); + } + await this.passkeyCeremony.submitResponse(webAuthnResponse); + } + + public async confirmPasskey(): Promise { + if (!this.passkeyCeremony) { + throw new BadRequestException('Passkey ceremony is disabled. Set PASSKEY_CEREMONY_ENABLED=true to enable it.'); + } + await this.passkeyCeremony.confirm(); + } + private readonly chatHandle = { 'chats.upsert': async (chats: Chat[]) => { const existingChatIds = await this.prismaRepository.chat.findMany({ diff --git a/src/api/routes/index.router.ts b/src/api/routes/index.router.ts index 45c43fca5b..e604aa51a9 100644 --- a/src/api/routes/index.router.ts +++ b/src/api/routes/index.router.ts @@ -19,6 +19,7 @@ import { ChatRouter } from './chat.router'; import { GroupRouter } from './group.router'; import { InstanceRouter } from './instance.router'; import { LabelRouter } from './label.router'; +import { PasskeyRouter } from './passkey.router'; import { ProxyRouter } from './proxy.router'; import { MessageRouter } from './sendMessage.router'; import { SettingsRouter } from './settings.router'; @@ -224,6 +225,7 @@ router .use('/settings', new SettingsRouter(...guards).router) .use('/proxy', new ProxyRouter(...guards).router) .use('/label', new LabelRouter(...guards).router) + .use('', new PasskeyRouter().router) .use('', new ChannelRouter(configService, ...guards).router) .use('', new EventRouter(configService, ...guards).router) .use('', new ChatbotRouter(...guards).router) diff --git a/src/api/routes/passkey.router.ts b/src/api/routes/passkey.router.ts new file mode 100644 index 0000000000..5205c508c1 --- /dev/null +++ b/src/api/routes/passkey.router.ts @@ -0,0 +1,25 @@ +import { PasskeyController } from '@api/controllers/passkey.controller'; +import { Router } from 'express'; + +const controller = new PasskeyController(); + +export class PasskeyRouter { + public readonly router: Router = Router(); + + constructor() { + this.router.get('/passkey-ceremony/:token', (req, res) => { + const { status, body } = controller.getCeremony(req.params.token); + res.status(status).json(body); + }); + + this.router.post('/passkey-ceremony/:token/response', async (req, res) => { + const { status, body } = await controller.submitResponse(req.params.token, req.body); + res.status(status).json(body); + }); + + this.router.post('/passkey-ceremony/:token/confirm', async (req, res) => { + const { status, body } = await controller.confirm(req.params.token); + res.status(status).json(body); + }); + } +} diff --git a/src/api/services/passkey-ceremony.store.ts b/src/api/services/passkey-ceremony.store.ts new file mode 100644 index 0000000000..11c0856b86 --- /dev/null +++ b/src/api/services/passkey-ceremony.store.ts @@ -0,0 +1,144 @@ +import { randomBytes } from 'crypto'; + +export type PasskeyStage = 'challenge' | 'awaiting_confirmation' | 'confirmation' | 'confirmed' | 'error'; + +export interface PasskeyState { + stage: PasskeyStage; + publicKey?: unknown; + code?: string; + skipHandoffUX: boolean; + error?: string; +} + +interface Entry { + instanceId: string; + state: PasskeyState; + expiresAt: number; +} + +const DEFAULT_TTL_MS = 10 * 60 * 1000; + +export class PasskeyCeremonyStore { + private byToken = new Map(); + private byInstance = new Map(); + + private newToken(): string { + return randomBytes(32).toString('hex'); + } + + private prune(now: number): void { + for (const [tok, e] of this.byToken) { + if (now > e.expiresAt) { + this.byToken.delete(tok); + if (this.byInstance.get(e.instanceId) === tok) { + this.byInstance.delete(e.instanceId); + } + } + } + } + + start(instanceId: string, publicKey: unknown): string { + const now = Date.now(); + this.prune(now); + + const old = this.byInstance.get(instanceId); + if (old) this.byToken.delete(old); + + const tok = this.newToken(); + this.byToken.set(tok, { + instanceId, + state: { stage: 'challenge', publicKey, skipHandoffUX: false }, + expiresAt: now + DEFAULT_TTL_MS, + }); + this.byInstance.set(instanceId, tok); + return tok; + } + + private setStateByInstance(instanceId: string, mutate: (s: PasskeyState) => void): void { + const tok = this.byInstance.get(instanceId); + if (!tok) return; + const e = this.byToken.get(tok); + if (!e) return; + mutate(e.state); + e.expiresAt = Date.now() + DEFAULT_TTL_MS; + } + + setAwaitingConfirmation(instanceId: string): void { + this.setStateByInstance(instanceId, (s) => { + s.stage = 'awaiting_confirmation'; + s.publicKey = undefined; + s.error = undefined; + }); + } + + setConfirmation(instanceId: string, code: string, skipHandoffUX = false): void { + this.setStateByInstance(instanceId, (s) => { + s.stage = 'confirmation'; + s.code = code; + s.skipHandoffUX = skipHandoffUX; + s.error = undefined; + }); + } + + setConfirmed(instanceId: string): void { + this.setStateByInstance(instanceId, (s) => { + s.stage = 'confirmed'; + s.error = undefined; + }); + } + + setError(instanceId: string, msg: string): void { + this.setStateByInstance(instanceId, (s) => { + s.stage = 'error'; + s.error = msg; + }); + } + + clear(instanceId: string): void { + const tok = this.byInstance.get(instanceId); + if (tok) { + this.byToken.delete(tok); + this.byInstance.delete(instanceId); + } + } + + lookup(token: string): { instanceId: string; state: PasskeyState } | undefined { + this.prune(Date.now()); + const e = this.byToken.get(token); + if (!e) return undefined; + return { instanceId: e.instanceId, state: e.state }; + } + + instanceForToken(token: string): string | undefined { + const e = this.byToken.get(token); + if (!e) return undefined; + if (Date.now() > e.expiresAt) return undefined; + return e.instanceId; + } + + stateByInstance(instanceId: string): { token: string; state: PasskeyState } | undefined { + const tok = this.byInstance.get(instanceId); + if (!tok) return undefined; + const e = this.byToken.get(tok); + if (!e) return undefined; + if (Date.now() > e.expiresAt) return undefined; + return { token: tok, state: e.state }; + } + + hasActiveByInstance(instanceId: string): boolean { + const tok = this.byInstance.get(instanceId); + if (!tok) return false; + const e = this.byToken.get(tok); + if (!e) return false; + if (Date.now() > e.expiresAt) return false; + return e.state.stage !== 'error'; + } +} + +export const passkeyCeremonyStore = new PasskeyCeremonyStore(); + +export function buildPasskeyOpenURL(token: string): string { + const base = process.env.PASSKEY_PUBLIC_URL || ''; + const payload = Buffer.from(JSON.stringify({ t: token, b: base })).toString('base64url'); + return `https://web.whatsapp.com/#wapk=${payload}`; +}