Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added public/passkey-helper.zip
Binary file not shown.
20 changes: 18 additions & 2 deletions src/api/controllers/instance.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
71 changes: 71 additions & 0 deletions src/api/controllers/passkey.controller.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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 };
Original file line number Diff line number Diff line change
@@ -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<void>;
}

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<void> {
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<void> {
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<void> {
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<void> {
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<string> {
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('<ref> 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);
}
}
Loading