End-to-end encrypted, persistent meeting chat
Shipped 2026-08-02, replacing an entirely broadcast-only, unpersisted chat
implementation. Migration: db/migrations/V053__e2e_encrypted_persistent_meeting_chat.sql
(group meeting-chat-e2e in database-profiles.json). Optional encrypted
key backup and restore was added later in
db/migrations/V075__chat_key_backup_restore.sql; the completed migration
record is archived at archive/docs/E2E_CHAT_ENCRYPTION_UPGRADE.md.
What was actually broken before this
hooks/useRealtimeChat.ts used Supabase Realtime broadcast exclusively —
an ephemeral pub/sub channel. Messages only ever lived in React state
(useState<ChatMessage[]>), never written to any table. Consequences:
- A message only reached the other participant if they were connected to
the channel at the exact moment it was sent. Reload the screen, or open
it a minute later, and the message was gone — there was no "async"
messaging at all, despite
meeting_chat_messages,get_meeting_chat_messages, andsend_meeting_chat_messageall already existing in the schema. - Those persistence RPCs were being called (
lib/store-messages.ts, via a confusingly-named secondstoreMessagesFnfromuseMessagesQuerythat was destructured but never actually invoked — a copy/paste leftover), but confirmed viaSELECT count(*) FROM meeting_chat_messageson production: 0 rows, ever. Givenmeetingsthemselves couldn't be created on production until the same session'sV051fix (seedb/migrations/V051__fix_meetings_speaker_id_write_type_divergence.sql), it's likely this path was simply never exercised end-to-end in production. meeting_chat_messages.sender_id's FK pointed at thepublic.userregistry on dev while every RLS policy and RPC compared againstauth.uid()— the same registry-vs-auth-id class of bug asV046/V050earlier in this session. A compliant insert (sender_id = auth.uid()) would have violated the FK on dev.- Chat-message notifications were routed through
notification.type === 'chat_message'→networking/meeting-detail?...&openChat=true(both innotifications.tsxandNotificationContext.tsx's web browser-notification handler) — butmeeting-detail.tsxnever readsopenChat. The link landed on the plain meeting detail screen with no way to reach chat from there. Fixed to route straight tonetworking/meeting-chatinstead. app/events/[eventSlug]/networking/meeting-chat.tsx(the route wrapper, not theMeetingChat.tsxcomponent) comparedmeetingData.requester_id === user.id— Better Auth's own id, not a real Supabase uuid. Missed in the originaldbUserIdaudit from v1.8.273, which fixed this exact bug in every other chat-adjacent file. Fixed to usedbUserId.
None of this was covered by any test before this change.
Design
Each user holds a device-local X25519 keypair. The private key never
leaves the device (SecureStore on native, localStorage on web — same
platform-branch pattern as lib/auth/providers/directus.ts's session
storage). Only the public key is published, via publish_user_chat_public_key,
to public.user_chat_keys (user_id uuid PK → auth.users, world-readable by
any authenticated user, writable only by the owner — public keys are not
secret by definition).
A message is encrypted with XChaCha20-Poly1305, using a key derived via
HKDF-SHA256 from the X25519 ECDH shared secret between the two
participants' keys. Because ECDH is symmetric —
getSharedSecret(myPriv, theirPub) === getSharedSecret(theirPriv, myPub) —
a single derived key decrypts every message in the conversation regardless
of which side sent it; there's no per-message key exchange. The server
(meeting_chat_messages.ciphertext/.nonce) only ever sees ciphertext — it
cannot decrypt messages even with full DB access, and send_meeting_chat_message
notifies the recipient (create_notification(..., 'chat_message', 'New message', "{sender} sent you a message.", ...)) without ever touching the
plaintext.
All primitives come from the pure-JS @noble/* suite (@noble/curves/ed25519's
x25519, @noble/hashes/hkdf+sha2, @noble/ciphers/chacha.js's
xchacha20poly1305) rather than a native crypto module — see
Why pure-JS crypto below.
Both HKDF_INFO and BACKUP_KDF_INFO are explicitly UTF-8 encoded
(utf8ToBytes(...)) before being passed as the info argument to hkdf(),
matching the same explicit-encoding pattern deriveBackupKey() already used
for the password itself, rather than handing hkdf() a raw JS string.
Optional encrypted key backup and restore
The original V053 design was device-local, but V075 adds optional continuity for reinstall and new-device flows. The private key is encrypted on the client with a user-provided password before storage; the server stores only the encrypted backup and salt. There is no password reset or server-side key escrow.
When a device has no local key but an encrypted backup exists, the chat UI offers restore. Users may skip the prompt and continue with a new keypair, which means messages encrypted under the old key remain unavailable on that device. Setup reads local storage and checks for the encrypted backup before it can generate or publish a replacement key; publishing a new public key clears the old backup, so key rotation requires creating a new backup if continuity is still desired.
decryptChatMessage() returns null (never throws) on decryption failure,
so the UI renders a per-message [Unable to decrypt this message] placeholder
rather than crashing or hiding the whole conversation. The detailed migration
schema, user flows, and security record is archived at
archive/docs/E2E_CHAT_ENCRYPTION_UPGRADE.md.
Schema
meeting_chat_messages (existing table, redesigned columns) and the
user_chat_keys table — see V053 for the core DDL and V075 for the
encrypted backup columns and RPCs. Also cleaned up in the same
migration: prod had accumulated a vestigial meeting_request_id column and
a second, older set of RLS policies referencing it
(chat_select_participant/chat_insert_participant) alongside newer
meeting_id-based ones; dev never had that legacy column but had
sender_id's (and chat_last_seen.user_id's) FK pointed at the registry
table instead of auth.users. Both environments converge on one clean shape
now. chat_last_seen also diverged in an unrelated way: prod's table never
had an updated_at column (dev's does, via its own trigger) — V053's
update_chat_last_seen() simply doesn't touch that column, since
last_seen_at is the only one the feature actually depends on.
RPCs (all uuid-typed now; the old (text, text, ...) signatures were
dropped outright since callers changed in the same commit):
send_meeting_chat_message(p_meeting_id, p_sender_id, p_ciphertext, p_nonce, p_message_type)— re-validates participancy explicitly (SECURITY DEFINER bypasses RLS, so the table's own INSERT policy predicate is duplicated inside the function body), then notifies the other participant.get_meeting_chat_messages(p_meeting_id, p_user_id)— same participancy check, returns raw ciphertext/nonce rows for client-side decryption.get_user_chat_public_key(p_user_id)/publish_user_chat_public_key(p_user_id, p_public_key)— the public-key directory read/write.update_chat_last_seen(p_user_id, p_meeting_id)/get_chat_last_seen(p_user_id, p_meeting_id)— unchanged presence-adjacent "last seen" tracking, just retyped.
Delivery: realtime and actually asynchronous
The old broadcast channel required both participants connected
simultaneously. The new hook (hooks/useRealtimeChat.ts) instead:
- Loads history via
get_meeting_chat_messageson mount (after both keys are ready), decrypting each row. - Subscribes to Supabase Realtime's
postgres_changes(notbroadcast) onmeeting_chat_messagesINSERT, filtered bymeeting_id=eq.<id>. This is driven by the actual database write — a message is only ever visible via this path after it has safely persisted, and a client that reconnects later just re-runs step 1. That's what makes delivery asynchronous rather than a live-only illusion. sendMessage()encrypts, callssend_meeting_chat_message(persist first), then appends the plaintext locally (the sender already knows it — no need to wait for the realtime echo, which is de-duped by message id if it does arrive).
Presence (online/offline, last-active-in-chat) is kept on the old
broadcast channel, unchanged — that's genuinely ephemeral information
with no reason to be persisted, unlike message content.
Key-readiness UI state
otherKeyMissing (from the hook) is true when the other participant has
never opened chat on any device, so no public key has been published for
them yet — sending would have nothing to encrypt against.
RealtimeChat.tsx disables the composer and shows an inline banner in this
state, rather than letting a send silently fail.
Why pure-JS crypto (and the one native-adjacent exception)
@noble/curves, @noble/ciphers, and @noble/hashes are pure JavaScript —
no native module, no impact on OTA-vs-native-build classification (see
eas-update-ota.md and CLAUDE.md's "Mobile Android
Release Workflow" section on detect-mobile-native-change.js). @noble/ed25519
was already a direct dependency (used server-side, in app/api/auth/wallet/solana+api.ts);
@noble/curves, @noble/ciphers, and @noble/hashes were already present
transitively but are now declared explicitly in package.json since
lib/chat-encryption.ts imports from them directly.
One exception: @noble's RNG (used for keypair generation and per-message
nonces) requires globalThis.crypto.getRandomValues, which Hermes/React
Native doesn't provide. Rather than adding react-native-get-random-values
(a real native module with iOS/Android code, which would trip the
native-build guard), lib/chat-encryption.ts polyfills it from expo-crypto's
getRandomValues — already part of the Expo SDK's linked native modules
(autolinked into every build regardless of whether it was previously
imported), so declaring it as a direct dependency adds no new native
surface, even though it does add a package.json diff.
Practical note: detect-mobile-native-change.js's guard is a simple
package.json-diff heuristic — it can't distinguish "this dependency change
is functionally native" from "this dependency was already linked, only the
declaration changed." The PR that ships this change will trigger a full
native build rather than an OTA-only release, purely because package.json
changed, even though expo-crypto and the three @noble/* packages don't
add any genuinely new native code to the app. This is expected, not a bug in
the guard worth working around.
Testing
tests/lib/chat-encryption.test.ts— the actual crypto: round-trip encrypt/decrypt, ECDH symmetry (sender can decrypt their own sent message), unique nonce per call, AEAD tamper detection (corrupted ciphertext →null, not a throw), wrong-keypair decryption →null, key generation/publish/reuse, public-key fetch (found / not-found / RPC-error cases), encrypted backup/restore, and wrong-password handling.tests/hooks/useRealtimeChat.test.tsx— hook orchestration with the crypto functions mocked: key setup sequencing, history load + decrypt,otherKeyMissinggating,postgres_changesINSERT handling with id-based de-dup, send flow (encrypt → RPC → optimistic append), and the "recipient has no key yet" send rejection.- Both migration RPCs verified end-to-end against live dev and prod via
rolled-back transactions (
BEGIN; ...; ROLLBACK;) covering: key publish/fetch, a real participant sending a message, a non-participant being rejected, message read-back, thechat_messagenotification actually being created, and last-seen read/write — following the same verification pattern used forV050/V051earlier in this session.
A latent, unrelated bug found while writing useRealtimeChat.test.tsx
packages/utils/src/performance-utils.ts schedules a real setInterval
(image-cache cleanup, every 5 minutes) at module-evaluation time, gated
only on typeof window !== 'undefined' — unlike its sibling
memory-manager.ts's cleanup interval, which also checks Platform.OS === 'web'. In this repo's Jest environment window is defined even when
Platform.OS is mocked to 'android', so any test that transitively
imports @hashpass/utils for the first time schedules a real timer that
jest.useFakeTimers() can't retroactively cancel if it's called after the
import (imports are hoisted ahead of any inline statement). Worked around
locally in the test file by calling jest.useFakeTimers() before a
require() of the hook (instead of a plain top-level import), but the
root cause in performance-utils.ts is still live for the next test file
that imports @hashpass/utils fresh. Not fixed here (out of scope for this
change) — worth a follow-up Platform.OS === 'web' guard to match
memory-manager.ts.