Skip to content

feat(chat): encrypt and decrypt end-to-end-encrypted DMs - #1619

Merged
bmc08gt merged 15 commits into
code/cashfrom
feat/e2ee-dm-crypto
Sep 29, 2026
Merged

bmc08gt merged 15 commits into
code/cashfrom
feat/e2ee-dm-crypto

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Sep 29, 2026

Copy link
Copy Markdown
Collaborator

Encrypts DM text and replies with ChatCipher (#1616) and opens them on the way in, so an end-to-end-encrypted DM renders, previews and notifies as plaintext. Stacked on #1618. Media is out of scope and still renders as unsupported.

Policy. E2eePolicy now delegates to the shared ChatEncryptionPolicy, so the @Flipcash exemption comes from FLIPCASH_USER_ID rather than an app constant. CONTACT_DM and TIP_DM count as direct messages, matching the server's IsDmChatType.

Keys. ChatContentCrypto derives the chat key from the viewer's account key pair and the peer's registered owner key from the Resolver, and caches it per chat and account. ChatCipher is injected through Hilt; every test here uses FakeChatCipher, because DefaultChatCipher's libsodium binding doesn't load on a JVM host.

Send. sendMessage, retryMessage and editMessage seal Text and Reply(Text) when the policy says so. An edit stays encrypted if the original was. If the chat can't be read, the peer key can't be fetched, or the server returns EncryptionNotAllowed, the pending row fails and offers a retry instead of sending plaintext.

Receive. ChatMessageDataSource opens messages before writing them, so the transcript, quotes and chat-list preview read plaintext from Room. Migration 39 to 40 adds ciphertext_json and encryption_state beside the content. A message that doesn't open shows the Phase 1 bubble with a hint picked by cause:

Cause Hint
Unknown scheme, or a type this client doesn't render Update Flipcash to see it
Authentication failed, peer's message Ask {first name} to send it again
Authentication failed, own message Try sending it again
Peer key fetch failed No bubble: stored KEY_PENDING, hidden, reopened on the chat's next write and at login

Marker. An "Encrypted" row sits above the oldest stored encrypted message (nodes 10416:1404, 10416:1490), placed from the transcript rather than from use_e2ee. Tapping it opens the DM encryption sheet. When its gap also needs a date separator or the unread divider, the marker draws that above itself.

Push. applyChatStyle asks the chat coordinator to open the pushed message. A long message arrives as message_id only and is read from Room, or fetched with GetMessage. Any failure keeps the server's body.

use_e2ee refresh. ChatMetadataDao.upsert wrote use_e2ee only on first insert, so a DM stored before the server set the flag never encrypted. That gap came in with #1573 and is also on code/cash; it's fixed here in its own commit.

The E2EE learn-more sheet lists its items with the same icon-and-line
row the contacts rationale uses. Moved as-is, with a spacing parameter
for the sheet's 12dp gap; existing callers keep grid.x2.
DM profiles get a footer saying messages are end-to-end encrypted when
E2eePolicy says the chat is; Group Info always says group chats aren't.
Learn More opens a sheet listing what is and isn't encrypted, one for
DMs (node 10416:1533) and one for groups (node 10557:1412).

The footer is the scaffold's bottom bar rather than MenuList's footer,
so it stays at the bottom while the rows scroll. ChatViewModel maps the
chat's metadata through E2eePolicy, which stays the only reader of
use_e2ee; the policy gains an @Inject constructor for that.
An encrypted message used to render as a tombstone saying it isn't
supported on this version. It now matches node 10416:1576: a dashed
bubble with "This message can't be displayed" and "Update Flipcash to
see it" underneath.

The line underneath is an UndecryptableHint, so decryption can add
"Ask {first name} to send it again" and "Try sending it again" for
messages that fail to open. Only UpdateApp is produced for now.
The DM profile with its footer on, Group Info, both learn-more sheets and the
unreadable-message bubble with each hint, written to build/screenshots/ for
comparison with nodes 10557:1304, 10557:1643, 10416:1533, 10557:1412 and
10416:1576. Same Robolectric mechanics as ChatIdentityScreenshotTest; the
profiles are built from the screens' scaffold, header, rows and footer because
the screens take their view models.
Three of the new strings used ’ while the group footer used ', which is what
most of strings.xml uses.
…Policy

The @Flipcash exemption now comes from ChatEncryptionPolicy, which both apps share,
so the app's own null constant and the test for its unset state are gone. CONTACT_DM
and TIP_DM map to isDirectMessage, matching the server's IsDmChatType.
ChatContentCrypto turns Text and Reply(Text) into EncryptedContent and back,
using the viewer's account key pair and the peer's registered owner key from
the Resolver. The derived chat key is cached per chat and account, so a
transcript costs one key fetch.

Opening sorts failures by what the UI should say: an unknown scheme or a
plaintext type this client doesn't render (including media) is Unsupported,
a failed authentication or a sender who isn't a member is Authentication,
and a failed key fetch is KeyPending so the message can be retried instead
of shown as broken.

ChatCipher is injected through Hilt so tests can use a fake;
DefaultChatCipher's libsodium binding doesn't load on a JVM host.
When E2eePolicy says a DM encrypts, sendMessage, retryMessage and
editMessage seal Text and Reply(Text) before the request. The pending row
and the stored reply keep the plaintext, so the viewer's own message never
has to be decrypted back.

An edit stays encrypted if the original was, even after the chat stops
encrypting; a plaintext message edited in a chat that now encrypts goes out
sealed. A send whose chat can't be read, whose peer key can't be fetched, or
that the server refuses with EncryptionNotAllowed fails its pending row, so
the transcript offers a retry rather than sending plaintext.

The fake ChatCipher moves to :services:flipcash testFixtures so the chat
module's tests can seal and open with it.
…text

Incoming messages are opened in ChatMessageDataSource before they're written,
so the transcript, quotes and chat-list preview all read plaintext from Room.
The ciphertext is kept beside it in two new columns, ciphertext_json and
encryption_state (migration 39 to 40), which lets a later copy of the same
message reuse the stored plaintext instead of opening it again.

A message that didn't open shows the "can't be displayed" bubble with a hint
picked by cause: an unknown scheme or type asks for an update; a failed
authentication asks the sender by first name to resend, or the viewer to send
their own again. A failed peer key fetch is not a decrypt failure: the row is
stored KEY_PENDING, hidden, and opened again on the chat's next write and at
login. Rows stored encrypted before this version are marked KEY_PENDING by the
migration.
An "Encrypted" row sits above the oldest stored message that arrived
encrypted, and at the head of the transcript when every message did
(nodes 10416:1404, 10416:1490). Its position comes from the stored rows,
not from use_e2ee. Tapping it opens the DM encryption sheet.

The paging separator pass takes one item per gap. When the marker's gap
also needs a date separator or the unread divider, the marker carries it
and draws it above itself. The list's scroll-to-unread lookups match a
divider nested inside the marker.
The server can't read an end-to-end encrypted DM, so the body it pushes
is a stand-in. applyChatStyle now asks the chat coordinator to open the
pushed message and shows its text instead. A long message arrives as
message_id only; that one is read from Room if stored, else fetched with
GetMessage.

Any failure (plaintext message, bad ciphertext, key or message fetch
failure) returns null and the notification keeps the server's body.
Group chats never encrypt, so they skip the lookup.
ChatMetadataDao.upsert wrote use_e2ee only on first insert;
updateServerOwnedFields left it out. A DM stored before the server set
the flag kept use_e2ee = 0 on every later sync, so sends in it stayed
plaintext and the profile footer never showed.

The column came in with #1573, so code/cash has the same gap.
It matches the date separator above it instead of the white the plan called for.
@bmc08gt bmc08gt self-assigned this Sep 29, 2026
@github-actions github-actions Bot added type: feature New functionality area: network gRPC, connectivity, API, exchange rates area: notifications Push notifications, in-app messaging labels Sep 29, 2026
@bmc08gt
bmc08gt changed the base branch from feat/e2ee-dm-ui to code/cash September 29, 2026 21:24
@bmc08gt
bmc08gt merged commit d4b2f99 into code/cash Sep 29, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: network gRPC, connectivity, API, exchange rates area: notifications Push notifications, in-app messaging type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant