Skip to content

Implement group message end-to-end encryption (finish the 5 message-service stubs) #182

Description

@TortoiseWolfe

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. 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 getMessageHistory on 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

Line Method (@def) Caller What it must do
L250 sendMessage() (@150) ConversationView.tsx:264 fetch group key at current_key_version, encryptMessage(content, groupKey), insert with key_version
L691 getMessageHistory() (@579) ConversationView.tsx:157 (on mount → immediate blocker) per message, fetch key by that message's key_version, decryptMessage(...) (handles rotation)
L1118 editMessage() (@1042) ConversationView.tsx:323 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)

Line Method (@def) Caller Fix
L1334 archiveConversation() (@1311) useConversationList.ts:423 write per-member conversation_members.archived
L1420 unarchiveConversation() (@1397) useConversationList.ts:443 same, unset archived

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:

  • generateGroupKey()CryptoKey
  • encryptGroupKeyForMember(groupKey, memberPublicKey, senderPrivateKey) / decryptGroupKey(...)
  • getGroupKeyForConversation(conversationId, keyVersion) → CryptoKey ← the method the message path needs (cached; fetches the user's group_keys row, ECDH-decrypts, returns a ready AES-GCM key)
  • distributeGroupKey(conversationId, members, keyVersion) — generates + per-member encrypts + inserts into group_keys
  • 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 set messages.key_version on insert (1:1 leaves it defaulted), and decrypt keys by each message's key_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:

⚠️ 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).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requestpriority:p2Medium — schedule (feature gaps, partial implementations)template-v1-out-of-scopeApplication-level features deferred from template v1; not blockers for any fork

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions