From 01b8b0b1d615983f70502fdf1bca705696dce369 Mon Sep 17 00:00:00 2001 From: Mateus Ferreira <103341236+mateus2001ferreira@users.noreply.github.com> Date: Tue, 7 Jul 2026 11:04:37 -0300 Subject: [PATCH] feat(baileys): add passkey companion-linking ceremony Add opt-in support for WhatsApp's passkey (Shortcake/CRSC) companion-linking flow, implemented on top of Baileys' public API without forking it. A browser helper extension performs the WebAuthn assertion; the ceremony is driven via public /passkey-ceremony endpoints and exposed through connectionState. Gated behind PASSKEY_CEREMONY_ENABLED (default off) since a malformed IQ can get the number banned. Requires PASSKEY_PUBLIC_URL reachable by the browser. --- public/passkey-helper.zip | Bin 0 -> 6448 bytes src/api/controllers/instance.controller.ts | 20 ++- src/api/controllers/passkey.controller.ts | 71 ++++++++ .../passkey/passkey-ceremony.crypto.ts | 100 +++++++++++ .../passkey/passkey-ceremony.orchestrator.ts | 162 ++++++++++++++++++ .../whatsapp/whatsapp.baileys.service.ts | 35 ++++ src/api/routes/index.router.ts | 2 + src/api/routes/passkey.router.ts | 25 +++ src/api/services/passkey-ceremony.store.ts | 144 ++++++++++++++++ 9 files changed, 557 insertions(+), 2 deletions(-) create mode 100644 public/passkey-helper.zip create mode 100644 src/api/controllers/passkey.controller.ts create mode 100644 src/api/integrations/channel/whatsapp/passkey/passkey-ceremony.crypto.ts create mode 100644 src/api/integrations/channel/whatsapp/passkey/passkey-ceremony.orchestrator.ts create mode 100644 src/api/routes/passkey.router.ts create mode 100644 src/api/services/passkey-ceremony.store.ts diff --git a/public/passkey-helper.zip b/public/passkey-helper.zip new file mode 100644 index 0000000000000000000000000000000000000000..8e95bfdb444e82ef85b722fbcaa5cddb60208a9d GIT binary patch literal 6448 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 literal 0 HcmV?d00001 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}`; +}