security: switch WhatsApp token encryption from AES-CBC to AES-GCM

CBC without a MAC is unauthenticated — anyone who can write rows to
whatsapp_config can flip bits in the stored access_token or
verify_token without the decrypt throwing. You'd silently get garbled
bytes; worst case, if the mutation happens to produce a valid token,
messages go out under a spoofed account. GCM appends a 16-byte
authentication tag; any tampering fails the decrypt.

## What changed

src/lib/whatsapp/encryption.ts
  - encrypt() now emits AES-256-GCM ciphertexts with a 12-byte IV,
    formatted as `<iv>:<ciphertext>:<authTag>` (three colons).
  - decrypt() auto-detects format by splitting on `:`:
      3 parts → GCM, validated via auth tag.
      2 parts → legacy CBC, read-only compatibility path.
    Callers are unchanged; same signature, same return type.
  - isLegacyFormat() exposes the format check so hot paths can
    opportunistically upgrade old rows.
  - IV length is validated on decrypt (12 for GCM, 16 for CBC) to
    fail fast on corrupted input.

## Self-healing existing rows

Two call sites opportunistically upgrade a legacy row to GCM after a
successful decrypt — fire-and-forget, no latency impact on the request
path, idempotent under concurrent writes.

  - src/app/api/whatsapp/send/route.ts (hot path)
      After decrypting access_token, if it was CBC, we schedule an
      UPDATE that writes a fresh GCM ciphertext back to the same row.
      The first send after deploy upgrades the token; subsequent
      sends are no-ops because isLegacyFormat returns false.

  - src/app/api/whatsapp/webhook/route.ts (subscribe handshake)
      When a config's verify_token matches the inbound
      hub.verify_token, we upgrade it in place if it was legacy.
      Runs during Meta's webhook re-subscription events, which
      happen on config changes and periodic refreshes.

No schema migration required — the DB column holds whatever string
encrypt() produces. Operators who want to force an upgrade for a
particular user can clear and re-save the WhatsApp settings form.

## Verified (tsx smoke test)
  - GCM roundtrip works (plaintext in → same plaintext out).
  - Legacy CBC decrypt works on 2-part format.
  - isLegacyFormat distinguishes new/legacy correctly.
  - Bit-flipped GCM ciphertext throws on decrypt.
  - Forged auth tag throws on decrypt.
  - Same plaintext produces different ciphertexts across calls
    (IV randomness).

## Rollback

Revert this file. Existing GCM rows would then fail to decrypt
because the previous encrypt.ts has no branch for the 3-part format.
Safer rollback: keep the decrypt branches, revert only the encrypt()
body to CBC — but since GCM roundtrips cleanly and the PR's bug
surface is small (pure crypto utility), a straight revert should only
matter if a forker had time between merging this PR and reverting to
accumulate GCM rows.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arnas Donauskas 2026-04-20 20:25:48 +03:00
parent 7182abe7d8
commit e570e1a8f2
No known key found for this signature in database
3 changed files with 148 additions and 23 deletions

View File

@ -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

View File

@ -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 (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,
)
}
})
if (matched) {
}
// Return challenge as plain text
return new Response(challenge, {
status: 200,

View File

@ -1,29 +1,113 @@
import crypto from 'crypto'
/**
* WhatsApp token encryption.
*
* Format — GCM (current):
* `<iv-hex>:<ciphertext-hex>:<authTag-hex>` (three colons)
*
* Format — CBC (legacy, decrypt-only):
* `<iv-hex>:<ciphertext-hex>` (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}`,
)
let decrypted = decipher.update(parts[1], 'hex', 'utf8')
}
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
})`,
)
}
/**
* 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
}