You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
1:1 messaging is fully end-to-end encrypted, but group messaging is blocked by 5 stub throws in src/services/messaging/message-service.ts. This is not dead code — groups are creatable, listed in the sidebar, and selectable, and opening one throws immediately on mount. Every group conversation is currently a dead-end for the user.
The good news: the entire crypto substrate already exists and is wired.GroupKeyService generates/distributes/rotates the group AES key, and createGroup() already distributes a per-member encrypted key copy on creation. The DB schema is fully ready. The remaining work is almost entirely swapping the 5 is_group guards in message-service.ts for a group-symmetric-key path that mirrors the existing 1:1 pattern — this is a wiring job, not new cryptography.
Surfaced by the systematic code review in #181 (deliberately not fixed there — bigger than a review-scoped change).
Reachability — why this is user-facing, not latent
Groups are listed in the sidebar: useConversationList.ts:127 queries .eq('is_group', true) and builds groupItems with isGroup: true.
Selecting one mounts <ConversationView> (src/app/messages/page.tsx:118), which does not guard on is_group and calls getMessageHistoryon mount (ConversationView.tsx:157) → throw FYI #2 fires before the user does anything.
Live group-creation entry point is the /messages/new-group page (linked from UnifiedSidebar.tsx:103). ⚠️ Note: src/components/organisms/CreateGroupModal/ is NOT the live component (unused outside its own dir/stories/tests) — don't edit the wrong one.
The 5 throw sites (src/services/messaging/message-service.ts)
Split by nature — 3 are crypto, 2 are a trivial DB-field switch:
Crypto (3) — wire the group AES key into the message path
re-encrypt edited content with the current group key
Each is a ValidationError inside if (conversation.is_group) { ... } — the send-path comment even says "Group conversations use symmetric encryption (handled separately in Phase 4)".
Non-crypto (2) — just a DB-field switch (independent, small)
1:1 archive uses the conversation-level archived_by_participant_N columns; groups must use the per-member conversation_members.archived column (the comment already prescribes this). No crypto involved.
Substrate that ALREADY exists
src/services/messaging/group-key-service.ts (818 lines, singleton groupKeyService) — full lifecycle, AES-GCM-256 group key wrapped per-member via ECDH:
rotateGroupKey(conversationId) — bumps version + redistributes (already called on add/remove/leave)
group-service.createGroup() already calls distributeGroupKey(conversation.id, members, 1) on creation (group-service.ts:217) and rolls back the whole group if distribution reaches zero members. So every current member of an existing group already has a decryptable key row.
The 1:1 pattern to mirror
1:1 SEND (sendMessage): derive a per-pair ECDH shared secret → encryptMessage(content, sharedSecret) → insert encrypted_content + initialization_vector (leaves key_version defaulted).
1:1 DECRYPT (getMessageHistory): derive the same shared secret → decryptMessage(ciphertext, iv, sharedSecret), with a placeholder-on-failure fallback ('Encrypted with previous keys').
Group path = identical encryptMessage/decryptMessage calls, but replace the per-pair ECDH sharedSecret with groupKeyService.getGroupKeyForConversation(conversationId, keyVersion). The only data differences: groups setmessages.key_version on insert (1:1 leaves it defaulted), and decrypt keys by each message'skey_version so messages under a prior key still decrypt.
Data model — NO schema change needed (verified)
All required columns exist in supabase/migrations/20251006_complete_monolithic_setup.sql:
messages.is_system_message / system_message_type (L2082) — even the join/leave-notice columns exist
⚠️ One tweak (no migration): the 5 methods' SELECT currently pull only participant_1_id, participant_2_id, is_group — add current_key_version for the group branches (send/edit).
Test coverage today
Well covered:group-key-service.test.ts (28 tests, incl. encrypt→decrypt round-trip), group-service membership/creation tests, group-creation.test.ts (8), RLS group-isolation (skipped without DB env).
Locks in the stub (must be rewritten):message-service.test.ts (~L385-399) asserts rejects.toThrow('Group message encryption not yet implemented') — rewrite into behavioral encrypt/decrypt/edit assertions.
E2E gap:tests/e2e/messaging/group-chat-multiuser.spec.ts covers only UI nav + group creation — no send/open/decrypt assertions, no .skip/.todo placeholders. A multi-user "send in group as A, receive+decrypt as B" spec is missing (the iso-fixture harness already seeds multi-user).
Task breakdown
Decrypt (unblocks opening groups first): replace throw at L691. Per message, getGroupKeyForConversation(conversationId, msg.key_version) → decryptMessage; reuse the placeholder-on-failure pattern. Add current_key_version to the conversation select.
Send: replace throw at L250. Fetch key at current_key_version, encryptMessage, add key_version: current_key_version to the insert. Confirm the offline-queue path carries key_version.
Edit: replace throw at L1118, mirroring send (re-encrypt at current version).
Archive / unarchive (non-crypto, separable): replace throws at L1334 / L1420 — update conversation_members.archived for the current user.
ConversationView group awareness: group header (group_name + member avatars — data already produced by useConversationList); no message-loading guard needed once the above land.
Tests: rewrite the message-service.test.ts group-stub assertions into behavioral tests; add a multi-user group send/receive/decrypt E2E to group-chat-multiuser.spec.ts.
Verify key rotation: old messages keep their key_version; confirm decrypt selects per-message version and that pending members get their key (retryKeyDistribution) before they can decrypt. Rotation-on-membership-change is already wired; new-message flow just needs to read current_key_version.
Bottom line
This is "wire the already-distributed group AES key into 5 message-service methods," not "build group encryption." The encryption is built and tested; the message service just never calls it. Estimated: M–L (3 crypto methods + 2 trivial DB-field methods + test rewrites + a multi-user E2E).
Origin: code review #181. Related follow-up: #183 (remove the unused oauth_states CSRF layer).
Summary
1:1 messaging is fully end-to-end encrypted, but group messaging is blocked by 5 stub throws in
src/services/messaging/message-service.ts. This is not dead code — groups are creatable, listed in the sidebar, and selectable, and opening one throws immediately on mount. Every group conversation is currently a dead-end for the user.The good news: the entire crypto substrate already exists and is wired.
GroupKeyServicegenerates/distributes/rotates the group AES key, andcreateGroup()already distributes a per-member encrypted key copy on creation. The DB schema is fully ready. The remaining work is almost entirely swapping the 5is_groupguards inmessage-service.tsfor a group-symmetric-key path that mirrors the existing 1:1 pattern — this is a wiring job, not new cryptography.Surfaced by the systematic code review in #181 (deliberately not fixed there — bigger than a review-scoped change).
Reachability — why this is user-facing, not latent
useConversationList.ts:127queries.eq('is_group', true)and buildsgroupItemswithisGroup: true.<ConversationView>(src/app/messages/page.tsx:118), which does not guard onis_groupand callsgetMessageHistoryon mount (ConversationView.tsx:157) → throw FYI #2 fires before the user does anything./messages/new-grouppage (linked fromUnifiedSidebar.tsx:103).src/components/organisms/CreateGroupModal/is NOT the live component (unused outside its own dir/stories/tests) — don't edit the wrong one.The 5 throw sites (
src/services/messaging/message-service.ts)Split by nature — 3 are crypto, 2 are a trivial DB-field switch:
Crypto (3) — wire the group AES key into the message path
sendMessage()(@150)ConversationView.tsx:264current_key_version,encryptMessage(content, groupKey), insert withkey_versiongetMessageHistory()(@579)ConversationView.tsx:157(on mount → immediate blocker)key_version,decryptMessage(...)(handles rotation)editMessage()(@1042)ConversationView.tsx:323Each is a
ValidationErrorinsideif (conversation.is_group) { ... }— the send-path comment even says "Group conversations use symmetric encryption (handled separately in Phase 4)".Non-crypto (2) — just a DB-field switch (independent, small)
archiveConversation()(@1311)useConversationList.ts:423conversation_members.archivedunarchiveConversation()(@1397)useConversationList.ts:443archived1:1 archive uses the conversation-level
archived_by_participant_Ncolumns; groups must use the per-memberconversation_members.archivedcolumn (the comment already prescribes this). No crypto involved.Substrate that ALREADY exists
src/services/messaging/group-key-service.ts(818 lines, singletongroupKeyService) — full lifecycle, AES-GCM-256 group key wrapped per-member via ECDH:generateGroupKey()→CryptoKeyencryptGroupKeyForMember(groupKey, memberPublicKey, senderPrivateKey)/decryptGroupKey(...)getGroupKeyForConversation(conversationId, keyVersion) → CryptoKey← the method the message path needs (cached; fetches the user'sgroup_keysrow, ECDH-decrypts, returns a ready AES-GCM key)distributeGroupKey(conversationId, members, keyVersion)— generates + per-member encrypts + inserts intogroup_keysrotateGroupKey(conversationId)— bumps version + redistributes (already called on add/remove/leave)group-service.createGroup()already callsdistributeGroupKey(conversation.id, members, 1)on creation (group-service.ts:217) and rolls back the whole group if distribution reaches zero members. So every current member of an existing group already has a decryptable key row.The 1:1 pattern to mirror
1:1 SEND (
sendMessage): derive a per-pair ECDH shared secret →encryptMessage(content, sharedSecret)→ insertencrypted_content+initialization_vector(leaveskey_versiondefaulted).1:1 DECRYPT (
getMessageHistory): derive the same shared secret →decryptMessage(ciphertext, iv, sharedSecret), with a placeholder-on-failure fallback ('Encrypted with previous keys').Group path = identical
encryptMessage/decryptMessagecalls, but replace the per-pair ECDHsharedSecretwithgroupKeyService.getGroupKeyForConversation(conversationId, keyVersion). The only data differences: groups setmessages.key_versionon insert (1:1 leaves it defaulted), and decrypt keys by each message'skey_versionso messages under a prior key still decrypt.Data model — NO schema change needed (verified)
All required columns exist in
supabase/migrations/20251006_complete_monolithic_setup.sql:messages.key_version INTEGER DEFAULT 1(L2076) — per-message group-key selectorconversations.current_key_version INTEGER DEFAULT 1(L2060)conversation_members.archived BOOLEAN DEFAULT FALSE(L2109) — for throws [Eval-Backlog] PAYMENT-OFFLINE-UI: UI layer for existing offline payment queue #4/[Eval-Backlog] SUBSCRIPTION-MGMT: subscription management route + 4 edge functions + grace-period wiring #5group_keystable (L2119):encrypted_key TEXT,key_version,UNIQUE (conversation_id, user_id, key_version)messages.is_system_message/system_message_type(L2082) — even the join/leave-notice columns existSELECTcurrently pull onlyparticipant_1_id, participant_2_id, is_group— addcurrent_key_versionfor the group branches (send/edit).Test coverage today
group-key-service.test.ts(28 tests, incl. encrypt→decrypt round-trip),group-servicemembership/creation tests,group-creation.test.ts(8), RLS group-isolation (skipped without DB env).message-service.test.ts(~L385-399) assertsrejects.toThrow('Group message encryption not yet implemented')— rewrite into behavioral encrypt/decrypt/edit assertions.tests/e2e/messaging/group-chat-multiuser.spec.tscovers only UI nav + group creation — no send/open/decrypt assertions, no.skip/.todoplaceholders. A multi-user "send in group as A, receive+decrypt as B" spec is missing (the iso-fixture harness already seeds multi-user).Task breakdown
getGroupKeyForConversation(conversationId, msg.key_version)→decryptMessage; reuse the placeholder-on-failure pattern. Addcurrent_key_versionto the conversation select.current_key_version,encryptMessage, addkey_version: current_key_versionto the insert. Confirm the offline-queue path carrieskey_version.conversation_members.archivedfor the current user.ConversationViewgroup awareness: group header (group_name + member avatars — data already produced byuseConversationList); no message-loading guard needed once the above land.message-service.test.tsgroup-stub assertions into behavioral tests; add a multi-user group send/receive/decrypt E2E togroup-chat-multiuser.spec.ts.key_version; confirm decrypt selects per-message version and thatpendingmembers get their key (retryKeyDistribution) before they can decrypt. Rotation-on-membership-change is already wired; new-message flow just needs to readcurrent_key_version.Bottom line
This is "wire the already-distributed group AES key into 5 message-service methods," not "build group encryption." The encryption is built and tested; the message service just never calls it. Estimated: M–L (3 crypto methods + 2 trivial DB-field methods + test rewrites + a multi-user E2E).
Origin: code review #181. Related follow-up: #183 (remove the unused
oauth_statesCSRF layer).