mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 09:19:51 +00:00
Merge pull request #71 from ArnasDon/security/encrypt-aes-gcm
security: switch WhatsApp token encryption from AES-CBC to AES-GCM
This commit is contained in:
commit
d6a4677e6c
@ -1,7 +1,7 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { createClient } from '@/lib/supabase/server'
|
import { createClient } from '@/lib/supabase/server'
|
||||||
import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api'
|
import { sendTextMessage, sendTemplateMessage } from '@/lib/whatsapp/meta-api'
|
||||||
import { decrypt } from '@/lib/whatsapp/encryption'
|
import { decrypt, encrypt, isLegacyFormat } from '@/lib/whatsapp/encryption'
|
||||||
import {
|
import {
|
||||||
sanitizePhoneForMeta,
|
sanitizePhoneForMeta,
|
||||||
isValidE164,
|
isValidE164,
|
||||||
@ -116,6 +116,26 @@ export async function POST(request: Request) {
|
|||||||
|
|
||||||
const accessToken = decrypt(config.access_token)
|
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
|
// Send via Meta API — retry with phone-number variants if Meta rejects
|
||||||
// with "recipient not in allowed list" (common in sandbox / when a
|
// with "recipient not in allowed list" (common in sandbox / when a
|
||||||
// number was registered with/without a trunk 0). If an alternate
|
// number was registered with/without a trunk 0). If an alternate
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { NextResponse } from 'next/server'
|
import { NextResponse } from 'next/server'
|
||||||
import { createClient } from '@supabase/supabase-js'
|
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 { getMediaUrl, downloadMedia } from '@/lib/whatsapp/meta-api'
|
||||||
import { normalizePhone, phonesMatch } from '@/lib/whatsapp/phone-utils'
|
import { normalizePhone, phonesMatch } from '@/lib/whatsapp/phone-utils'
|
||||||
import { verifyMetaWebhookSignature } from '@/lib/whatsapp/webhook-signature'
|
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
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
const matched = configs.some((config: any) => {
|
let matchedConfig: any = null
|
||||||
if (!config.verify_token) return false
|
for (const config of configs) {
|
||||||
|
if (!config.verify_token) continue
|
||||||
try {
|
try {
|
||||||
const decrypted = decrypt(config.verify_token)
|
if (decrypt(config.verify_token) === verifyToken) {
|
||||||
return decrypted === verifyToken
|
matchedConfig = config
|
||||||
|
break
|
||||||
|
}
|
||||||
} catch {
|
} 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 challenge as plain text
|
||||||
return new Response(challenge, {
|
return new Response(challenge, {
|
||||||
status: 200,
|
status: 200,
|
||||||
|
|||||||
@ -1,29 +1,113 @@
|
|||||||
import crypto from 'crypto'
|
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 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 {
|
export function encrypt(text: string): string {
|
||||||
const iv = crypto.randomBytes(IV_LENGTH)
|
const iv = crypto.randomBytes(GCM_IV_LENGTH)
|
||||||
const cipher = crypto.createCipheriv(
|
const cipher = crypto.createCipheriv(
|
||||||
'aes-256-cbc',
|
'aes-256-gcm',
|
||||||
Buffer.from(ENCRYPTION_KEY, 'hex'),
|
Buffer.from(ENCRYPTION_KEY, 'hex'),
|
||||||
iv
|
iv,
|
||||||
)
|
)
|
||||||
let encrypted = cipher.update(text, 'utf8', 'hex')
|
let encrypted = cipher.update(text, 'utf8', 'hex')
|
||||||
encrypted += cipher.final('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 {
|
export function decrypt(encryptedText: string): string {
|
||||||
const parts = encryptedText.split(':')
|
const parts = encryptedText.split(':')
|
||||||
const iv = Buffer.from(parts[0], 'hex')
|
|
||||||
const decipher = crypto.createDecipheriv(
|
if (parts.length === 3) {
|
||||||
'aes-256-cbc',
|
// GCM — current format.
|
||||||
Buffer.from(ENCRYPTION_KEY, 'hex'),
|
const [ivHex, ctHex, tagHex] = parts
|
||||||
iv
|
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')
|
decrypted += decipher.final('utf8')
|
||||||
return decrypted
|
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
|
||||||
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user