diff --git a/src/app/api/whatsapp/send/route.ts b/src/app/api/whatsapp/send/route.ts index a2dd1ea..fb791da 100644 --- a/src/app/api/whatsapp/send/route.ts +++ b/src/app/api/whatsapp/send/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server' import { createClient } from '@/lib/supabase/server' import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api' -import { decrypt } from '@/lib/whatsapp/encryption' +import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption' import { sanitizePhoneForMeta, isValidE164, @@ -116,6 +116,26 @@ export async function POST(request: Request) { const accessToken = decrypt(config.access_token) + // Self-heal legacy CBC-encrypted tokens. Fire-and-forget: we + // return from the send without waiting, so a failed upgrade just + // means the next send tries again. The upgrade is idempotent — + // concurrent sends both produce valid GCM ciphertexts of the same + // plaintext, last write wins. + if (isLegacyFormat(config.access_token)) { + void supabase + .from('whatsapp_config') + .update({ access_token: encrypt(accessToken) }) + .eq('id', config.id) + .then(({ error }) => { + if (error) { + console.warn( + '[whatsapp/send] access_token GCM upgrade failed:', + error.message, + ) + } + }) + } + // Send via Meta API — retry with phone-number variants if Meta rejects // with "recipient not in allowed list" (common in sandbox / when a // number was registered with/without a trunk 0). If an alternate diff --git a/src/app/api/whatsapp/webhook/route.ts b/src/app/api/whatsapp/webhook/route.ts index 13a3181..844d61c 100644 --- a/src/app/api/whatsapp/webhook/route.ts +++ b/src/app/api/whatsapp/webhook/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server' import { createClient } from '@supabase/supabase-js' -import { decrypt } from '@/lib/whatsapp/encryption' +import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption' import { getMediaUrl, downloadMedia } from '@/lib/whatsapp/meta-api' import { normalizePhone, phonesMatch } from '@/lib/whatsapp/phone-utils' import { verifyMetaWebhookSignature } from '@/lib/whatsapp/webhook-signature' @@ -87,19 +87,40 @@ export async function GET(request: Request) { ) } - // Check if any config's verify_token matches + // Check if any config's verify_token matches. Also collect the + // matching row so we can opportunistically upgrade its token to + // GCM if it was still in the legacy CBC format. // eslint-disable-next-line @typescript-eslint/no-explicit-any - const matched = configs.some((config: any) => { - if (!config.verify_token) return false + let matchedConfig: any = null + for (const config of configs) { + if (!config.verify_token) continue try { - const decrypted = decrypt(config.verify_token) - return decrypted === verifyToken + if (decrypt(config.verify_token) === verifyToken) { + matchedConfig = config + break + } } catch { - return false + // Malformed / wrong-key token row — skip it and keep checking. } - }) + } - if (matched) { + if (matchedConfig) { + // Fire-and-forget GCM upgrade. Safe to run on every subscribe + // since it's a no-op once the column is already GCM. + if (isLegacyFormat(matchedConfig.verify_token)) { + void supabaseAdmin() + .from('whatsapp_config') + .update({ verify_token: encrypt(verifyToken) }) + .eq('id', matchedConfig.id) + .then(({ error }: { error: unknown }) => { + if (error) { + console.warn( + '[webhook] verify_token GCM upgrade failed:', + (error as { message?: string })?.message ?? error, + ) + } + }) + } // Return challenge as plain text return new Response(challenge, { status: 200, diff --git a/src/lib/whatsapp/encryption.ts b/src/lib/whatsapp/encryption.ts index 6636988..27a6a73 100644 --- a/src/lib/whatsapp/encryption.ts +++ b/src/lib/whatsapp/encryption.ts @@ -1,29 +1,113 @@ import crypto from 'crypto' +/** + * WhatsApp token encryption. + * + * Format — GCM (current): + * `::` (three colons) + * + * Format — CBC (legacy, decrypt-only): + * `:` (one colon) + * + * Why GCM instead of CBC: + * CBC without a MAC is unauthenticated — an attacker who can write + * rows to `whatsapp_config` (directly, through a future RLS bug, or + * via a DB backup being modified) can flip bits in the ciphertext + * without the decrypt throwing. You'd silently get garbled tokens; + * worst case, if the mutated bytes happen to form a valid access + * token, messages go out under a spoofed account. GCM appends a + * 16-byte authentication tag; any tampering fails the decrypt hard. + * + * Backward compatibility: + * `decrypt()` auto-detects the format by counting parts, so legacy + * rows keep working. New `encrypt()` output is always GCM. + * Existing rows can be upgraded in place by call sites that hold a + * Supabase client — see the `isLegacyFormat` / `encrypt` pattern in + * `src/app/api/whatsapp/send/route.ts`. + */ + const ENCRYPTION_KEY = process.env.ENCRYPTION_KEY! -const IV_LENGTH = 16 +// 12 bytes is the NIST-recommended IV length for GCM — keeps the +// counter block well below 2^32 and matches the default web-crypto +// behaviour, so any future port is straightforward. +const GCM_IV_LENGTH = 12 +const CBC_IV_LENGTH = 16 +const AUTH_TAG_LENGTH = 16 export function encrypt(text: string): string { - const iv = crypto.randomBytes(IV_LENGTH) + const iv = crypto.randomBytes(GCM_IV_LENGTH) const cipher = crypto.createCipheriv( - 'aes-256-cbc', + 'aes-256-gcm', Buffer.from(ENCRYPTION_KEY, 'hex'), - iv + iv, ) let encrypted = cipher.update(text, 'utf8', 'hex') encrypted += cipher.final('hex') - return iv.toString('hex') + ':' + encrypted + const authTag = cipher.getAuthTag() + return `${iv.toString('hex')}:${encrypted}:${authTag.toString('hex')}` } export function decrypt(encryptedText: string): string { const parts = encryptedText.split(':') - const iv = Buffer.from(parts[0], 'hex') - const decipher = crypto.createDecipheriv( - 'aes-256-cbc', - Buffer.from(ENCRYPTION_KEY, 'hex'), - iv + + if (parts.length === 3) { + // GCM — current format. + const [ivHex, ctHex, tagHex] = parts + const iv = Buffer.from(ivHex, 'hex') + if (iv.length !== GCM_IV_LENGTH) { + throw new Error( + `Encrypted token has unexpected GCM IV length ${iv.length}`, + ) + } + const authTag = Buffer.from(tagHex, 'hex') + if (authTag.length !== AUTH_TAG_LENGTH) { + throw new Error( + `Encrypted token has unexpected GCM auth-tag length ${authTag.length}`, + ) + } + const decipher = crypto.createDecipheriv( + 'aes-256-gcm', + Buffer.from(ENCRYPTION_KEY, 'hex'), + iv, + ) + decipher.setAuthTag(authTag) + let decrypted = decipher.update(ctHex, 'hex', 'utf8') + decrypted += decipher.final('utf8') + return decrypted + } + + if (parts.length === 2) { + // CBC — legacy. Read-only; `encrypt()` never produces this shape. + const [ivHex, ctHex] = parts + const iv = Buffer.from(ivHex, 'hex') + if (iv.length !== CBC_IV_LENGTH) { + throw new Error( + `Encrypted token has unexpected CBC IV length ${iv.length}`, + ) + } + const decipher = crypto.createDecipheriv( + 'aes-256-cbc', + Buffer.from(ENCRYPTION_KEY, 'hex'), + iv, + ) + let decrypted = decipher.update(ctHex, 'hex', 'utf8') + decrypted += decipher.final('utf8') + return decrypted + } + + throw new Error( + `Encrypted token has unrecognised format (expected 1 or 2 colons, got ${ + parts.length - 1 + })`, ) - let decrypted = decipher.update(parts[1], 'hex', 'utf8') - decrypted += decipher.final('utf8') - return decrypted +} + +/** + * Cheap format detector — call sites use this to decide whether to + * write a refreshed GCM ciphertext back to the database after a + * successful legacy decrypt. Does not attempt decryption; purely a + * structural check. + */ +export function isLegacyFormat(encryptedText: string): boolean { + return encryptedText.split(':').length === 2 }