πŸ“„ raw markdown for agents/LLMs: /HOW-E2EE-WORKS.md Β· all docs

How encrypted Messenger works β€” CAT, PIN, devices, and how bluebut reads/sends it

⭐ TARGET ARCHITECTURE (2026-07-09): whatsmeow is the ONE path β€” LightSpeed is being DELETED. bluebut reads + sends Messenger DMs only through whatsmeow (the bluebut-e2ee daemon = a registered encrypted-messaging device per account). The old LightSpeed subsystem (browser sync-inbox, the persistent LS socket, ls-keeper, etc.) is being removed. The daemon decrypts every inbound message and the bridge builds the inbox from that stream (upserts the thread + contact; names come from the fb_person graph). Accepted trade: no thread-list of idle chats, no history, no folders, no plain (non-encrypted) threads. This page explains the mechanics (they don't change) β€” just note that where it says "LightSpeed does X", X is going away and whatsmeow + the bridge cover the parts we keep.

A from-scratch explainer. If you only remember one thing: to read or send an encrypted Messenger message, you have to BE a device on the account. Facebook's servers physically cannot hand you the plaintext β€” that's the whole point of end-to-end encryption. So bluebut registers its own device and does the crypto itself, exactly the way the maintained bridge (mautrix-meta) does.


TL;DR


The picture

bluebut Β· Messenger read + send green = browser-free Β· amber = the one browser touch (CAT, ~24h) Facebook relays messages (can't read E2EE) Real browser Β· cloakbox reads encrypted_serialized_cat off /messages/ β†’ the CAT the ONLY browser touch Β· once/~24h bluebut-e2ee daemon (prod) holds both sockets in RAM whatsmeow socket Β· E2EE (type-15) = a REGISTERED device (Signal keys) decrypts inbound Β· encrypts outbound auth: CAT (24h) + device (register-once) keys persist in WA_DB β†’ survive reconnect LightSpeed socket Β· plain (type-1) thread list, folders, plain send/receive auth: keyvault jar + proxy browser-free fb_message bluebut-db is_e2ee Β· decrypted_by whatsmeow-keepalive */5 mints CAT β†’ reconnects whatsmeow ls-keeper */5 holds LightSpeed (browser-free) PIN / secure storage HISTORY backup only β€” NOT live ciphertext plaintext decrypted mint CAT

Read it as: Facebook relays messages β†’ the daemon's two sockets receive them β†’ decrypted/plain rows land in fb_message. The whatsmeow socket (green) needs a CAT minted by the real browser (amber, once/~24h); the LightSpeed socket is browser-free. The keepers hold both up. The PIN hangs off to the side β€” history backup only, never on the live path.


1. Two message worlds

Messenger thread
β”œβ”€β”€ thread_type = 1   β†’ PLAIN        server can read it     β†’ LightSpeed wire
└── thread_type = 15  β†’ E2EE         only devices can read  β†’ Signal protocol (whatsmeow)

Why this split matters: a plain message is just data on a wire β€” read it. An E2EE message is a sealed envelope addressed to specific devices β€” you can only open it if you are one of those devices.

A decrypted E2EE message is self-contained. whatsmeow does not hand you "just the body" β€” the decrypted payload carries its own thread_key, sender_id, timestamp_ms, message_id, and text (see handler.go DecryptedMessage). What whatsmeow does not give you is the inbox around it: the list of threads, folders (inbox/requests/spam), unread counts, contact display names, and participant rosters β€” plus all your plain (type-1) traffic. That's LightSpeed's job. So the two are complementary: whatsmeow = complete E2EE messages (raw IDs); LightSpeed = the inbox + names + folders + plain messages. Without LightSpeed you'd have decrypted E2EE messages as numeric IDs floating with no threads, no names, and no plain chats.


2. Why E2EE forces us to BE a device

End-to-end encryption means the plaintext exists only on the endpoints (the devices), never on Facebook's servers. There is no API that returns the decrypted body, because Facebook literally does not have it.

So there are only two ways to read an E2EE message:

  1. Scrape the rendered browser after it has decrypted the message on-screen (brittle, localized, and needs the PIN for history). ❌ We don't do this.
  2. Become a device: register our own E2EE device on the account, receive the ciphertext addressed to it, and decrypt it ourselves with the Signal keys. βœ… This is what bluebut does.

That "own device" is the bluebut-e2ee daemon β€” a Go service running whatsmeow (the same library that talks to WhatsApp; it also speaks Meta's E2EE). One registered device per account.

Do we need LightSpeed if whatsmeow already gives full messages?

A fair question β€” a decrypted E2EE message is self-contained (thread_key + sender + ts + text). So what does LightSpeed add? Everything that isn't the message content. Precise split:

whatsmeow (E2EE) LightSpeed
E2EE message body + its thread_key / sender / ts / id βœ… ❌ (encrypted envelope only)
Plain (type-1) message bodies (groups, business, pages, old threads) ❌ βœ…
Thread list / inbox β€” which conversations exist ❌ βœ…
Folders (inbox / requests / spam), unread counts ❌ βœ…
Contact display names (who is 61583351473582?) ❌ raw IDs βœ…
Participant rosters ❌ βœ…
History (messages before we connected) ❌ realtime-only βœ… (initial mailbox sync)

The honest answer:

bluebut is the second case (an operator inbox handling both plain + E2EE), so it runs both: LightSpeed = the inbox + address book + plain messages + history; whatsmeow = the decryptor for the encrypted bodies.

   Other person's phone                         bluebut-e2ee daemon
   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”   ciphertext (E2EE)   β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
   β”‚ encrypts for  β”‚ ───────────────────▢  β”‚ whatsmeow device        β”‚
   β”‚ each of the   β”‚      via FB relay     β”‚ = a registered device   β”‚
   β”‚ account's     β”‚                       β”‚ decrypts with its own   β”‚
   β”‚ devices       β”‚                       β”‚ Signal session keys     β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜                       β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                                         β”‚ plaintext
                                                         β–Ό
                                              bridge β†’ fb_message (is_e2ee)

3. The players

Thing What it is Lifetime Where it lives
Account the FB login (cookies c_user/xs/datr) long keyvault facebook/accounts/<slug>
Web/LightSpeed session the plain-message socket (uses the cookies) until logout held by the daemon (ls-session.go)
E2EE device our registered whatsmeow device (has its own keys) register once, reuse forever WA_DB (whatsmeow store) + keyvault bluebut/e2ee-device/<slug>
Signal sessions the per-conversation double-ratchet keys persist WA_DB
CAT the ticket that authenticates the E2EE websocket ~24h minted per connect from the browser; not stored long
PIN / secure storage encrypts the history backup account-set Facebook's servers (restored via PIN)

Two things are commonly confused β€” keep them apart:

They have nothing to do with each other. You need the CAT to connect; you never need the PIN to send/receive live.


4. The CAT β€” the connection ticket (deep dive)

What it is: the encrypted_serialized_cat β€” a token Facebook mints for a logged-in Messenger web session. When you open facebook.com/messages/, the page embeds it in MessengerWebInitData. whatsmeow sends it as the bearer credential (ClientPayload.FbCat) when it opens the E2EE Noise websocket. It proves "this account is authenticated and this device is authorized to connect."

Why it expires (~24h): it's derived from the web session and is session-scoped. Facebook rotates it. An expired CAT = the websocket won't open (or drops).

Why we can't refresh it headlessly: minting a CAT requires a real logged-in browser page (it's baked into MessengerWebInitData). Facebook's anti-bot rejects a scripted/headless login ("xs cookie was deleted"), so you can't just curl for a fresh CAT. In the daemon, RefreshCAT is a deliberate hard-error stub:

// client.go
e2ee.RefreshCAT = func(ctx context.Context) error {
    // The CAT expires (~24h). We can't refresh it headlessly (that needs the web session).
    return fmt.Errorf("CAT refresh requires a fresh box-side probe (reconnect the account)")
}

How bluebut mints one: cat-connect.js drives the account's real cloakbox browser (cdp_drive β†’ go to /messages/ β†’ read encrypted_serialized_cat + appId off the page), then POSTs the daemon /connect. This is the one and only browser touch in the whole E2EE stack, and it's once per ~24h per account, not per message.

⚠️ The CAT mint MUST drive the public cloakbox gateway (https://cloakbox.hostbun.cc), not a pbox loopback β€” see cloak-client.js DEFAULT_DRIVE_BASE.


5. The device β€” register ONCE (ICDC)

Before it can connect, our whatsmeow client must be a registered E2EE device on the account. This is the ICDC flow (Identity Consistency Data Cache):

POST reg-e2ee.facebook.com/v2/fb_icdc_fetch      ← get the account's device list
POST reg-e2ee.facebook.com/v2/fb_register_v2     ← add OUR device, signed
   β†’ returns WADeviceID  (e.g. 1159930757:960@msgr)

Think of it like linking a WhatsApp companion device: you scan the QR once; after that the device stays linked and just reconnects.


6. Where decryption actually happens β€” the Signal sessions

Once registered + connected, the daemon holds Signal double-ratchet sessions (in WA_DB), one per conversation partner. This is the actual crypto:

The CAT is transport auth; the Signal session keys are the message crypto. The CAT gets you connected; the session keys (already in WA_DB) do the decryption. That's why a reconnect (fresh CAT) resumes reading immediately β€” the session keys never left.


7. The PIN / secure storage β€” history, NOT live

When you enable E2EE, Facebook also creates "secure storage": an encrypted backup of your message history (and some key material), protected by a PIN (6-digit) or device-based key. A new device restores that history by PIN-unlock.

Crucially β€” and this is the part people trip on:

So the dividing line for "can we read a message?" is not the PIN β€” it's when our device registered:

message sent AFTER our device registered   β†’ we have a session for it β†’ WE CAN READ IT (no PIN)
message sent BEFORE our device registered   β†’ history β†’ needs secure-storage restore (PIN)

The PIN/restore path in the code is handleMessageWithProtobufsRestoreV2 + the browser-side unlock-e2ee.js β€” noted as orthogonal and not used by the daemon's live path. bluebut currently does live only, which is all that's needed for send/receive.


8. The full lifecycle

  β”Œβ”€ ONCE per account ──────────────────────────────────────────────┐
  β”‚  1. mint CAT (drive the real browser, read encrypted_serialized_cat)
  β”‚  2. ICDC register  β†’ wa_device_id   (fires the one new-device alert)
  β”‚  3. store wa_device_id (WA_DB + keyvault)   ← register-once
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                              β”‚
  β”Œβ”€ every connect (after restart / CAT expiry) ─────────────────────┐
  β”‚  4. mint a fresh CAT (browser)                                    β”‚
  β”‚  5. daemon /connect {fbid, app_id, cat, proxy, wa_device_id}      β”‚
  β”‚     β†’ whatsmeow opens the Noise websocket, REUSES the device      β”‚
  β”‚     β†’ resumes the Signal sessions from WA_DB                      β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                              β”‚
  β”Œβ”€ while connected (the standing socket) ──────────────────────────┐
  β”‚  RECEIVE: ciphertext arrives β†’ decrypt with session β†’ fb_message  β”‚
  β”‚           (is_e2ee=true, decrypted_by='daemon')                   β”‚
  β”‚  SEND:    encrypt with session β†’ push over the socket β†’ verified  β”‚
  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Everything after step 2 that isn't a fresh CAT is browser-free. The device + Signal keys persist; only the CAT needs the periodic browser mint.


9. What has to be running for realtime send + receive

Per account, two daemon-held sockets (both live in RAM in the bluebut-e2ee daemon, so a daemon restart drops them and they must be re-established):

Socket Handles Auth Browser? Kept up by
LightSpeed plain (type-1) receive + send, thread list, folders keyvault jar + proxy no ls-keeper (*/5)
whatsmeow / E2EE type-15 receive (decrypt) + send CAT + registered device yes, for the CAT only (once/~24h) whatsmeow-keepalive (*/5 heal + 6h refresh)

And the daemon itself must be up. It all runs in PROD (Coolify on hostbun); local dev just talks to the public daemon (E2EE_DAEMON_URL=https://bluebut-e2ee.blpk.cc) β€” you can't hold these sockets on a laptop.


10. Scenarios (the questions people actually ask)

"Can we read an E2EE message without the PIN?" βœ… Yes, if it was sent after our device registered. Our device has the Signal session, so the daemon decrypts it directly β€” no PIN. (Proven: philip/pojtie have inbound E2EE messages decrypted_by='daemon', e2ee_no_text=0, zero PIN entered.) The only thing the PIN would add is back-history from before the device existed.

"What happens if we SEND without the PIN?" βœ… Sends fine β€” our device establishes a fresh Signal session with the recipient and encrypts. The PIN is irrelevant to sending live messages.

"What about on refresh / reconnect?" The device stays registered and its Signal sessions persist in WA_DB. Reconnecting (with a valid CAT) resumes live send/receive with no PIN and no re-registration.

"What about after a daemon restart / deploy?" The in-RAM sockets die β†’ the keepers reconnect. LightSpeed comes back browser-free in ≀5 min; E2EE needs a CAT mint (currently a browser touch β€” see the optimization below).

"Why did I get a 'new device logged in' alert once?" That's ICDC registration (step 2). It fires once per account, then never again (register-once).


11. How data is saved (the data model)

The flow per received message: whatsmeow decrypts β†’ the daemon emits a DecryptedMessage β†’ the bridge handleE2eeIngest upserts several tables (all in bluebut-db). Every message becomes a row β€” no type is dropped.

Table Written per message Key fields Dedup key
fb_message ALWAYS β€” one row for every message, any type message_type (textΒ·imageΒ·videoΒ·audioΒ·fileΒ·stickerΒ·locationΒ·contactΒ·view_once), text (body/caption), sender_id, thread_key (canonical), timestamp_ms, is_self, is_e2ee=true, decrypted_by='daemon' (account, message_id)
fb_message_attachment for each media part attachment_type, mime_type, filename, filesize, preview_width/height; attachment_fbid is synthesized (<message_id>-<n>) (account, message_id, attachment_fbid)
fb_thread upserted so the conversation appears in the inbox thread_type=1, folder_name='inbox', last_activity_timestamp_ms (account, thread_key)
fb_contact upserted for the sender id (numeric), name β€” resolved from the fb_person entity graph (whatsmeow only gives the id) (account, id)

All writes are merge-upserts (Prefer: resolution=merge-duplicates), so re-ingesting the same message is idempotent β€” safe with the realtime socket's own echo of our sent copies.

⚠️ What is NOT saved yet (honest gaps)

The daemon currently captures the message existence + type + caption + attachment metadata, but not everything whatsmeow could give:

These are the backlog. Everything above the line (message + type + caption + basic attachment + thread + contact + name) is live and verified.

12. How bluebut implements it (file map)

File Role
messenger/e2ee/daemon/ (Go) the bluebut-e2ee daemon β€” holds the whatsmeow + LightSpeed sockets
β”œβ”€ client.go E2EE connect flow: CAT injection, RefreshCAT stub, ClientPayload
β”œβ”€ register.go ICDC register (icdc_fetch / icdc_register) β†’ wa_device_id
β”œβ”€ ls-session.go the persistent LightSpeed socket (plain receive + send)
└─ main.go HTTP control API (/connect, /e2ee-send, /ls-send, /status, /ls-connect)
messenger/lib/cat-connect.js mints the CAT from the real browser + POSTs /connect
bin/whatsmeow-keepalive.mjs E2EE keep-alive: check status β†’ mint CAT β†’ reconnect (crons */5, 6h --force)
bin/ls-keeper.mjs LightSpeed keep-alive: hold the plain sockets open (browser-free, */5)
messenger/jobs/send-message.js the send job β€” browser-free; routes type-15 β†’ /e2ee-send, type-1 β†’ /ls-send
fb_message (bluebut-db) where decrypted messages land (is_e2ee, decrypted_by, is_self)

13. Gotchas & guardrails


14. The open optimization β€” cache the CAT

Right now the reconnect sweep mints a fresh CAT on every reconnect (a cloakbox browser touch each time). But a CAT is valid ~24h and is reusable across reconnects. So the improvement:

Persist the CAT (with its mint timestamp). On reconnect, try the cached CAT first β€” if it's still valid (<~20h), connect browser-free; only drive the cloakbox to mint a fresh one when the cached CAT is near expiry.

That would make daemon-restart recovery browser-free and instant, and cut cloakbox CAT mints from "every reconnect" to "once per ~20h per account" β€” the fewest possible browser touches while staying always-up.