mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
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>
114 lines
3.7 KiB
TypeScript
114 lines
3.7 KiB
TypeScript
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!
|
|
// 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(GCM_IV_LENGTH)
|
|
const cipher = crypto.createCipheriv(
|
|
'aes-256-gcm',
|
|
Buffer.from(ENCRYPTION_KEY, 'hex'),
|
|
iv,
|
|
)
|
|
let encrypted = cipher.update(text, 'utf8', 'hex')
|
|
encrypted += cipher.final('hex')
|
|
const authTag = cipher.getAuthTag()
|
|
return `${iv.toString('hex')}:${encrypted}:${authTag.toString('hex')}`
|
|
}
|
|
|
|
export function decrypt(encryptedText: string): string {
|
|
const parts = encryptedText.split(':')
|
|
|
|
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
|
|
})`,
|
|
)
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
}
|