diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 632b037d4b..097ef1c9ff 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -984,6 +984,7 @@ Ask %1$s to send it again Try sending it again + Encrypted Messages are end-to-end encrypted Group chats aren\'t end-to-end encrypted Learn More diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 6d89499dec..28edbb36f2 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -104,6 +104,8 @@ import com.flipcash.shared.chat.reactions.ReactionStripComposer import com.flipcash.shared.chat.reactions.SelfReaction import com.flipcash.shared.chat.readOnly import com.flipcash.shared.chat.resolveCapabilities +import com.flipcash.shared.chat.ui.UndecryptableHint +import com.flipcash.shared.chat.ui.undecryptableHint import com.flipcash.shared.chat.ui.detectMentions import com.flipcash.shared.chat.ui.detectUrls import com.flipcash.shared.chat.ui.linkableText @@ -722,6 +724,13 @@ internal class ChatViewModel @Inject constructor( // cache: an edit or delete awaiting the server re-runs the mapping without re-fetching. .cachedIn(viewModelScope) + /** Where the Encrypted marker goes: read from the transcript, never from `use_e2ee`. */ + @OptIn(ExperimentalCoroutinesApi::class) + private val oldestEncryptedId = stateFlow.mapNotNull { it.chatId } + .distinctUntilChanged() + .flatMapLatest { chatCoordinator.observeOldestEncryptedMessageId(it) } + .distinctUntilChanged() + /** Edits and deletes the server has not answered yet, composed over the stored transcript. */ @OptIn(ExperimentalCoroutinesApi::class) private val pendingMutations = stateFlow.mapNotNull { it.chatId } @@ -952,6 +961,14 @@ internal class ChatViewModel @Inject constructor( reactionPills = storedReactions.pills, selfReactions = storedReactions.selfReactions, canReact = canReact(message), + undecryptableHint = undecryptableHint( + encryption = message.encryption, + isFromSelf = message.isFromSelf, + // Only a DM is encrypted, so the sender of an incoming one is the + // participant. + senderName = stateFlow.value.participant?.name + ?: resources.getString(R.string.title_unnamedUser), + ) ?: UndecryptableHint.UpdateApp, ) // A carded link takes a row of its own, with the prose either side of it // on rows above and below. Reversed because the list is: this page runs @@ -998,7 +1015,10 @@ internal class ChatViewModel @Inject constructor( .filterNot { it is UnreadBoundary.Resolving } .distinctUntilChanged(), stateFlow.map { it.separatorConfig }.distinctUntilChanged(), - ) { paging, boundary, config -> paging.withSeparators(boundary, config) } + oldestEncryptedId, + ) { paging, boundary, config, oldestEncrypted -> + paging.withSeparators(boundary, config, oldestEncrypted) + } /** * The citation shown for [this] message. diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt index 2f8a138dff..ba2f896b07 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/UnreadDivider.kt @@ -35,14 +35,35 @@ internal fun unreadDividerBetween( return olderId <= boundary.readThrough && boundary.readThrough < newer.messageId } -/** The one item that goes in the gap between [newer] and [older] in the newest-first list, if any. */ +/** + * The one item that goes in the gap between [newer] and [older] in the newest-first list, if any. + * + * [oldestEncryptedId] is the oldest stored message that arrived end-to-end encrypted. The gap below + * its last row takes the Encrypted marker, carrying whatever separator the gap would have had. + */ internal fun separatorBetween( newer: ChatListItem.ContentBubble?, older: ChatListItem.ContentBubble?, boundary: UnreadBoundary, config: SeparatorConfig, + oldestEncryptedId: Long? = null, ): ChatListItem? { newer ?: return null + val separator = plainSeparatorBetween(newer, older, boundary, config) + val marksEncryption = newer.messageId == oldestEncryptedId && older?.messageId != newer.messageId + if (!marksEncryption) return separator + // At the head the list draws the oldest date as a trailing header unless the oldest item + // already carries one, and the marker is now the oldest item. + val above = separator ?: if (older == null) ChatListItem.DateSeparator(newer.timestamp) else null + return ChatListItem.EncryptedMarker(above) +} + +private fun plainSeparatorBetween( + newer: ChatListItem.ContentBubble, + older: ChatListItem.ContentBubble?, + boundary: UnreadBoundary, + config: SeparatorConfig, +): ChatListItem? { if (boundary is UnreadBoundary.At && unreadDividerBetween(newer, older, boundary)) { // The oldest stored message has no separator of its own; the list draws its date as a // trailing header. The divider sits there instead, so it carries that date. @@ -58,7 +79,8 @@ internal fun separatorBetween( internal fun PagingData.withSeparators( boundary: UnreadBoundary, config: SeparatorConfig, + oldestEncryptedId: Long? = null, ): PagingData = insertSeparators { newer: ChatListItem.ContentBubble?, older: ChatListItem.ContentBubble? -> - separatorBetween(newer, older, boundary, config) + separatorBetween(newer, older, boundary, config, oldestEncryptedId) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index cf15c2ab88..28a075b425 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -136,6 +136,10 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { } } + ChatAction.OpenEncryptionInfo -> keyboard.hideIfVisible { + navigator.push(AppRoute.Messaging.E2eeDmInfo) + } + ChatAction.AddCash -> { // The gate is the only place in the transcript that offers it. viewModel.dispatchEvent(ChatViewModel.Event.GateFundingTapped(GroupGateFunding.ADD_CASH)) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/EncryptedMarkerRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/EncryptedMarkerRow.kt new file mode 100644 index 0000000000..8ee2776667 --- /dev/null +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/EncryptedMarkerRow.kt @@ -0,0 +1,88 @@ +package com.flipcash.app.messenger.internal.screens.components + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.tooling.preview.PreviewWrapper +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.features.messenger.R +import com.flipcash.shared.chat.models.ChatListItem +import com.getcode.theme.CodeTheme +import kotlin.time.Clock + +/** + * "🔒 Encrypted ›" (node 10416:1404), above the first message that arrived end-to-end encrypted. + * [above] is the date separator or unread divider that shares its gap, drawn first so the reader + * sees the day, then the divider, then the marker and the message. + */ +@Composable +internal fun EncryptedMarkerRow( + above: ChatListItem?, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + when (above) { + is ChatListItem.DateSeparator -> DateSeparatorRow(above.timestamp) + is ChatListItem.UnreadDivider -> UnreadDividerRow(count = above.count, date = above.date) + else -> Unit + } + Row( + modifier = Modifier + .clickable(role = Role.Button, onClick = onClick) + .padding(horizontal = CodeTheme.dimens.inset, vertical = CodeTheme.dimens.grid.x2), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(12.dp), + imageVector = Icons.Filled.Lock, + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + Text( + text = stringResource(R.string.label_e2eeMarker), + style = CodeTheme.typography.textSmall.copy( + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + ), + color = CodeTheme.colors.textSecondary, + ) + Icon( + modifier = Modifier.size(14.dp), + imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = CodeTheme.colors.textSecondary, + ) + } + } +} + +@Preview +@PreviewWrapper(FlipcashThemeWrapper::class) +@Composable +private fun Preview_EncryptedMarker() { + Column { + EncryptedMarkerRow(above = null, onClick = {}) + EncryptedMarkerRow(above = ChatListItem.DateSeparator(Clock.System.now()), onClick = {}) + EncryptedMarkerRow(above = ChatListItem.UnreadDivider(count = 3), onClick = {}) + } +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 7146656712..e9d482de72 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -483,6 +483,7 @@ internal fun MessageList( is ChatListItem.ContentBubble -> oldest.timestamp is ChatListItem.DateSeparator -> null // already a separator is ChatListItem.UnreadDivider -> null // carries the oldest message's date + is ChatListItem.EncryptedMarker -> null // carries it too, as `above` null -> null } if (oldestTimestamp != null) { @@ -738,7 +739,7 @@ private suspend fun LazyPagingItems.walkUntil(budget: Int, find: ( /** The presented index of the unread divider, or `null` while it is still unloaded. */ private fun unreadDividerIndex(messages: LazyPagingItems): Int? = - (0 until messages.itemCount).firstOrNull { messages.peek(it) is ChatListItem.UnreadDivider } + (0 until messages.itemCount).firstOrNull { messages.peek(it)?.holdsUnreadDivider == true } /** * Whether the unread divider is laid out whole below the top bar. A divider under the bar's fade @@ -748,7 +749,7 @@ private fun LazyListLayoutInfo.showsUnreadDivider(messages: LazyPagingItems info.index < messages.itemCount && - messages.peek(info.index) is ChatListItem.UnreadDivider && + messages.peek(info.index)?.holdsUnreadDivider == true && info.offset >= band.first && info.offset + info.size <= band.last } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt index 418fbec089..182b5a842a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageRow.kt @@ -290,6 +290,13 @@ internal fun MessageRow( UnreadDividerRow(count = item.count, date = item.date) } + is ChatListItem.EncryptedMarker -> Box(insertionModifier) { + EncryptedMarkerRow( + above = item.above, + onClick = { onAction(ChatAction.OpenEncryptionInfo) }, + ) + } + is ChatListItem.ContentBubble -> { val effectiveStatus = effectiveReceiptStatus(item, otherReadPointer) // Bound to a local: `sender` is a property of another module's public API, so diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt index 2313708538..5b5448fc3d 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupAnalyticsTest.kt @@ -131,7 +131,7 @@ class ChatGroupAnalyticsTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, - e2eePolicy = E2eePolicy(null), + e2eePolicy = E2eePolicy(), contactCoordinator = contactCoordinator, contactPaymentDelegate = contactPaymentDelegate, tipPaymentDelegate = tipPaymentDelegate, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt index 7172415e06..ddc8d35de2 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatGroupCashLinkTest.kt @@ -135,7 +135,7 @@ class ChatGroupCashLinkTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, - e2eePolicy = E2eePolicy(null), + e2eePolicy = E2eePolicy(), contactCoordinator = contactCoordinator, contactPaymentDelegate = contactPaymentDelegate, tipPaymentDelegate = tipPaymentDelegate, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt index fe4f4aaf49..bf035ec623 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatOpenTranscriptTest.kt @@ -82,7 +82,7 @@ class ChatOpenTranscriptTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, - e2eePolicy = E2eePolicy(null), + e2eePolicy = E2eePolicy(), contactCoordinator = mockk(relaxed = true), contactPaymentDelegate = mockk(relaxed = true), tipPaymentDelegate = tipPaymentDelegate, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt index 4dd9bae0ab..60cd9a9c3b 100644 --- a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt @@ -123,7 +123,7 @@ class ChatSendFailureAnalyticsTest { private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, - e2eePolicy = E2eePolicy(null), + e2eePolicy = E2eePolicy(), contactCoordinator = contactCoordinator, contactPaymentDelegate = contactPaymentDelegate, tipPaymentDelegate = tipPaymentDelegate, diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/EncryptedMarkerPlacementTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/EncryptedMarkerPlacementTest.kt new file mode 100644 index 0000000000..b7474ffdbd --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/EncryptedMarkerPlacementTest.kt @@ -0,0 +1,88 @@ +package com.flipcash.app.messenger.internal + +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.shared.chat.UnreadBoundary +import com.flipcash.shared.chat.models.ChatListItem +import com.flipcash.shared.chat.models.MessagePart +import com.flipcash.shared.chat.models.SeparatorConfig +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.time.Duration.Companion.days +import kotlin.time.Instant + +class EncryptedMarkerPlacementTest { + + private val noon = Instant.fromEpochMilliseconds(1_700_000_000_000) + + private fun row(id: Long, at: Instant = noon, part: MessagePart? = null) = ChatListItem.ContentBubble( + messageId = id, + contentIndex = 0, + content = MessageContent.Text("m$id"), + isFromSelf = false, + timestamp = at, + part = part, + ) + + private fun between( + newer: ChatListItem.ContentBubble, + older: ChatListItem.ContentBubble?, + oldestEncryptedId: Long?, + boundary: UnreadBoundary = UnreadBoundary.None, + ) = separatorBetween(newer, older, boundary, SeparatorConfig.DayOnly, oldestEncryptedId) + + /** Oldest first; the ids the marker sits above. */ + private fun markedAbove(oldestEncryptedId: Long?, vararg oldestFirst: ChatListItem.ContentBubble): List { + val newestFirst = oldestFirst.reversed() + return newestFirst.indices.mapNotNull { i -> + val newer = newestFirst[i] + newer.messageId.takeIf { + between(newer, newestFirst.getOrNull(i + 1), oldestEncryptedId) is ChatListItem.EncryptedMarker + } + } + } + + @Test + fun `the marker sits above the oldest encrypted message, below older plaintext`() = + assertEquals(listOf(3L), markedAbove(3, row(1), row(2), row(3), row(4))) + + @Test + fun `no encrypted message, no marker`() = assertEquals(emptyList(), markedAbove(null, row(1), row(2))) + + @Test + fun `a transcript encrypted from the start has the marker at the head, carrying the date`() { + assertEquals(listOf(1L), markedAbove(1, row(1), row(2))) + val marker = assertIs(between(row(1), older = null, oldestEncryptedId = 1)) + assertEquals(ChatListItem.DateSeparator(noon), marker.above) + } + + @Test + fun `a day change in the same gap is drawn above the marker`() { + val marker = assertIs( + between(row(2, at = noon + 1.days), row(1), oldestEncryptedId = 2), + ) + assertEquals(ChatListItem.DateSeparator(noon + 1.days), marker.above) + } + + @Test + fun `the same day keeps the marker alone`() = + assertNull(assertIs(between(row(2), row(1), oldestEncryptedId = 2)).above) + + @Test + fun `the unread divider in the same gap is drawn above the marker`() { + val marker = assertIs( + between(row(2), row(1), oldestEncryptedId = 2, boundary = UnreadBoundary.At(1, 1)), + ) + assertEquals(ChatListItem.UnreadDivider(1), marker.above) + assertEquals(true, marker.holdsUnreadDivider) + } + + @Test + fun `the marker never splits the rows of one message`() { + val card = row(2, part = MessagePart.Card) + val leading = row(2, part = MessagePart.Leading) + assertNull(between(newer = card, older = leading, oldestEncryptedId = 2)) + assertIs(between(newer = leading, older = row(1), oldestEncryptedId = 2)) + } +} diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt index 3be61318e0..3e5712c414 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatAction.kt @@ -22,6 +22,9 @@ sealed interface ChatAction { */ data object AddCash : ChatAction + /** Opens the DM encryption explainer, from the transcript's Encrypted marker. */ + data object OpenEncryptionInfo : ChatAction + /** * Opens the group a link card names, pushed over this chat so Back returns here. The pushed * screen gates itself; a card never joins or buys on the reader's behalf. diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt index 9ae6456c18..7978ace09c 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/models/ChatListItem.kt @@ -5,6 +5,7 @@ import com.flipcash.shared.chat.MessageCapability import com.flipcash.shared.chat.reactions.ReactionPill import com.flipcash.shared.chat.reactions.SelfReaction import com.flipcash.shared.chat.ui.DetectedMention +import com.flipcash.shared.chat.ui.UndecryptableHint import com.getcode.opencode.model.core.ID import kotlin.time.Instant @@ -31,6 +32,26 @@ sealed interface ChatListItem { override val itemContentType: Any = "unread-divider" } + /** + * "Encrypted", above the oldest message in the transcript that arrived end-to-end encrypted + * (node 10416:1404). Placed from the transcript, not from the chat's flag, so it can't claim + * encryption that isn't happening. + * + * The list takes one item per gap, so [above] is the [DateSeparator] or [UnreadDivider] the + * marker displaced from that gap, drawn above it. At the head of the transcript it is the oldest + * message's date, which the list would otherwise draw as a trailing header. + * + * At most one per transcript, so its key is fixed. + */ + data class EncryptedMarker(val above: ChatListItem? = null) : ChatListItem { + override val itemKey: Any = "encrypted-marker" + override val itemContentType: Any = "encrypted-marker" + } + + /** Whether this is the unread divider, alone or drawn above the Encrypted marker. */ + val holdsUnreadDivider: Boolean + get() = this is UnreadDivider || (this is EncryptedMarker && above is UnreadDivider) + data class ContentBubble( val messageId: Long, val contentIndex: Int, @@ -125,6 +146,11 @@ sealed interface ChatListItem { * pending send are excluded. See `com.flipcash.shared.chat.canReact`. */ val canReact: Boolean = false, + /** + * The line under the bubble when [content] is ciphertext that didn't open. See + * [undecryptableHint][com.flipcash.shared.chat.ui.undecryptableHint]. + */ + val undecryptableHint: UndecryptableHint = UndecryptableHint.UpdateApp, ) : ChatListItem { /** * Who this bubble is attributed to, for grouping. [senderId] is the answer whenever the diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt index f0f53a1bda..9d9c9bdea1 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/MessageBubble.kt @@ -260,12 +260,11 @@ fun ContentBubble( attention = attention, ) - // Not decoded client-side -- see MessageContent.Encrypted. Only the update hint - // is produced until decryption exists, since the one message this client can't - // open is one a newer client can. + // Ciphertext reaches a bubble only when it didn't open; an opened message carries + // its plaintext content instead. is MessageContent.Encrypted -> UndecryptableBubble( modifier = modifier, - hint = UndecryptableHint.UpdateApp, + hint = item.undecryptableHint, ) // TODO diff --git a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UndecryptableBubble.kt b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UndecryptableBubble.kt index 7aa572e2d5..7ca8bd7f6d 100644 --- a/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UndecryptableBubble.kt +++ b/apps/flipcash/shared/chat-ui/src/main/kotlin/com/flipcash/shared/chat/ui/UndecryptableBubble.kt @@ -25,13 +25,13 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.flipcash.app.theme.FlipcashThemeWrapper +import com.flipcash.services.chat.MessageEncryption +import com.flipcash.services.chat.UndecryptableReason import com.getcode.theme.CodeTheme /** - * What the line under an [UndecryptableBubble] tells the reader to do about it. - * - * Only [UpdateApp] is produced today: the one message this client can't open is one a newer client - * could. The other two are for once decryption exists and a failed one is the sender's to fix. + * What the line under an [UndecryptableBubble] tells the reader to do about it. Picked by + * [undecryptableHint] from why the message didn't open. */ sealed interface UndecryptableHint { /** A newer version of the app can read it. */ @@ -44,6 +44,33 @@ sealed interface UndecryptableHint { data object TrySendingAgain : UndecryptableHint } +/** + * The hint for a message that arrived encrypted and didn't open, from [encryption]: + * - an unknown scheme or content type, or no recorded outcome, is one a newer client can read; + * - a failed authentication is the sender's to fix, so the viewer is asked to resend their own + * and to ask [senderName], by first name, to resend theirs. + * + * [senderName] is the whole name; its first word is used. Null for an opened message. + */ +fun undecryptableHint( + encryption: MessageEncryption?, + isFromSelf: Boolean, + senderName: String, +): UndecryptableHint? = when (encryption) { + is MessageEncryption.Decrypted, MessageEncryption.KeyPending -> null + is MessageEncryption.Undecryptable -> when (encryption.reason) { + UndecryptableReason.Unsupported -> UndecryptableHint.UpdateApp + UndecryptableReason.Authentication -> if (isFromSelf) { + UndecryptableHint.TrySendingAgain + } else { + UndecryptableHint.AskToResend(senderName.firstName()) + } + } + null -> UndecryptableHint.UpdateApp +} + +private fun String.firstName(): String = trim().substringBefore(' ') + @Composable private fun UndecryptableHint.text(): String = when (this) { UndecryptableHint.UpdateApp -> stringResource(R.string.hint_messageUndecryptable_update) diff --git a/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/UndecryptableHintTest.kt b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/UndecryptableHintTest.kt new file mode 100644 index 0000000000..34bdcdea9b --- /dev/null +++ b/apps/flipcash/shared/chat-ui/src/test/kotlin/com/flipcash/shared/chat/ui/UndecryptableHintTest.kt @@ -0,0 +1,51 @@ +package com.flipcash.shared.chat.ui + +import com.flipcash.services.chat.MessageEncryption +import com.flipcash.services.chat.UndecryptableReason +import com.flipcash.services.models.chat.MessageContent +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class UndecryptableHintTest { + + private fun hint(encryption: MessageEncryption?, isFromSelf: Boolean = false, senderName: String = "Ada Lovelace") = + undecryptableHint(encryption, isFromSelf, senderName) + + @Test + fun `an unknown scheme or type asks for an update`() { + assertEquals( + UndecryptableHint.UpdateApp, + hint(MessageEncryption.Undecryptable(UndecryptableReason.Unsupported)), + ) + } + + @Test + fun `ciphertext with no recorded outcome asks for an update`() { + assertEquals(UndecryptableHint.UpdateApp, hint(null)) + } + + @Test + fun `a failed authentication from the peer asks them, by first name, to resend`() { + assertEquals( + UndecryptableHint.AskToResend("Ada"), + hint(MessageEncryption.Undecryptable(UndecryptableReason.Authentication)), + ) + } + + @Test + fun `a failed authentication on the viewer's own message asks them to send again`() { + assertEquals( + UndecryptableHint.TrySendingAgain, + hint(MessageEncryption.Undecryptable(UndecryptableReason.Authentication), isFromSelf = true), + ) + } + + @Test + fun `an opened or pending message has no hint`() { + val sealed = MessageContent.Encrypted(scheme = 1, nonce = ByteArray(24), ciphertext = ByteArray(1)) + + assertNull(hint(MessageEncryption.Decrypted(sealed))) + assertNull(hint(MessageEncryption.KeyPending)) + } +} diff --git a/apps/flipcash/shared/chat/build.gradle.kts b/apps/flipcash/shared/chat/build.gradle.kts index afa205cec9..8a52e3379d 100644 --- a/apps/flipcash/shared/chat/build.gradle.kts +++ b/apps/flipcash/shared/chat/build.gradle.kts @@ -33,6 +33,7 @@ dependencies { implementation(project(":apps:flipcash:shared:analytics")) testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) implementation(project(":services:flipcash")) + testImplementation(testFixtures(project(":services:flipcash"))) implementation(project(":libs:network:connectivity:public")) implementation(project(":libs:emojis")) implementation(libs.androidx.lifecycle.process) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt index 298b40bd86..a07750b4ff 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/ChatCoordinator.kt @@ -361,6 +361,12 @@ interface MessagingOperations { /** Observes the other member's read pointer in [chatId] (for read receipts). */ fun observeOtherReadPointer(chatId: ChatId): Flow + /** + * The id of the oldest stored message in [chatId] that arrived end-to-end encrypted, or `null` + * when none did. The transcript's Encrypted marker sits above it. + */ + fun observeOldestEncryptedMessageId(chatId: ChatId): Flow + /** Fetches the full message history for [chatId] from the server and persists locally. */ suspend fun loadMessages(chatId: ChatId) @@ -373,6 +379,16 @@ interface MessagingOperations { */ suspend fun applyPushedMessage(chatId: ChatId, message: ChatMessage) + /** + * The plaintext of an end-to-end encrypted message a push is for, opened on this device, for + * the notification to show in place of the server's body. The push carries [message], or only + * [messageId], in which case the message is read from storage or fetched with `GetMessage`. + * + * `null` when the message isn't encrypted, isn't text, or can't be opened or fetched: the + * notification keeps the server's body. Nothing is stored; the push's sync work does that. + */ + suspend fun openPushedMessage(chatId: ChatId, message: ChatMessage?, messageId: Long?): String? + /** * Sends a text message to [chatId]. Returns the server-confirmed [ChatMessage]. * diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt index e00d8f11f5..89c8682e77 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/inject/ChatModule.kt @@ -2,6 +2,8 @@ package com.flipcash.shared.chat.inject import com.flipcash.shared.chat.ChatCoordinator import com.flipcash.shared.chat.ChatDraftStore +import com.flipcash.shared.chat.internal.DmOutgoingEncryption +import com.flipcash.shared.chat.internal.OutgoingEncryption import com.flipcash.shared.chat.internal.RealChatCoordinator import com.flipcash.shared.chat.internal.RealChatDraftStore import com.getcode.opencode.providers.SessionListener @@ -28,6 +30,11 @@ abstract class ChatModule { impl: RealChatDraftStore ): ChatDraftStore + @Binds + internal abstract fun bindOutgoingEncryption( + impl: DmOutgoingEncryption + ): OutgoingEncryption + @Binds @IntoSet abstract fun bindSessionListener( diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt new file mode 100644 index 0000000000..ad81279141 --- /dev/null +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/OutgoingEncryption.kt @@ -0,0 +1,109 @@ +package com.flipcash.shared.chat.internal + +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.services.chat.ChatContentCrypto +import com.flipcash.services.chat.E2eePolicy +import com.flipcash.services.chat.MessageEncryption +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.user.UserManager +import kotlinx.coroutines.flow.first +import javax.inject.Inject + +/** + * Decides what goes on the wire for a message the viewer sends or edits, and seals it when + * [E2eePolicy] says so. + * + * The local copy of an outgoing message is always its plaintext; only the request carries + * ciphertext. [Outgoing.echo] puts the plaintext back into the server's reply, so the stored row + * never has to be decrypted from the viewer's own send. + */ +interface OutgoingEncryption { + + /** + * What to send in [chatId] for [content]. + * + * [wasEncrypted] is for an edit: an encrypted message is never rewritten in plaintext, even if + * the chat has since stopped encrypting. + * + * Fails when the chat can't be read, since a chat that should be encrypted would otherwise go + * out in plaintext, and when sealing fails. Either way the send is failed and can be retried. + */ + suspend fun prepare( + chatId: ChatId, + content: List, + wasEncrypted: Boolean = false, + ): Result + + /** Sends everything in plaintext. What a delegate built without one -- a unit test -- is given. */ + object None : OutgoingEncryption { + override suspend fun prepare( + chatId: ChatId, + content: List, + wasEncrypted: Boolean, + ): Result = Result.success(Outgoing(plaintext = content, wire = content, isSealed = false)) + } +} + +data class Outgoing( + val plaintext: List, + val wire: List, + val isSealed: Boolean, +) { + + /** The server's copy of this message as it should be stored: with the plaintext it was sent from. */ + fun echo(serverMessage: ChatMessage): ChatMessage = + when (val sealed = wire.singleOrNull()) { + is MessageContent.Encrypted if isSealed -> serverMessage.copy( + content = plaintext, + encryption = MessageEncryption.Decrypted(sealed), + ) + else -> serverMessage + } +} + +internal class DmOutgoingEncryption @Inject constructor( + private val policy: E2eePolicy, + private val crypto: ChatContentCrypto, + private val userManager: UserManager, + private val chatController: ChatController, + private val metadataDataSource: ChatMetadataDataSource, + private val memberDataSource: ChatMemberDataSource, +) : OutgoingEncryption { + + override suspend fun prepare( + chatId: ChatId, + content: List, + wasEncrypted: Boolean, + ): Result { + val chat = chat(chatId) + ?: return Result.failure(IllegalStateException("Can't tell whether $chatId encrypts")) + if (!wasEncrypted && !policy.shouldEncrypt(chat)) { + return Result.success(Outgoing(plaintext = content, wire = content, isSealed = false)) + } + + val selfId = userManager.accountId + ?: return Result.failure(IllegalStateException("No account to encrypt from")) + val peerId = chat.members.firstOrNull { it.userId != selfId }?.userId + ?: return Result.failure(IllegalStateException("No peer in $chatId to encrypt to")) + // A DM message is one Text, or one Reply around Text. Anything else can't be sealed yet, + // and sending it in plaintext would break the chat's promise. + val single = content.singleOrNull() + ?: return Result.failure(IllegalArgumentException("Can't encrypt ${content.size} content items")) + + return crypto.seal(chatId, peerId, single) + .map { sealed -> Outgoing(plaintext = content, wire = listOf(sealed), isSealed = true) } + } + + private suspend fun chat(chatId: ChatId): ChatMetadata? { + metadataDataSource.observeById(chatId).first()?.let { entity -> + val members = memberDataSource.getMembersForChat(chatId) + if (members.isNotEmpty()) return metadataDataSource.toMetadata(entity, members, null) + } + return chatController.getChat(chatId).getOrNull() + } +} diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt index dc929f7392..8462c33412 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/RealChatCoordinator.kt @@ -154,6 +154,10 @@ class RealChatCoordinator @Inject constructor( // answers delays the sync rather than cancelling it. withTimeoutOrNull(FEED_READ_WAIT) { stateHolder.state.first { it.feed != null } } syncFeeds() + // Encrypted messages a previous session couldn't open, and any stored before this version + // could open them at all. A chat's own writes retry it too; this reaches the chats that + // don't get one. + scope.launch { messagingDelegate.openKeyPending() } eventStreamDelegate.open() eventStreamDelegate.startHeartbeat { syncFeeds() } } diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 5dc55342c8..245e1f4739 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -14,6 +14,8 @@ import com.flipcash.app.analytics.analytics import com.flipcash.app.persistence.sources.ChatMemberDataSource import com.flipcash.app.persistence.sources.ChatMessageDataSource import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.app.persistence.sources.IncomingMessageOpener +import com.flipcash.services.chat.MessageEncryption import com.flipcash.app.persistence.sources.mediator.ChatMessageRemoteMediator import com.flipcash.services.controllers.ChatController import com.flipcash.services.controllers.ChatMessagingController @@ -21,6 +23,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.ClientMessageId import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.MuteState @@ -39,6 +42,7 @@ import com.flipcash.shared.chat.MessagingOperations import com.flipcash.shared.chat.PendingMutation import com.flipcash.shared.chat.UnreadBoundary import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.internal.OutgoingEncryption import com.flipcash.shared.chat.replacingText import com.flipcash.services.user.UserManager import com.flipcash.shared.chat.MessageLinkPrefetch @@ -71,6 +75,9 @@ import kotlin.time.Clock * 3. [confirmPending][ChatMessageDataSource.confirmPending] replaces the placeholder, * or [failPending][ChatMessageDataSource.failPending] marks it as failed. * + * In an end-to-end encrypted DM the placeholder is still the plaintext; [OutgoingEncryption] + * seals only what goes on the wire, and its echo stores the reply with that plaintext. + * * @see com.flipcash.shared.chat.internal.RealChatCoordinator */ @Singleton @@ -86,6 +93,9 @@ class MessagingDelegate @Inject constructor( private val analytics: FlipcashAnalytics, private val senderResolver: SenderResolver, private val linkPrefetch: MessageLinkPrefetch = MessageLinkPrefetch.None, + private val outgoing: OutgoingEncryption = OutgoingEncryption.None, + /** Opens encrypted pushes; without it, every push keeps the server's body. */ + private val incoming: IncomingMessageOpener? = null, ) : MessagingOperations { /** @@ -229,6 +239,9 @@ class MessagingDelegate @Inject constructor( override fun requestSenderProfile(userId: ID) = senderResolver.request(userId) + override fun observeOldestEncryptedMessageId(chatId: ChatId): Flow = + messageDataSource.observeOldestEncryptedMessageId(chatId) + override fun observeOtherReadPointer(chatId: ChatId): Flow { val selfId = userManager.accountId return memberDataSource.observeMembers(chatId) @@ -277,6 +290,37 @@ class MessagingDelegate @Inject constructor( } } + override suspend fun openPushedMessage(chatId: ChatId, message: ChatMessage?, messageId: Long?): String? { + val opener = incoming ?: return null + val selfId = userManager.accountId ?: return null + val candidate = message + ?: messageId?.let { id -> + messageDataSource.getMessage(chatId, id) + ?: messagingController.getMessage(chatId, id).getOrNull() + } + ?: return null + + val opened = when (candidate.encryption) { + is MessageEncryption.Decrypted -> candidate + null -> { + if (candidate.content.singleOrNull() !is MessageContent.Encrypted) return null + // Only the viewer's own message needs the other member; anyone else's is theirs. + val peerId = if (candidate.senderId == selfId) getOtherMember(chatId)?.userId else null + opener.open(chatId, selfId, peerId, listOf(candidate), stored = { null }).single() + } + else -> return null + } + if (opened.encryption !is MessageEncryption.Decrypted) return null + return opened.content.singleOrNull()?.pushText() + } + + /** Text and replies with text are what DMs encrypt; anything else keeps the server's body. */ + private fun MessageContent.pushText(): String? = when (this) { + is MessageContent.Text -> text + is MessageContent.Reply -> (content.singleOrNull() as? MessageContent.Text)?.text + else -> null + } + override suspend fun sendMessage( chatId: ChatId, content: String, @@ -301,23 +345,35 @@ class MessagingDelegate @Inject constructor( senderId = senderId, ) - return messagingController.sendMessage(chatId, payload, clientMessageId) - .onSuccess { serverMessage -> - messageDataSource.confirmPending(chatId, clientMessageId, serverMessage) - advanceReadPointer(chatId, serverMessage.messageId) - - metadataDataSource.updateLastMessageId(chatId, serverMessage.messageId) - metadataDataSource.updateLastActivity(chatId, serverMessage.timestamp.toEpochMilliseconds()) - } - .onFailure { - messageDataSource.failPending(chatId, clientMessageId) - } + return send(chatId, payload, clientMessageId) } override suspend fun retryMessage(chatId: ChatId, pendingClientIdHex: String, content: List): Result { val clientMessageId = messageDataSource.retryPending(chatId, pendingClientIdHex) - return messagingController.sendMessage(chatId, content, clientMessageId) + // Sealed afresh: the pending row holds plaintext, and the chat may have started or stopped + // encrypting since the first attempt. + return send(chatId, content, clientMessageId) + } + + /** + * Seals [payload] if the chat encrypts, sends it, and settles the pending row. A failure to + * seal fails the row like a failed request, so it can be retried; so does the server refusing + * the ciphertext with `EncryptionNotAllowed`. + */ + private suspend fun send( + chatId: ChatId, + payload: List, + clientMessageId: ClientMessageId, + ): Result { + val prepared = outgoing.prepare(chatId, payload).getOrElse { cause -> + trace(tag = TAG, message = "Couldn't prepare send in $chatId", type = TraceType.Error, error = cause) + messageDataSource.failPending(chatId, clientMessageId) + return Result.failure(cause) + } + + return messagingController.sendMessage(chatId, prepared.wire, clientMessageId) + .map(prepared::echo) .onSuccess { serverMessage -> messageDataSource.confirmPending(chatId, clientMessageId, serverMessage) advanceReadPointer(chatId, serverMessage.messageId) @@ -356,7 +412,14 @@ class MessagingDelegate @Inject constructor( ), ) - return messagingController.editMessage(chatId, messageId, content, expectedSequence) + val prepared = outgoing.prepare(chatId, content, wasEncrypted = stored.encryption != null) + .getOrElse { cause -> + clearMutation(chatId, messageId) + return Result.failure(cause) + } + + return messagingController.editMessage(chatId, messageId, prepared.wire, expectedSequence) + .map(prepared::echo) .reconcile(chatId, messageId) { it is EditMessageError.Conflict } } @@ -558,6 +621,11 @@ class MessagingDelegate @Inject constructor( messageDataSource.failInterruptedSends() } + /** Opens the encrypted messages still waiting on a key, in every chat. */ + internal suspend fun openKeyPending() { + messageDataSource.reopenAllKeyPending() + } + internal suspend fun clear() { pendingMutations.value = emptyMap() metadataDataSource.clear() diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt new file mode 100644 index 0000000000..e5e7c45b8f --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingPushDecryptionTest.kt @@ -0,0 +1,145 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.IncomingMessageOpener +import com.flipcash.services.chat.ChatContentCrypto +import com.flipcash.services.chat.ChatKeySource +import com.flipcash.services.chat.FakeChatCipher +import com.flipcash.services.chat.MessageEncryption +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.internal.delegates.MessagingDelegate +import com.getcode.ed25519kmp.KeyPair +import com.getcode.opencode.model.core.ID +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.IOException +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.time.Instant + +/** What a DM push shows: the opened plaintext, or `null` so the server's body stays. */ +class MessagingPushDecryptionTest { + + private val chatId = ChatId(ByteArray(32) { 7 }) + private val self: ID = List(16) { 1 } + private val peer: ID = List(16) { 2 } + private val selfKeys = KeyPair(publicKey = ByteArray(32) { 11 }, privateKey = ByteArray(64) { 12 }) + private val peerKeys = KeyPair(publicKey = ByteArray(32) { 21 }, privateKey = ByteArray(64) { 22 }) + + private class Keys(private val own: KeyPair, private val peerPk: ByteArray) : ChatKeySource { + var peerFails = false + override fun ownKeyPair() = own + override suspend fun peerPublicKey(userId: ID): Result = + if (peerFails) Result.failure(IOException("offline")) else Result.success(peerPk) + } + + private val selfSide = Keys(selfKeys, peerKeys.publicKey) + private val theirs = ChatContentCrypto(FakeChatCipher, Keys(peerKeys, selfKeys.publicKey)) + private val messageDataSource = mockk(relaxed = true) + private val messagingController = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) { + every { accountId } returns self + } + + private val delegate = MessagingDelegate( + chatController = mockk(relaxed = true), + messagingController = messagingController, + metadataDataSource = mockk(relaxed = true), + messageDataSource = messageDataSource, + memberDataSource = mockk(relaxed = true), + notificationManager = mockk(relaxed = true), + userManager = userManager, + stateHolder = mockk(relaxed = true), + analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), + incoming = IncomingMessageOpener(ChatContentCrypto(FakeChatCipher, selfSide)), + ) + + private suspend fun sealedFromPeer(content: MessageContent) = + theirs.seal(chatId, peerId = self, content = content).getOrThrow() + + private fun message(content: MessageContent, id: Long = 5) = ChatMessage( + messageId = id, + senderId = peer, + content = listOf(content), + timestamp = Instant.fromEpochSeconds(1_000), + unreadSeq = 0, + ) + + @Test + fun `an inlined encrypted message shows its plaintext`() = runTest { + val pushed = message(sealedFromPeer(MessageContent.Text("see you at 8"))) + + assertEquals("see you at 8", delegate.openPushedMessage(chatId, pushed, messageId = null)) + } + + @Test + fun `a reply shows the text it carries`() = runTest { + val reply = MessageContent.Reply(repliedMessageId = 2, content = listOf(MessageContent.Text("yes"))) + val pushed = message(sealedFromPeer(reply)) + + assertEquals("yes", delegate.openPushedMessage(chatId, pushed, messageId = null)) + } + + @Test + fun `an id-only push fetches the message when it isn't stored`() = runTest { + val fetched = message(sealedFromPeer(MessageContent.Text("long one")), id = 9) + coEvery { messageDataSource.getMessage(chatId, 9) } returns null + coEvery { messagingController.getMessage(chatId, 9, any()) } returns Result.success(fetched) + + assertEquals("long one", delegate.openPushedMessage(chatId, message = null, messageId = 9)) + } + + @Test + fun `an id-only push reads an already-opened stored copy without fetching`() = runTest { + val sealed = sealedFromPeer(MessageContent.Text("stored")) + val stored = message(MessageContent.Text("stored"), id = 9) + .copy(encryption = MessageEncryption.Decrypted(sealed)) + coEvery { messageDataSource.getMessage(chatId, 9) } returns stored + + assertEquals("stored", delegate.openPushedMessage(chatId, message = null, messageId = 9)) + coVerify(exactly = 0) { messagingController.getMessage(any(), any(), any()) } + } + + @Test + fun `a failed fetch keeps the server's body`() = runTest { + coEvery { messageDataSource.getMessage(chatId, 9) } returns null + coEvery { messagingController.getMessage(chatId, 9, any()) } returns Result.failure(IOException("offline")) + + assertNull(delegate.openPushedMessage(chatId, message = null, messageId = 9)) + } + + @Test + fun `a plaintext message keeps the server's body`() = runTest { + assertNull(delegate.openPushedMessage(chatId, message(MessageContent.Text("hi")), messageId = null)) + } + + @Test + fun `a message that fails to open keeps the server's body`() = runTest { + val sealed = sealedFromPeer(MessageContent.Text("hi")) + val tampered = sealed.copy(ciphertext = sealed.ciphertext.copyOf().also { it[0] = (it[0] + 1).toByte() }) + + assertNull(delegate.openPushedMessage(chatId, message(tampered), messageId = null)) + } + + @Test + fun `a key fetch failure keeps the server's body`() = runTest { + val pushed = message(sealedFromPeer(MessageContent.Text("hi"))) + selfSide.peerFails = true + + assertNull(delegate.openPushedMessage(chatId, pushed, messageId = null)) + } + + @Test + fun `nothing to open without a message or its id`() = runTest { + assertNull(delegate.openPushedMessage(chatId, message = null, messageId = null)) + } +} diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt new file mode 100644 index 0000000000..f8435de1d9 --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/MessagingSendEncryptionTest.kt @@ -0,0 +1,326 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.persistence.entities.ChatMetadataEntity +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.app.persistence.sources.PendingMessage +import com.flipcash.services.chat.ChatContentCrypto +import com.flipcash.services.chat.ChatKeySource +import com.flipcash.services.chat.E2eePolicy +import com.flipcash.services.chat.FakeChatCipher +import com.flipcash.services.chat.MessageEncryption +import com.flipcash.services.chat.OpenedContent +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.models.GetChatError +import com.flipcash.services.models.SendMessageError +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMember +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.ClientMessageId +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.internal.DmOutgoingEncryption +import com.flipcash.shared.chat.internal.delegates.MessagingDelegate +import com.getcode.chatcipher.ChatEncryptionPolicy +import com.getcode.ed25519kmp.KeyPair +import com.getcode.opencode.model.core.ID +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.IOException +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.time.Instant + +/** + * The send side of end-to-end encrypted DMs: what goes on the wire, what is stored, and what + * happens when sealing can't be done. + */ +class MessagingSendEncryptionTest { + + private val chatId = ChatId(ByteArray(32) { 3 }) + private val selfId: ID = List(16) { 1 } + private val peerId: ID = List(16) { 2 } + private val flipcashId: ID = ChatEncryptionPolicy.FLIPCASH_USER_ID.toList() + private val clientMessageId = ClientMessageId(ByteArray(16) { 9 }) + + private val selfKeys = KeyPair(publicKey = ByteArray(32) { 11 }, privateKey = ByteArray(64) { 12 }) + private val peerKeys = KeyPair(publicKey = ByteArray(32) { 21 }, privateKey = ByteArray(64) { 22 }) + + private class Keys(private val own: KeyPair, private val peerPk: ByteArray) : ChatKeySource { + var peerFails: Throwable? = null + override fun ownKeyPair() = own + override suspend fun peerPublicKey(userId: ID) = + peerFails?.let { Result.failure(it) } ?: Result.success(peerPk) + } + + private val keys = Keys(selfKeys, peerKeys.publicKey) + private val peerCrypto = ChatContentCrypto(FakeChatCipher, Keys(peerKeys, selfKeys.publicKey)) + + private val controller = mockk(relaxed = true) + private val chatController = mockk(relaxed = true) + private val messages = mockk(relaxed = true) + private val metadata = mockk(relaxed = true) + private val members = mockk(relaxed = true) + private val userManager = mockk(relaxed = true).also { + every { it.accountId } returns selfId + } + + private fun chat(type: ChatType = ChatType.CONTACT_DM, useE2ee: Boolean = true, peer: ID = peerId) = + ChatMetadata( + chatId = chatId, + type = type, + members = listOf(member(selfId), member(peer)), + lastMessage = null, + lastActivity = Instant.fromEpochSeconds(0), + useE2ee = useE2ee, + ) + + private fun member(id: ID) = ChatMember(userId = id, userProfile = UserProfile.Empty, pointers = emptyList()) + + private fun storedChat(chat: ChatMetadata?) { + val entity = chat?.let { mockk() } + every { metadata.observeById(chatId) } returns flowOf(entity) + coEvery { members.getMembersForChat(chatId) } returns chat?.members.orEmpty() + if (chat != null) every { metadata.toMetadata(any(), any(), any()) } returns chat + } + + private fun serverCopy(content: List, messageId: Long = 10) = ChatMessage( + messageId = messageId, + senderId = selfId, + content = content, + timestamp = Instant.fromEpochSeconds(2_000), + unreadSeq = 0, + eventSequence = 5, + isFromSelf = true, + ) + + private val delegate by lazy { + MessagingDelegate( + chatController = chatController, + messagingController = controller, + metadataDataSource = metadata, + messageDataSource = messages, + memberDataSource = members, + notificationManager = mockk(relaxed = true), + userManager = userManager, + stateHolder = mockk(relaxed = true), + analytics = mockk(relaxed = true), + senderResolver = mockk(relaxed = true), + outgoing = DmOutgoingEncryption( + policy = E2eePolicy(), + crypto = ChatContentCrypto(FakeChatCipher, keys), + userManager = userManager, + chatController = chatController, + metadataDataSource = metadata, + memberDataSource = members, + ), + ) + } + + /** Captures what the send put on the wire and answers with the server's copy of it. */ + private fun answerSends(): MutableList> { + val wires = mutableListOf>() + coEvery { messages.insertPending(chatId, any(), selfId) } answers { + PendingMessage(serverCopy(secondArg()), clientMessageId) + } + coEvery { messages.retryPending(chatId, any()) } returns clientMessageId + coEvery { controller.sendMessage(chatId, any(), any()) } answers { + wires += secondArg>() + Result.success(serverCopy(secondArg())) + } + return wires + } + + private suspend fun openAsPeer(wire: List): MessageContent { + val sealed = assertIs(wire.single()) + val opened = peerCrypto.open(chatId, selfId = peerId, peerId = selfId, senderId = selfId, encrypted = sealed) + return assertIs(opened).content + } + + @Test + fun `text in an encrypted DM goes out sealed and is stored as the plaintext`() = runTest { + storedChat(chat()) + val wires = answerSends() + + delegate.sendMessage(chatId, "hello", replyToMessageId = null).getOrThrow() + + assertEquals(MessageContent.Text("hello"), openAsPeer(wires.single())) + coVerify { messages.insertPending(chatId, listOf(MessageContent.Text("hello")), selfId) } + val stored = slot() + coVerify { messages.confirmPending(chatId, clientMessageId, capture(stored)) } + assertEquals(listOf(MessageContent.Text("hello")), stored.captured.content) + assertEquals(MessageEncryption.Decrypted(wires.single().single() as MessageContent.Encrypted), stored.captured.encryption) + } + + @Test + fun `a reply is sealed whole, quote id and all`() = runTest { + storedChat(chat()) + val wires = answerSends() + + delegate.sendMessage(chatId, "yes", replyToMessageId = 4).getOrThrow() + + assertEquals( + MessageContent.Reply(repliedMessageId = 4, content = listOf(MessageContent.Text("yes"))), + openAsPeer(wires.single()), + ) + } + + @Test + fun `a DM without the flag goes out in plaintext`() = runTest { + storedChat(chat(useE2ee = false)) + val wires = answerSends() + + delegate.sendMessage(chatId, "hello", replyToMessageId = null).getOrThrow() + + assertEquals(listOf(MessageContent.Text("hello")), wires.single()) + val stored = slot() + coVerify { messages.confirmPending(chatId, clientMessageId, capture(stored)) } + assertEquals(null, stored.captured.encryption) + } + + @Test + fun `the flipcash chat never encrypts`() = runTest { + storedChat(chat(peer = flipcashId)) + val wires = answerSends() + + delegate.sendMessage(chatId, "hello", replyToMessageId = null).getOrThrow() + + assertEquals(listOf(MessageContent.Text("hello")), wires.single()) + } + + @Test + fun `a group never encrypts`() = runTest { + storedChat(chat(type = ChatType.GROUP)) + val wires = answerSends() + + delegate.sendMessage(chatId, "hello", replyToMessageId = null).getOrThrow() + + assertEquals(listOf(MessageContent.Text("hello")), wires.single()) + } + + @Test + fun `a chat that isn't stored is read from the server`() = runTest { + storedChat(null) + coEvery { chatController.getChat(chatId, any()) } returns Result.success(chat()) + val wires = answerSends() + + delegate.sendMessage(chatId, "hello", replyToMessageId = null).getOrThrow() + + assertEquals(MessageContent.Text("hello"), openAsPeer(wires.single())) + } + + @Test + fun `a chat that can't be read fails the send instead of sending plaintext`() = runTest { + storedChat(null) + coEvery { chatController.getChat(chatId, any()) } returns Result.failure(GetChatError.Other()) + answerSends() + + val result = delegate.sendMessage(chatId, "hello", replyToMessageId = null) + + assertEquals(true, result.isFailure) + coVerify(exactly = 0) { controller.sendMessage(any(), any(), any()) } + coVerify { messages.failPending(chatId, clientMessageId) } + } + + @Test + fun `a failed key fetch fails the send so it can be retried`() = runTest { + storedChat(chat()) + keys.peerFails = IOException("offline") + answerSends() + + val result = delegate.sendMessage(chatId, "hello", replyToMessageId = null) + + assertIs(result.exceptionOrNull()) + coVerify(exactly = 0) { controller.sendMessage(any(), any(), any()) } + coVerify { messages.failPending(chatId, clientMessageId) } + } + + @Test + fun `EncryptionNotAllowed fails the send so it can be retried`() = runTest { + storedChat(chat()) + answerSends() + coEvery { controller.sendMessage(chatId, any(), any()) } returns + Result.failure(SendMessageError.EncryptionNotAllowed()) + + val result = delegate.sendMessage(chatId, "hello", replyToMessageId = null) + + assertIs(result.exceptionOrNull()) + coVerify { messages.failPending(chatId, clientMessageId) } + } + + @Test + fun `a retry seals the stored plaintext again`() = runTest { + storedChat(chat()) + val wires = answerSends() + + delegate.retryMessage(chatId, "09", listOf(MessageContent.Text("again"))).getOrThrow() + + assertEquals(MessageContent.Text("again"), openAsPeer(wires.single())) + val stored = slot() + coVerify { messages.confirmPending(chatId, clientMessageId, capture(stored)) } + assertEquals(listOf(MessageContent.Text("again")), stored.captured.content) + } + + private fun answerEdits(original: ChatMessage): MutableList> { + val wires = mutableListOf>() + coEvery { messages.getMessage(chatId, original.messageId) } returns original + coEvery { controller.editMessage(chatId, original.messageId, any(), original.eventSequence) } answers { + wires += thirdArg>() + Result.success(original.copy(content = thirdArg(), eventSequence = original.eventSequence + 1)) + } + return wires + } + + @Test + fun `an encrypted message stays encrypted when edited after the chat stops encrypting`() = runTest { + storedChat(chat(useE2ee = false)) + val original = serverCopy(listOf(MessageContent.Text("before"))) + .copy(encryption = MessageEncryption.Decrypted(MessageContent.Encrypted(1, ByteArray(24), ByteArray(8)))) + val wires = answerEdits(original) + + delegate.editMessage(chatId, original.messageId, "after").getOrThrow() + + assertEquals(MessageContent.Text("after"), openAsPeer(wires.single())) + val stored = slot>() + coVerify { messages.upsert(chatId, capture(stored)) } + assertEquals(listOf(MessageContent.Text("after")), stored.captured.single().content) + assertEquals(MessageEncryption.Decrypted(wires.single().single() as MessageContent.Encrypted), stored.captured.single().encryption) + } + + @Test + fun `a plaintext message is encrypted when edited in a chat that now encrypts`() = runTest { + storedChat(chat()) + val original = serverCopy(listOf(MessageContent.Text("before"))) + val wires = answerEdits(original) + + delegate.editMessage(chatId, original.messageId, "after").getOrThrow() + + assertEquals(MessageContent.Text("after"), openAsPeer(wires.single())) + } + + @Test + fun `an edit that can't be sealed is not sent and its overlay comes down`() = runTest { + storedChat(chat()) + keys.peerFails = IOException("offline") + val original = serverCopy(listOf(MessageContent.Text("before"))) + answerEdits(original) + + val result = delegate.editMessage(chatId, original.messageId, "after") + + assertIs(result.exceptionOrNull()) + coVerify(exactly = 0) { controller.editMessage(any(), any(), any(), any()) } + assertEquals(emptyMap(), delegate.observePendingMutations(chatId).first()) + } +} diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt index a090fc7ba3..6ec1153d2a 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt @@ -53,6 +53,7 @@ import com.getcode.utils.trace import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -404,7 +405,10 @@ class NotificationService : FirebaseMessagingService(), ?.let { NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(it) } ?: NotificationCompat.MessagingStyle(selfPerson) - style.addMessage(planMessageBody(body, senderNames), System.currentTimeMillis(), senderPerson) + // The server can't read an end-to-end encrypted DM, so its body is a stand-in. The opened + // text is the message itself, with no sender prefix to strip. + val opened = if (!styling.isGroupConversation) openPushedMessage(chatId, metadata) else null + style.addMessage(opened ?: planMessageBody(body, senderNames), System.currentTimeMillis(), senderPerson) // After the extract above, not before: a re-post rebuilds the style from the notification // already on screen, which carries the old flag and title back with it. @@ -421,6 +425,19 @@ class NotificationService : FirebaseMessagingService(), return notificationId } + /** The pushed DM message's plaintext, or `null` to keep the server's body on any failure. */ + private suspend fun openPushedMessage(chatId: ChatId, metadata: PushChatMetadata?): String? { + if (metadata == null || (metadata.message == null && metadata.messageId == null)) return null + return try { + chatCoordinator.openPushedMessage(chatId, metadata.message, metadata.messageId) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + trace(tag = "NotificationService", message = "Couldn't open a pushed message", error = e) + null + } + } + /** Who a chat push is attributed to: their id (for blob access) and their profile. */ private data class Sender(val userId: ID, val profile: UserProfile) diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/40.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/40.json new file mode 100644 index 0000000000..fef8564878 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/40.json @@ -0,0 +1,936 @@ +{ + "formatVersion": 1, + "database": { + "version": 40, + "identityHash": "23409d5095e4f1a6a1b02274376202ca", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, `title` TEXT, `picture_json` TEXT, `member_count` INTEGER NOT NULL DEFAULT 0, `roster_version` INTEGER NOT NULL DEFAULT 0, `rules_json` TEXT, `is_member` INTEGER NOT NULL DEFAULT 1, `mute_until_epoch_ms` INTEGER, `mute_forever` INTEGER NOT NULL DEFAULT 0, `viewer_state_version` INTEGER NOT NULL DEFAULT 0, `can_edit` INTEGER NOT NULL DEFAULT 0, `creator_hex` TEXT, `use_e2ee` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT" + }, + { + "fieldPath": "pictureJson", + "columnName": "picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "memberCount", + "columnName": "member_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rosterVersion", + "columnName": "roster_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rulesJson", + "columnName": "rules_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isMember", + "columnName": "is_member", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "muteUntilEpochMs", + "columnName": "mute_until_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "muteForever", + "columnName": "mute_forever", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "viewerStateVersion", + "columnName": "viewer_state_version", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "canEdit", + "columnName": "can_edit", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "creatorHex", + "columnName": "creator_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "useE2ee", + "columnName": "use_e2ee", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, `is_deleted` INTEGER NOT NULL DEFAULT 0, `ciphertext_json` TEXT, `encryption_state` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + }, + { + "fieldPath": "isDeleted", + "columnName": "is_deleted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "ciphertextJson", + "columnName": "ciphertext_json", + "affinity": "TEXT" + }, + { + "fieldPath": "encryptionState", + "columnName": "encryption_state", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + }, + "indices": [ + { + "name": "index_chat_messages_chat_id_hex_timestamp_epoch_ms", + "unique": false, + "columnNames": [ + "chat_id_hex", + "timestamp_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_messages_chat_id_hex_timestamp_epoch_ms` ON `${TABLE_NAME}` (`chat_id_hex`, `timestamp_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "chat_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `text` TEXT NOT NULL, `reply_target_json` TEXT, `saved_at` INTEGER NOT NULL, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "replyTargetJson", + "columnName": "reply_target_json", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `username` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "username", + "columnName": "username", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "link_previews", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `json` TEXT NOT NULL, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`key`))", + "fields": [ + { + "fieldPath": "key", + "columnName": "key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "json", + "columnName": "json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "updatedAt", + "columnName": "updated_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "key" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '23409d5095e4f1a6a1b02274376202ca')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index 8919fd0399..dc60110d2b 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -110,8 +110,9 @@ import com.getcode.utils.subByteArray AutoMigration(from = 36, to = 37, spec = FlipcashDatabase.Migration36To37::class), AutoMigration(from = 37, to = 38), // chat_metadata.creator_hex (nullable), use_e2ee (default 0) AutoMigration(from = 38, to = 39), // link_previews table + AutoMigration(from = 39, to = 40, spec = FlipcashDatabase.Migration39To40::class), ], - version = 39, + version = 40, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { @@ -278,6 +279,23 @@ abstract class FlipcashDatabase : RoomDatabase() { } } + /** + * Adds `chat_messages.ciphertext_json` and `encryption_state`. A row stored before this version + * with encrypted content was never opened, so it's marked for opening with its ciphertext + * copied across, the same as a row whose key fetch failed. + */ + class Migration39To40 : AutoMigrationSpec { + override fun onPostMigrate(connection: SQLiteConnection) { + connection.execSQL(MARK_ENCRYPTED_FOR_OPENING) + } + + companion object { + const val MARK_ENCRYPTED_FOR_OPENING = + "UPDATE chat_messages SET encryption_state = 'KEY_PENDING', ciphertext_json = content_json " + + "WHERE content_json LIKE '[{\"type\":\"encrypted\"%'" + } + } + companion object { /** diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt index 49ef43d42c..9d30d931a2 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt @@ -8,13 +8,14 @@ import androidx.room.Query import androidx.room.Transaction import com.flipcash.app.persistence.converters.mergeReactionsJson import com.flipcash.app.persistence.entities.ChatMessageEntity +import com.flipcash.app.persistence.entities.EncryptionState import com.flipcash.app.persistence.entities.MessageStatus import kotlinx.coroutines.flow.Flow @Dao interface ChatMessageDao { - @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex ORDER BY timestamp_epoch_ms ASC, message_id ASC") + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND (encryption_state IS NULL OR encryption_state != 'KEY_PENDING') ORDER BY timestamp_epoch_ms ASC, message_id ASC") fun observeMessages(chatIdHex: String): Flow> /** @@ -22,7 +23,7 @@ interface ChatMessageDao { * sent in a burst, or stamped from one server clock read, share a millisecond. A paged read * re-queries per page, so an unstable order there duplicates or skips a row across the seam. */ - @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex ORDER BY timestamp_epoch_ms DESC, message_id DESC") + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND (encryption_state IS NULL OR encryption_state != 'KEY_PENDING') ORDER BY timestamp_epoch_ms DESC, message_id DESC") fun observeMessagesPaged(chatIdHex: String): PagingSource /** @@ -44,7 +45,7 @@ interface ChatMessageDao { * the newest id including tombstones, or a delete would regress the read pointer and leave the * chat unread forever. */ - @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND is_deleted = 0 ORDER BY timestamp_epoch_ms DESC, message_id DESC LIMIT 1") + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND is_deleted = 0 AND (encryption_state IS NULL OR encryption_state != 'KEY_PENDING') ORDER BY timestamp_epoch_ms DESC, message_id DESC LIMIT 1") suspend fun getLatestVisible(chatIdHex: String): ChatMessageEntity? /** @@ -58,6 +59,7 @@ interface ChatMessageDao { "SELECT * FROM chat_messages WHERE rowid IN (" + "SELECT (SELECT m.rowid FROM chat_messages m " + "WHERE m.chat_id_hex = c.chat_id_hex AND m.is_deleted = 0 " + + "AND (m.encryption_state IS NULL OR m.encryption_state != 'KEY_PENDING') " + "ORDER BY m.timestamp_epoch_ms DESC, m.message_id DESC LIMIT 1) " + "FROM (SELECT DISTINCT chat_id_hex FROM chat_messages) c)" ) @@ -129,6 +131,28 @@ interface ChatMessageDao { @Query("SELECT COUNT(*) FROM chat_messages WHERE chat_id_hex = :chatIdHex AND timestamp_epoch_ms > :timestampEpochMs") suspend fun countNewerThan(chatIdHex: String, timestampEpochMs: Long): Int + /** Rows in [chatIdHex] still waiting on a key to be opened; see [EncryptionState.KEY_PENDING]. */ + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND encryption_state = 'KEY_PENDING'") + suspend fun getKeyPending(chatIdHex: String): List + + @Query("SELECT EXISTS(SELECT 1 FROM chat_messages WHERE chat_id_hex = :chatIdHex AND encryption_state = 'KEY_PENDING')") + suspend fun hasKeyPending(chatIdHex: String): Boolean + + @Query("SELECT DISTINCT chat_id_hex FROM chat_messages WHERE encryption_state = 'KEY_PENDING'") + suspend fun chatsWithKeyPending(): List + + /** + * The oldest message in [chatIdHex] that arrived end-to-end encrypted and is shown, opened or + * not. The transcript's Encrypted marker sits above it. Ordered as the transcript is. + */ + @Query( + "SELECT message_id FROM chat_messages " + + "WHERE chat_id_hex = :chatIdHex AND encryption_state IS NOT NULL " + + "AND encryption_state != 'KEY_PENDING' " + + "ORDER BY timestamp_epoch_ms ASC, message_id ASC LIMIT 1" + ) + fun observeOldestEncrypted(chatIdHex: String): Flow + @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(entity: ChatMessageEntity) @@ -292,6 +316,18 @@ interface ChatMessageDao { newUnreadSeq = serverMessage.unreadSeq, newEventSequence = serverMessage.eventSequence, ) + // An encrypted send went out as ciphertext; the row keeps its plaintext and gains the + // ciphertext beside it. Written as a row rather than in the UPDATE above, since Room would + // expand a list parameter into an IN clause. + if (serverMessage.encryptionState != null) { + val confirmed = getByClientId(chatIdHex, clientIdHex) ?: return + insert( + confirmed.copy( + ciphertextJson = serverMessage.ciphertextJson, + encryptionState = serverMessage.encryptionState, + ) + ) + } } @Transaction diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt index 7b096cb346..8518a1e520 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt @@ -86,7 +86,8 @@ interface ChatMetadataDao { "title = :title, " + "picture_json = :pictureJson, " + "rules_json = :rulesJson, " + - "is_member = :isMember " + + "is_member = :isMember, " + + "use_e2ee = :useE2ee " + "WHERE chat_id_hex = :chatIdHex" ) suspend fun updateServerOwnedFields( @@ -99,6 +100,7 @@ interface ChatMetadataDao { pictureJson: MediaItem?, rulesJson: ChatRulesSerialized?, isMember: Boolean, + useE2ee: Boolean, ) /** @@ -198,6 +200,7 @@ interface ChatMetadataDao { pictureJson = entity.pictureJson, rulesJson = entity.rulesJson, isMember = entity.isMember, + useE2ee = entity.useE2ee, ) updateRosterIfNewer( chatIdHex = entity.chatIdHex, diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt index df9c27e0a0..eca8b8f4be 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMessageEntity.kt @@ -11,6 +11,24 @@ enum class MessageStatus { FAILED, } +/** How an end-to-end encrypted row stands on this device. Null on a row sent in plaintext. */ +enum class EncryptionState { + /** [ChatMessageEntity.contentJson] is the plaintext. */ + DECRYPTED, + + /** + * A key fetch failed, so the row hasn't been opened yet. Hidden from the transcript and the + * chat list until it is. + */ + KEY_PENDING, + + /** An unknown scheme, or a plaintext type this client doesn't render. */ + UNSUPPORTED, + + /** The ciphertext failed authentication. */ + AUTH_FAILED, +} + /** * The transcript reads this table one page at a time, ordered newest-first within a chat, and the * composite primary key `(chat_id_hex, message_id)` does not serve that order. Without the index @@ -47,4 +65,11 @@ data class ChatMessageEntity( * someone sends a message whose text happens to contain it. */ @ColumnInfo(name = "is_deleted", defaultValue = "0") val isDeleted: Boolean = false, + /** + * The message as it arrived, when it arrived end-to-end encrypted: one serialized + * `Encrypted`. Kept beside the plaintext in [contentJson] so a later copy of the same message + * is recognised without opening it again. + */ + @ColumnInfo(name = "ciphertext_json") val ciphertextJson: List? = null, + @ColumnInfo(name = "encryption_state") val encryptionState: EncryptionState? = null, ) diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/EncryptedRowMigrationTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/EncryptedRowMigrationTest.kt new file mode 100644 index 0000000000..e4a68646b4 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/EncryptedRowMigrationTest.kt @@ -0,0 +1,34 @@ +package com.flipcash.app.persistence + +import com.flipcash.app.persistence.converters.ChatTypeConverters +import com.flipcash.app.persistence.converters.MessageContentSerialized +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * [FlipcashDatabase.Migration39To40] finds the encrypted rows to mark by a `LIKE` on the stored + * JSON, so it depends on how the converter writes an encrypted message. + */ +class EncryptedRowMigrationTest { + + private val converters = ChatTypeConverters() + private val prefix = FlipcashDatabase.Migration39To40.MARK_ENCRYPTED_FOR_OPENING + .substringAfter("LIKE '").substringBefore("%'") + + @Test + fun `an encrypted message matches the migration's pattern`() { + val json = converters.toMessageContentList( + listOf(MessageContentSerialized.Encrypted(scheme = 1, nonce = "AA", ciphertext = "BB")) + )!! + + assertTrue(json.startsWith(prefix), json) + } + + @Test + fun `a text message doesn't`() { + val json = converters.toMessageContentList(listOf(MessageContentSerialized.Text("encrypted")))!! + + assertFalse(json.startsWith(prefix), json) + } +} diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt index a207be8692..f95a4471fd 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMessageDaoTest.kt @@ -9,7 +9,9 @@ import com.flipcash.app.persistence.converters.EmojiReactionSerialized import com.flipcash.app.persistence.converters.MessageContentSerialized import com.flipcash.app.persistence.converters.ReactionSummarySerialized import com.flipcash.app.persistence.entities.ChatMessageEntity +import com.flipcash.app.persistence.entities.EncryptionState import com.flipcash.app.persistence.entities.MessageStatus +import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import kotlinx.serialization.json.Json import org.junit.After @@ -512,6 +514,70 @@ class ChatMessageDaoTest { } /** SQLite's running count of rows written on this connection, triggers included. */ + // region end-to-end encryption + + private val cipherJson = listOf(MessageContentSerialized.Encrypted(scheme = 1, nonce = "AA", ciphertext = "BB")) + + private fun encrypted(messageId: Long, state: EncryptionState) = ChatMessageEntity( + chatIdHex = CHAT_HEX, + messageId = messageId, + senderIdHex = SENDER_HEX, + contentJson = if (state == EncryptionState.DECRYPTED) listOf(MessageContentSerialized.Text("hi")) else cipherJson, + timestampEpochMs = messageId * 1_000, + unreadSeq = messageId, + ciphertextJson = cipherJson, + encryptionState = state, + ) + + @Test + fun `a message waiting on its key is hidden from the preview`() = runTest { + dao.upsert(listOf(text(1, "plain"), encrypted(2, EncryptionState.KEY_PENDING))) + + assertEquals(1L, dao.getLatestVisible(CHAT_HEX)?.messageId) + assertEquals(listOf(1L), dao.getLatestVisibleForAllChats().map { it.messageId }) + } + + @Test + fun `waiting messages are listed for reopening`() = runTest { + dao.upsert(listOf(text(1, "plain"), encrypted(2, EncryptionState.KEY_PENDING))) + + assertEquals(true, dao.hasKeyPending(CHAT_HEX)) + assertEquals(listOf(2L), dao.getKeyPending(CHAT_HEX).map { it.messageId }) + assertEquals(listOf(CHAT_HEX), dao.chatsWithKeyPending()) + } + + @Test + fun `the oldest encrypted message skips plaintext and waiting rows`() = runTest { + dao.upsert( + listOf( + text(1, "plain"), + encrypted(2, EncryptionState.KEY_PENDING), + encrypted(3, EncryptionState.AUTH_FAILED), + encrypted(4, EncryptionState.DECRYPTED), + ) + ) + + assertEquals(3L, dao.observeOldestEncrypted(CHAT_HEX).first()) + } + + @Test + fun `confirming an encrypted send keeps its plaintext and gains the ciphertext`() = runTest { + dao.upsert(pending("hello")) + + dao.confirmPendingMessage( + CHAT_HEX, + CLIENT_HEX, + text(7, "hello").copy(ciphertextJson = cipherJson, encryptionState = EncryptionState.DECRYPTED), + ) + + val stored = dao.getMessage(CHAT_HEX, 7)!! + assertEquals(listOf(MessageContentSerialized.Text("hello")), stored.contentJson) + assertEquals(cipherJson, stored.ciphertextJson) + assertEquals(EncryptionState.DECRYPTED, stored.encryptionState) + } + + // endregion + private fun totalChanges(): Long = db.openHelper.writableDatabase.query("SELECT total_changes()").use { cursor -> cursor.moveToFirst() diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt index a47da549e3..efd55a45d3 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDaoTest.kt @@ -62,6 +62,7 @@ class ChatMetadataDaoTest { muteForever: Boolean = false, viewerStateVersion: Long = 0, canEdit: Boolean = false, + useE2ee: Boolean = false, ) = ChatMetadataEntity( chatIdHex = chatIdHex, chatType = chatType, @@ -80,6 +81,7 @@ class ChatMetadataDaoTest { muteForever = muteForever, viewerStateVersion = viewerStateVersion, canEdit = canEdit, + useE2ee = useE2ee, ) @Test @@ -172,6 +174,24 @@ class ChatMetadataDaoTest { assertEquals(false, stored?.isMember) } + @Test + fun `upsert picks up use_e2ee turning on for a stored chat`() = runTest { + dao.upsert(entity(useE2ee = false)) + + dao.upsert(entity(useE2ee = true)) + + assertEquals(true, dao.getById(CHAT_HEX)?.useE2ee) + } + + @Test + fun `upsert picks up use_e2ee turning off for a stored chat`() = runTest { + dao.upsert(entity(useE2ee = true)) + + dao.upsert(entity(useE2ee = false)) + + assertEquals(false, dao.getById(CHAT_HEX)?.useE2ee) + } + /** * `MetadataUpdate.TitleChanged` carries no version, unlike the roster and viewer-state * overlays above, so there is nothing to gate the write on. diff --git a/apps/flipcash/shared/persistence/sources/build.gradle.kts b/apps/flipcash/shared/persistence/sources/build.gradle.kts index 7f09ed6ea6..744be06cbf 100644 --- a/apps/flipcash/shared/persistence/sources/build.gradle.kts +++ b/apps/flipcash/shared/persistence/sources/build.gradle.kts @@ -16,6 +16,7 @@ android { dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) + testImplementation(testFixtures(project(":services:flipcash"))) implementation(libs.bundles.kotlinx.serialization) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt index 29483e2bab..a1453d65ae 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt @@ -6,6 +6,7 @@ import com.flipcash.app.persistence.FlipcashDatabase import com.flipcash.app.persistence.dao.ChatMessageDao import com.flipcash.app.persistence.entities.ChatMessageEntity import com.flipcash.app.persistence.sources.mapper.chat.ChatEntityMapper +import com.flipcash.services.chat.MessageEncryption import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatMetadata @@ -39,6 +40,7 @@ data class PendingMessage( class ChatMessageDataSource @Inject constructor( private val mapper: ChatEntityMapper, private val userManager: UserManager, + private val opener: IncomingMessageOpener, ) : PagingDataSource, Int, ChatMessageEntity> { private val db: FlipcashDatabase? @@ -196,6 +198,70 @@ class ChatMessageDataSource @Inject constructor( suspend fun upsert(chatId: ChatId, messages: List) { val hex = mapper.chatIdHex(chatId) + val opened = openEncrypted(chatId, hex, messages) + write(hex, opened) + // A write that got a key is the next chance to open what an earlier one couldn't. + if (opened.none { it.encryption == MessageEncryption.KeyPending }) { + reopenKeyPending(chatId) + } + } + + /** + * Opens the messages in [chatId] stored [MessageEncryption.KeyPending]: those whose key fetch + * failed, or that arrived before the other member of the DM was stored. Cheap when there are + * none. Called on each write to the chat, when the chat is opened, and on a push for it. + */ + suspend fun reopenKeyPending(chatId: ChatId) { + val dao = db?.chatMessageDao() ?: return + val hex = mapper.chatIdHex(chatId) + if (!dao.hasKeyPending(hex)) return + val selfId = userManager.accountId + val peerId = peerOf(hex, selfId) + val reopened = dao.getKeyPending(hex).map { entity -> + opener.reopen(chatId, selfId, peerId, mapper.toMessage(entity)) + } + if (reopened.all { it.encryption == MessageEncryption.KeyPending }) return + dao.upsert(reopened.map { mapper.toEntity(hex, it) }) + } + + /** [reopenKeyPending] for every chat that has a message waiting. */ + suspend fun reopenAllKeyPending() { + val dao = db?.chatMessageDao() ?: return + for (hex in dao.chatsWithKeyPending()) reopenKeyPending(mapper.chatIdFromHex(hex)) + } + + /** + * The id of the oldest end-to-end encrypted message in [chatId] the transcript shows, opened + * or not; the Encrypted marker sits above it. + */ + fun observeOldestEncryptedMessageId(chatId: ChatId): Flow = + db?.chatMessageDao()?.observeOldestEncrypted(mapper.chatIdHex(chatId)) ?: flowOf(null) + + private suspend fun openEncrypted(chatId: ChatId, hex: String, messages: List): List { + if (messages.none { it.encryption == null && it.content.singleOrNull() is MessageContent.Encrypted }) { + return messages + } + val dao = db?.chatMessageDao() ?: return messages + val selfId = userManager.accountId + return opener.open( + chatId = chatId, + selfId = selfId, + peerId = peerOf(hex, selfId), + messages = messages, + stored = { messageId -> dao.getMessage(hex, messageId)?.let(mapper::toMessage) }, + ) + } + + /** The DM member who isn't the viewer, when the chat's members are stored. */ + private suspend fun peerOf(chatIdHex: String, selfId: ID?): ID? { + val selfHex = selfId?.hexEncodedString() ?: return null + return db?.chatMemberDao()?.getMembersForChat(chatIdHex) + ?.map { it.member.userIdHex } + ?.singleOrNull { it != selfHex } + ?.let(mapper::userIdFromHex) + } + + private suspend fun write(hex: String, messages: List) { val entities = messages.map { mapper.toEntity(hex, it) } val selfId = userManager.accountId val selfHex = selfId?.hexEncodedString() @@ -229,8 +295,15 @@ class ChatMessageDataSource @Inject constructor( suspend fun upsertAll(messagesByChat: Map>) { val database = db ?: return if (messagesByChat.isEmpty()) return + // Opened before the transaction: opening can fetch a key over the network. + val opened = messagesByChat.mapValues { (chatId, messages) -> + openEncrypted(chatId, mapper.chatIdHex(chatId), messages) + } database.withTransaction { - for ((chatId, messages) in messagesByChat) upsert(chatId, messages) + for ((chatId, messages) in opened) write(mapper.chatIdHex(chatId), messages) + } + for ((chatId, messages) in opened) { + if (messages.none { it.encryption == MessageEncryption.KeyPending }) reopenKeyPending(chatId) } } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpener.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpener.kt new file mode 100644 index 0000000000..da18daaefc --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpener.kt @@ -0,0 +1,86 @@ +package com.flipcash.app.persistence.sources + +import com.flipcash.services.chat.ChatContentCrypto +import com.flipcash.services.chat.MessageEncryption +import com.flipcash.services.chat.OpenedContent +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.getcode.opencode.model.core.ID +import javax.inject.Inject + +/** + * Opens the end-to-end encrypted messages in a write before they're stored, so the plaintext is + * stored beside the ciphertext and everything reading the table (transcript, quotes, previews) + * sees plaintext. + */ +class IncomingMessageOpener @Inject constructor( + private val crypto: ChatContentCrypto, +) { + /** + * [messages] with each encrypted one opened and its [ChatMessage.encryption] set. A message + * already stored opened with the same ciphertext takes the stored plaintext instead of being + * opened again. + * + * [peerId] is the other member of the DM when it's stored; a message from the viewer can't be + * opened without it, and is left [MessageEncryption.KeyPending] to be opened once it is. + */ + suspend fun open( + chatId: ChatId, + selfId: ID?, + peerId: ID?, + messages: List, + stored: suspend (messageId: Long) -> ChatMessage?, + ): List = messages.map { message -> + val sealed = message.sealedContent() ?: return@map message + + val storedCopy = stored(message.messageId) + val storedEncryption = storedCopy?.encryption + if (storedEncryption is MessageEncryption.Decrypted && storedEncryption.sealed == sealed) { + return@map message.copy(content = storedCopy.content, encryption = storedEncryption) + } + + open(chatId, selfId, peerId, message, sealed) + } + + /** Opens [message], which is stored [MessageEncryption.KeyPending], or leaves it that way. */ + suspend fun reopen(chatId: ChatId, selfId: ID?, peerId: ID?, message: ChatMessage): ChatMessage { + val sealed = message.content.singleOrNull() as? MessageContent.Encrypted ?: return message + return open(chatId, selfId, peerId, message, sealed) + } + + private suspend fun open( + chatId: ChatId, + selfId: ID?, + peerId: ID?, + message: ChatMessage, + sealed: MessageContent.Encrypted, + ): ChatMessage { + val peer = when (val sender = message.senderId) { + null -> null + selfId -> peerId + else -> sender + } + if (selfId == null || peer == null) { + return message.copy(content = listOf(sealed), encryption = MessageEncryption.KeyPending) + } + + return when (val opened = crypto.open(chatId, selfId, peer, message.senderId, sealed)) { + is OpenedContent.Plaintext -> message.copy( + content = listOf(opened.content), + encryption = MessageEncryption.Decrypted(sealed), + ) + OpenedContent.KeyPending -> message.copy(encryption = MessageEncryption.KeyPending) + is OpenedContent.Undecryptable -> message.copy( + encryption = MessageEncryption.Undecryptable(opened.reason), + ) + } + } + + /** + * The ciphertext of a message as the server sent it. A message that already carries an + * [ChatMessage.encryption] was opened on this device (a confirmed send) and is left alone. + */ + private fun ChatMessage.sealedContent(): MessageContent.Encrypted? = + if (encryption != null) null else content.singleOrNull() as? MessageContent.Encrypted +} diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt index 9a312260f8..2fe4560a1f 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapper.kt @@ -13,10 +13,13 @@ import com.flipcash.app.persistence.entities.ChatMemberEntity import com.flipcash.app.persistence.entities.ChatMemberWithProfile import com.flipcash.app.persistence.entities.ChatMessageEntity import com.flipcash.app.persistence.entities.ChatMetadataEntity +import com.flipcash.app.persistence.entities.EncryptionState import com.flipcash.app.persistence.entities.MessageStatus import com.flipcash.app.persistence.entities.UserProfileEntity import com.flipcash.app.persistence.entities.toSerialized import com.flipcash.app.persistence.sources.mapper.toDomain +import com.flipcash.services.chat.MessageEncryption +import com.flipcash.services.chat.UndecryptableReason import com.flipcash.services.models.SocialAccount import com.flipcash.services.models.UserProfile import com.flipcash.services.models.chat.ChatId @@ -179,6 +182,8 @@ class ChatEntityMapper @Inject constructor() { lastEditedTsEpochMs = message.lastEditedTs?.toEpochMilliseconds(), reactionsJson = encodeReactions(message.reactions), isDeleted = message.content.any { it is MessageContent.Deleted }, + ciphertextJson = message.ciphertext()?.let { listOf(it.toSerialized()) }, + encryptionState = message.encryption?.toState(), ) } @@ -198,6 +203,7 @@ class ChatEntityMapper @Inject constructor() { MessageStatus.FAILED -> DeliveryStatus.FAILED }, pendingClientIdHex = entity.pendingClientIdHex, + encryption = entity.toEncryption(), ) } @@ -320,6 +326,8 @@ class ChatEntityMapper @Inject constructor() { fun userIdHex(userId: ID): String = userId.hexEncodedString() + fun userIdFromHex(hex: String): ID = hex.hexToId() + private fun String.hexToByteArray(): ByteArray { val len = length val data = ByteArray(len / 2) @@ -333,6 +341,35 @@ class ChatEntityMapper @Inject constructor() { private fun String.hexToId(): List = hexToByteArray().toList() + /** The ciphertext the message arrived as; its content is the ciphertext unless it was opened. */ + private fun ChatMessage.ciphertext(): MessageContent.Encrypted? = when (val encryption = encryption) { + null -> null + is MessageEncryption.Decrypted -> encryption.sealed + else -> content.singleOrNull() as? MessageContent.Encrypted + } + + private fun MessageEncryption.toState(): EncryptionState = when (this) { + is MessageEncryption.Decrypted -> EncryptionState.DECRYPTED + MessageEncryption.KeyPending -> EncryptionState.KEY_PENDING + is MessageEncryption.Undecryptable -> when (reason) { + UndecryptableReason.Unsupported -> EncryptionState.UNSUPPORTED + UndecryptableReason.Authentication -> EncryptionState.AUTH_FAILED + } + } + + private fun ChatMessageEntity.toEncryption(): MessageEncryption? = when (encryptionState) { + null -> null + EncryptionState.DECRYPTED -> { + val sealed = ciphertextJson?.singleOrNull()?.toDomain() as? MessageContent.Encrypted + // A decrypted row without its ciphertext can't have come from this client; read it as + // plaintext rather than drop it. + sealed?.let(MessageEncryption::Decrypted) + } + EncryptionState.KEY_PENDING -> MessageEncryption.KeyPending + EncryptionState.UNSUPPORTED -> MessageEncryption.Undecryptable(UndecryptableReason.Unsupported) + EncryptionState.AUTH_FAILED -> MessageEncryption.Undecryptable(UndecryptableReason.Authentication) + } + // endregion } diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpenerTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpenerTest.kt new file mode 100644 index 0000000000..34c53b06c8 --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/IncomingMessageOpenerTest.kt @@ -0,0 +1,155 @@ +package com.flipcash.app.persistence.sources + +import com.flipcash.services.chat.ChatContentCrypto +import com.flipcash.services.chat.ChatKeySource +import com.flipcash.services.chat.FakeChatCipher +import com.flipcash.services.chat.MessageEncryption +import com.flipcash.services.chat.UndecryptableReason +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.MessageContent +import com.getcode.ed25519kmp.KeyPair +import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.IOException +import kotlin.test.assertEquals +import kotlin.time.Instant + +class IncomingMessageOpenerTest { + + private val chatId = ChatId(ByteArray(32) { 7 }) + private val self: ID = List(16) { 1 } + private val peer: ID = List(16) { 2 } + private val selfKeys = KeyPair(publicKey = ByteArray(32) { 11 }, privateKey = ByteArray(64) { 12 }) + private val peerKeys = KeyPair(publicKey = ByteArray(32) { 21 }, privateKey = ByteArray(64) { 22 }) + + private class Keys(private val own: KeyPair, private val peerPk: ByteArray) : ChatKeySource { + var peerFails = false + var fetches = 0 + override fun ownKeyPair() = own + override suspend fun peerPublicKey(userId: ID): Result { + fetches++ + return if (peerFails) Result.failure(IOException("offline")) else Result.success(peerPk) + } + } + + private val selfSide = Keys(selfKeys, peerKeys.publicKey) + private val opener = IncomingMessageOpener(ChatContentCrypto(FakeChatCipher, selfSide)) + private val theirs = ChatContentCrypto(FakeChatCipher, Keys(peerKeys, selfKeys.publicKey)) + private val mine = ChatContentCrypto(FakeChatCipher, Keys(selfKeys, peerKeys.publicKey)) + + private suspend fun fromPeer(text: String) = + theirs.seal(chatId, peerId = self, content = MessageContent.Text(text)).getOrThrow() + + private fun message(content: MessageContent, senderId: ID? = peer, id: Long = 1) = ChatMessage( + messageId = id, + senderId = senderId, + content = listOf(content), + timestamp = Instant.fromEpochSeconds(1_000), + unreadSeq = 0, + isFromSelf = senderId == self, + ) + + private suspend fun open(vararg messages: ChatMessage, peerId: ID? = peer, stored: ChatMessage? = null) = + opener.open(chatId, selfId = self, peerId = peerId, messages = messages.toList(), stored = { stored }) + + @Test + fun `a message from the peer is stored as its plaintext beside the ciphertext`() = runTest { + val sealed = fromPeer("hi") + + val opened = open(message(sealed)).single() + + assertEquals(listOf(MessageContent.Text("hi")), opened.content) + assertEquals(MessageEncryption.Decrypted(sealed), opened.encryption) + } + + @Test + fun `the viewer's own message opens against the stored peer`() = runTest { + val sealed = mine.seal(chatId, peerId = peer, content = MessageContent.Text("mine")).getOrThrow() + + val opened = open(message(sealed, senderId = self)).single() + + assertEquals(listOf(MessageContent.Text("mine")), opened.content) + } + + @Test + fun `the viewer's own message waits for the peer when members aren't stored`() = runTest { + val sealed = mine.seal(chatId, peerId = peer, content = MessageContent.Text("mine")).getOrThrow() + + val opened = open(message(sealed, senderId = self), peerId = null).single() + + assertEquals(MessageEncryption.KeyPending, opened.encryption) + assertEquals(listOf(sealed), opened.content) + } + + @Test + fun `a failed key fetch leaves the message pending rather than undecryptable`() = runTest { + val sealed = fromPeer("hi") + selfSide.peerFails = true + + val opened = open(message(sealed)).single() + + assertEquals(MessageEncryption.KeyPending, opened.encryption) + assertEquals(listOf(sealed), opened.content) + } + + @Test + fun `tampered ciphertext fails authentication`() = runTest { + val sealed = fromPeer("hi") + val tampered = sealed.copy(ciphertext = sealed.ciphertext.copyOf().also { it[0] = (it[0] + 1).toByte() }) + + val opened = open(message(tampered)).single() + + assertEquals(MessageEncryption.Undecryptable(UndecryptableReason.Authentication), opened.encryption) + } + + @Test + fun `an unknown scheme is unsupported`() = runTest { + val opened = open(message(fromPeer("hi").copy(scheme = 2))).single() + + assertEquals(MessageEncryption.Undecryptable(UndecryptableReason.Unsupported), opened.encryption) + } + + @Test + fun `a stored copy with the same ciphertext is reused without opening`() = runTest { + val sealed = fromPeer("hi") + val stored = message(MessageContent.Text("hi")).copy(encryption = MessageEncryption.Decrypted(sealed)) + + val opened = open(message(sealed), stored = stored).single() + + assertEquals(stored.content, opened.content) + assertEquals(0, selfSide.fetches) + } + + @Test + fun `an edit arrives as new ciphertext and is opened again`() = runTest { + val original = fromPeer("hi") + val edited = fromPeer("hello") + val stored = message(MessageContent.Text("hi")).copy(encryption = MessageEncryption.Decrypted(original)) + + val opened = open(message(edited), stored = stored).single() + + assertEquals(listOf(MessageContent.Text("hello")), opened.content) + } + + @Test + fun `plaintext and confirmed sends pass through`() = runTest { + val plain = message(MessageContent.Text("plain")) + val confirmed = message(MessageContent.Text("sent"), senderId = self) + .copy(encryption = MessageEncryption.Decrypted(fromPeer("x"))) + + assertEquals(listOf(plain, confirmed), open(plain, confirmed)) + } + + @Test + fun `a pending message opens once the key is back`() = runTest { + val sealed = fromPeer("hi") + val pending = message(sealed).copy(encryption = MessageEncryption.KeyPending) + + val reopened = opener.reopen(chatId, self, peer, pending) + + assertEquals(listOf(MessageContent.Text("hi")), reopened.content) + assertEquals(MessageEncryption.Decrypted(sealed), reopened.encryption) + } +} diff --git a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt index cc729d3024..6b854ac127 100644 --- a/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt +++ b/apps/flipcash/shared/persistence/sources/src/test/kotlin/com/flipcash/app/persistence/sources/mapper/chat/ChatEntityMapperTest.kt @@ -18,7 +18,11 @@ import com.flipcash.services.models.chat.RosterSummary import com.flipcash.services.models.chat.ViewerState import com.getcode.opencode.model.financial.CurrencyCode import com.getcode.opencode.model.financial.Fiat +import com.flipcash.app.persistence.entities.EncryptionState +import com.flipcash.services.chat.MessageEncryption +import com.flipcash.services.chat.UndecryptableReason import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Test import kotlin.time.Instant @@ -428,6 +432,55 @@ class ChatEntityMapperTest { // endregion + // region encrypted messages + + private val sealed = MessageContent.Encrypted(scheme = 1, nonce = ByteArray(24) { 3 }, ciphertext = byteArrayOf(9, 8, 7)) + + private fun encryptedMessage(content: MessageContent, encryption: MessageEncryption?) = ChatMessage( + messageId = 5, + senderId = List(16) { 2 }, + content = listOf(content), + timestamp = Instant.fromEpochSeconds(1_000), + unreadSeq = 0, + encryption = encryption, + ) + + @Test + fun `an opened message stores its plaintext with the ciphertext beside it`() { + val message = encryptedMessage(MessageContent.Text("hi"), MessageEncryption.Decrypted(sealed)) + + val entity = mapper.toEntity(CHAT_HEX, message) + + assertEquals(EncryptionState.DECRYPTED, entity.encryptionState) + assertEquals(message, mapper.toMessage(entity)) + } + + @Test + fun `each unopened state round-trips with its ciphertext as content`() { + listOf( + MessageEncryption.KeyPending, + MessageEncryption.Undecryptable(UndecryptableReason.Unsupported), + MessageEncryption.Undecryptable(UndecryptableReason.Authentication), + ).forEach { encryption -> + val message = encryptedMessage(sealed, encryption) + + val entity = mapper.toEntity(CHAT_HEX, message) + + assertEquals(1, entity.ciphertextJson?.size) + assertEquals(message, mapper.toMessage(entity)) + } + } + + @Test + fun `a plaintext message has no encryption columns`() { + val entity = mapper.toEntity(CHAT_HEX, encryptedMessage(MessageContent.Text("plain"), null)) + + assertNull(entity.ciphertextJson) + assertNull(entity.encryptionState) + } + + // endregion + private companion object { const val CHAT_HEX = "aabbccdd" } diff --git a/services/flipcash/build.gradle.kts b/services/flipcash/build.gradle.kts index e8fc17062d..87d42fe9bd 100644 --- a/services/flipcash/build.gradle.kts +++ b/services/flipcash/build.gradle.kts @@ -21,6 +21,10 @@ android { buildFeatures { buildConfig = true } + + testFixtures { + enable = true + } } dependencies { @@ -28,6 +32,9 @@ dependencies { api(project(":libs:network:jwt")) api(project(":services:opencode")) implementation(project(":ui:resources")) + // `api`: ChatContentCrypto and ChatKeySource name ChatCipher and its KeyPair. + api(project(":libs:encryption:chat-cipher")) + testFixturesImplementation(project(":libs:encryption:chat-cipher")) implementation(libs.javax.inject) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatContentCrypto.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatContentCrypto.kt new file mode 100644 index 0000000000..3131ebb144 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatContentCrypto.kt @@ -0,0 +1,158 @@ +package com.flipcash.services.chat + +import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel +import com.flipcash.services.internal.network.extensions.asContent +import com.flipcash.services.internal.network.extensions.toMessageContent +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MessageContent +import com.getcode.chatcipher.ChatCipher +import com.getcode.chatcipher.ChatCipherException +import com.getcode.chatcipher.EncryptedPayload +import com.getcode.opencode.model.core.ID +import com.google.protobuf.InvalidProtocolBufferException +import java.util.concurrent.ConcurrentHashMap +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Seals and opens DM content as `messaging.v1.EncryptedContent`. + * + * Whether to seal is not decided here; that is [E2eePolicy]. This only knows how, and keeps each + * chat's derived key so a transcript costs one key fetch rather than one per message. + */ +@Singleton +class ChatContentCrypto @Inject constructor( + private val cipher: ChatCipher, + private val keys: ChatKeySource, +) { + private class ChatKeys(val ownPk: ByteArray, val peerPk: ByteArray, val chatKey: ByteArray) + + // The own public key is part of the key so a different account on this device never reuses + // another account's chat keys. + private data class CacheKey(val chatId: ID, val ownPk: ID, val peerId: ID) + + private val chatKeys = ConcurrentHashMap() + + /** + * Encrypts [content] from the viewer to [peerId]. Only Text, and a Reply whose body is Text, + * are sealed; media is not sent encrypted yet. + */ + suspend fun seal(chatId: ChatId, peerId: ID, content: MessageContent): Result { + if (!content.isSealable()) { + return Result.failure(IllegalArgumentException("Cannot encrypt ${content::class.simpleName}")) + } + val chatKeys = chatKeys(chatId, peerId).getOrElse { return Result.failure(it) } + return runCatching { + val payload = cipher.encrypt( + content = content.asContent().toByteArray(), + chatKey = chatKeys.chatKey, + senderPk = chatKeys.ownPk, + recipientPk = chatKeys.peerPk, + chatId = chatId.bytes, + ) + MessageContent.Encrypted( + scheme = SCHEME_X25519_XCHACHA20POLY1305, + nonce = payload.nonce, + ciphertext = payload.ciphertext, + ) + } + } + + /** + * Decrypts [encrypted], a message [senderId] sent in the DM between [selfId] and [peerId]. + */ + suspend fun open( + chatId: ChatId, + selfId: ID, + peerId: ID, + senderId: ID?, + encrypted: MessageContent.Encrypted, + ): OpenedContent { + if (encrypted.scheme != SCHEME_X25519_XCHACHA20POLY1305) { + return OpenedContent.Undecryptable(UndecryptableReason.Unsupported) + } + + val chatKeys = chatKeys(chatId, peerId).getOrElse { cause -> + // A peer key the cipher rejects will be rejected again; anything else is a failed + // fetch, which a later attempt can get past. + return if (cause is ChatCipherException) { + OpenedContent.Undecryptable(UndecryptableReason.Authentication) + } else { + OpenedContent.KeyPending + } + } + + val (senderPk, recipientPk) = when (senderId) { + selfId -> chatKeys.ownPk to chatKeys.peerPk + peerId -> chatKeys.peerPk to chatKeys.ownPk + else -> return OpenedContent.Undecryptable(UndecryptableReason.Authentication) + } + + val plaintext = try { + cipher.decrypt( + payload = EncryptedPayload(nonce = encrypted.nonce, ciphertext = encrypted.ciphertext), + chatKey = chatKeys.chatKey, + senderPk = senderPk, + recipientPk = recipientPk, + chatId = chatId.bytes, + ) + } catch (_: ChatCipherException) { + return OpenedContent.Undecryptable(UndecryptableReason.Authentication) + } + + val content = try { + MessagingModel.Content.parseFrom(plaintext) + } catch (_: InvalidProtocolBufferException) { + return OpenedContent.Undecryptable(UndecryptableReason.Unsupported) + } + + return if (content.isRenderable()) { + OpenedContent.Plaintext(content.toMessageContent()) + } else { + OpenedContent.Undecryptable(UndecryptableReason.Unsupported) + } + } + + fun clear() { + chatKeys.clear() + } + + private suspend fun chatKeys(chatId: ChatId, peerId: ID): Result { + val own = keys.ownKeyPair() + ?: return Result.failure(IllegalStateException("No account key pair")) + val cacheKey = CacheKey(chatId.bytes.toList(), own.publicKey.toList(), peerId) + chatKeys[cacheKey]?.let { return Result.success(it) } + + val peerPk = keys.peerPublicKey(peerId).getOrElse { return Result.failure(it) } + return runCatching { + ChatKeys( + ownPk = own.publicKey, + peerPk = peerPk, + chatKey = cipher.chatKey(own, peerPk, chatId.bytes), + ) + }.onSuccess { chatKeys[cacheKey] = it } + } + + companion object { + /** `EncryptedContent.Scheme.X25519_XCHACHA20POLY1305`. */ + const val SCHEME_X25519_XCHACHA20POLY1305 = 1 + } +} + +private fun MessageContent.isSealable(): Boolean = when (this) { + is MessageContent.Text -> true + is MessageContent.Reply -> content.isNotEmpty() && content.all { it is MessageContent.Text } + else -> false +} + +/** + * The spec allows Text, Media, and a Reply of either. Media isn't rendered from an encrypted + * message yet, so it takes the same "update" path as a type this client has never heard of. + */ +private fun MessagingModel.Content.isRenderable(): Boolean = when (typeCase) { + MessagingModel.Content.TypeCase.TEXT -> true + MessagingModel.Content.TypeCase.REPLY -> + reply.contentCount > 0 && + reply.contentList.all { it.typeCase == MessagingModel.Content.TypeCase.TEXT } + else -> false +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatKeySource.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatKeySource.kt new file mode 100644 index 0000000000..86bb6749b6 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/ChatKeySource.kt @@ -0,0 +1,36 @@ +package com.flipcash.services.chat + +import com.flipcash.services.controllers.ResolverController +import com.flipcash.services.user.UserManager +import com.getcode.ed25519kmp.KeyPair +import com.getcode.opencode.model.core.ID +import javax.inject.Inject + +/** The Ed25519 keys a DM is encrypted between. */ +interface ChatKeySource { + /** The viewer's account key pair, or null when no account is loaded. */ + fun ownKeyPair(): KeyPair? + + /** The Ed25519 public key [userId] registered their account with. */ + suspend fun peerPublicKey(userId: ID): Result +} + +/** + * Reads the peer's key from the Resolver: resolving a user id returns the owner key the account + * registered with, the same key a tip to that user is sent to. + */ +internal class DefaultChatKeySource @Inject constructor( + private val userManager: UserManager, + private val resolver: ResolverController, +) : ChatKeySource { + + override fun ownKeyPair(): KeyPair? = + userManager.accountCluster?.authority?.keyPair?.let { keyPair -> + // The orlp private key is the SHA-512 expansion of the seed, which is what the + // cipher's X25519 conversion reads. + KeyPair(publicKey = keyPair.publicKeyBytes, privateKey = keyPair.privateKeyBytes) + } + + override suspend fun peerPublicKey(userId: ID): Result = + resolver.resolve(userId).map { it.byteArray } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/E2eePolicy.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/E2eePolicy.kt index 44c60e3bad..6ed1f8f9af 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/E2eePolicy.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/E2eePolicy.kt @@ -2,37 +2,31 @@ package com.flipcash.services.chat import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType -import com.getcode.opencode.model.core.ID +import com.getcode.chatcipher.ChatEncryptionPolicy import javax.inject.Inject -/** - * The @flipcash account's user id. Chats with it stay plaintext, because the backend sends its - * onboarding messages in the clear and reads the user's replies. - * - * Null until the id is known, which makes the exemption a no-op: every DM with the flag on is - * treated as encrypted. - */ -// TODO: set @flipcash user id -val FLIPCASH_ACCOUNT_ID: ID? = null - /** * Whether new content in a chat should be end-to-end encrypted. The client decides; the server's * `use_e2ee` flag is only an input to that decision while E2EE is rolling out. * - * This is the only reader of [ChatMetadata.useE2ee]. Screens follow the policy or the transcript, - * so the flag can be dropped later by changing [shouldEncrypt] alone. - * - * @param flipcashAccountId the @flipcash account to exempt; injectable for tests. + * This is the only reader of [ChatMetadata.useE2ee] in the app. It maps a chat onto + * [ChatEncryptionPolicy], which both apps share, so the two platforms can't disagree on when a DM + * is encrypted. The @flipcash exemption lives there. */ -class E2eePolicy(private val flipcashAccountId: ID?) { - - @Inject - constructor() : this(FLIPCASH_ACCOUNT_ID) +class E2eePolicy @Inject constructor() { fun shouldEncrypt(chat: ChatMetadata): Boolean { + // CONTACT_DM and TIP_DM, matching the server's IsDmChatType. val isDm = chat.type == ChatType.CONTACT_DM || chat.type == ChatType.TIP_DM - if (!isDm || !chat.useE2ee) return false - val official = flipcashAccountId ?: return true - return chat.members.none { it.userId == official } + // Every member is checked rather than picking out the peer, so the answer doesn't depend + // on knowing which entry is the viewer. The viewer is never @flipcash. + val members = chat.members.map { it.userId.toByteArray() }.ifEmpty { listOf(ByteArray(0)) } + return members.all { userId -> + ChatEncryptionPolicy.shouldEncrypt( + isDirectMessage = isDm, + useE2ee = chat.useE2ee, + peerUserId = userId, + ) + } } } diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/chat/MessageEncryption.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/MessageEncryption.kt new file mode 100644 index 0000000000..05aac9aa96 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/chat/MessageEncryption.kt @@ -0,0 +1,44 @@ +package com.flipcash.services.chat + +import com.flipcash.services.models.chat.MessageContent + +/** + * How an end-to-end encrypted message stands on this device. Null on a [ChatMessage] means the + * message was sent in plaintext. + * + * [com.flipcash.services.models.chat.ChatMessage.content] follows it: the decrypted content when + * [Decrypted], the raw [MessageContent.Encrypted] otherwise. + */ +sealed interface MessageEncryption { + /** + * Opened on this device, or sent from it. [sealed] is the ciphertext as it went over the wire, + * kept so a later copy of the same message can be recognised without opening it again. + */ + data class Decrypted(val sealed: MessageContent.Encrypted) : MessageEncryption + + /** + * The chat key could not be derived because a key fetch failed. Not a decrypt failure: the + * message is hidden and opened again later, rather than shown as undecryptable. + */ + data object KeyPending : MessageEncryption + + data class Undecryptable(val reason: UndecryptableReason) : MessageEncryption +} + +enum class UndecryptableReason { + /** + * An unknown scheme, or a plaintext type this client doesn't render. A newer client can read + * it. + */ + Unsupported, + + /** The ciphertext failed authentication on a scheme this client supports. */ + Authentication, +} + +/** The outcome of opening one [MessageContent.Encrypted]. */ +sealed interface OpenedContent { + data class Plaintext(val content: MessageContent) : OpenedContent + data object KeyPending : OpenedContent + data class Undecryptable(val reason: UndecryptableReason) : OpenedContent +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt new file mode 100644 index 0000000000..7c3317d7f4 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/inject/ChatCryptoModule.kt @@ -0,0 +1,24 @@ +package com.flipcash.services.inject + +import com.flipcash.services.chat.ChatKeySource +import com.flipcash.services.chat.DefaultChatKeySource +import com.getcode.chatcipher.ChatCipher +import com.getcode.chatcipher.DefaultChatCipher +import dagger.Binds +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +internal abstract class ChatCryptoModule { + + @Binds + abstract fun bindChatKeySource(source: DefaultChatKeySource): ChatKeySource + + companion object { + @Provides + fun provideChatCipher(): ChatCipher = DefaultChatCipher + } +} diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt index a0759eb81b..6f44bf74e0 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/LocalToProtobuf.kt @@ -171,12 +171,7 @@ internal fun MessageContent.asContent(): MessagingModel.Content { .setDeleted(deletedBuilder) .build() } - // The client never constructs this locally from scratch -- it only exists as a decode - // result for incoming `Content.encrypted` (see MessageContent.Encrypted). An edit that - // rewrites the text of a message envelope containing this (e.g. a reply body) re-sends - // the untouched fields verbatim, so this is a faithful round-trip of the original wire - // bytes, not new encryption. Encrypting new content needs its own crypto implementation - // (X25519/HKDF/XChaCha20), tracked separately. + // Sealed by ChatContentCrypto, or relayed verbatim when an edit rewrites around it. is MessageContent.Encrypted -> MessagingModel.Content.newBuilder() .setEncrypted( MessagingModel.EncryptedContent.newBuilder() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt index 6c0cff564b..ac0f46d624 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/internal/network/extensions/ProtobufToLocal.kt @@ -117,10 +117,9 @@ internal fun PushModels.Payload.asPayload(): NotificationPayload { sendingUserId = if (chatMetadata.hasSendingUserId()) chatMetadata.sendingUserId.toId() else null, chatType = chatMetadata.type.toChatType(), message = if (chatMetadata.hasMessage()) chatMetadata.message.toChatMessage() else null, - // TODO(push/v1 ChatMetadata.message_ref): a long message now arrives as - // `message_id` only (fetch via Messaging.GetMessage(chatId, messageId)). Carried - // here for a future fetch; today the message-less case still falls back to - // PushHandlingPlanner's existing LoadMessages sync, so nothing is dropped. + // push/v1 ChatMetadata.message_ref: a long message arrives as `message_id` only. The + // notification fetches it with GetMessage to open an encrypted DM; storing it is left + // to PushHandlingPlanner's LoadMessages sync. messageId = if (chatMetadata.hasMessageId()) chatMetadata.messageId.value else null, muted = chatMetadata.muted, ) @@ -194,10 +193,7 @@ internal fun MessagingModel.Content.toMessageContent(): MessageContent { deletedTs = Instant.fromEpochSeconds(deleted.deletedTs.seconds, deleted.deletedTs.nanos), deletedBy = if (deleted.hasDeletedBy()) deleted.deletedBy.toId() else null, ) - // Not decoded: E2EE crypto (X25519/HKDF/XChaCha20) is a cross-platform parity hotspot - // that needs its own decision. Rendered as unsupported rather than dropped, but the raw - // fields are kept verbatim so the message can round-trip through storage and be - // faithfully re-encoded (e.g. on edit) without losing the ciphertext. + // Kept as ciphertext here; ChatContentCrypto opens it before it's stored. MessagingModel.Content.TypeCase.ENCRYPTED -> MessageContent.Encrypted( scheme = encrypted.schemeValue, nonce = encrypted.nonce.toByteArray(), diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt index d9a91dcdfe..b03cf69ca6 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt @@ -469,8 +469,9 @@ sealed class SendMessageError( override val cause: Throwable? = null ): CodeServerError(message, cause) { class Denied : SendMessageError("Denied") - // Sender attempted to send EncryptedContent to a chat that doesn't allow it (non-DM). - // The client never constructs EncryptedContent today, so this is defensive. + // Sender sent EncryptedContent to a chat that doesn't allow it (non-DM). E2eePolicy only + // encrypts in a DM, so this means the chat's type changed under us; the send fails and can + // be retried. class EncryptionNotAllowed : SendMessageError("Encryption not allowed") class Unrecognized : SendMessageError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : SendMessageError(message = cause?.message, cause = cause), NotifiableError @@ -533,8 +534,8 @@ sealed class EditMessageError( class MessageNotFound : EditMessageError("Message not found") class CannotEdit : EditMessageError("Cannot edit") class Conflict : EditMessageError("Conflict") - // Editor attempted to send EncryptedContent to a chat that doesn't allow it (non-DM). - // The client never constructs EncryptedContent today, so this is defensive. + // Editor sent EncryptedContent to a chat that doesn't allow it (non-DM). See + // SendMessageError.EncryptionNotAllowed. class EncryptionNotAllowed : EditMessageError("Encryption not allowed") class Unrecognized : EditMessageError("Unrecognized"), NotifiableError data class Other(override val cause: Throwable? = null) : EditMessageError(message = cause?.message, cause = cause), NotifiableError diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt index bc32f9bac1..0f6c389097 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/NotificationPayload.kt @@ -50,9 +50,9 @@ data class PushChatMetadata( // notification for it. val muted: Boolean = false, // Set when the server sent only the message's id (push/v1 ChatMetadata.message_ref, the - // `message_id` arm -- used for long messages instead of inlining `message`). Not yet - // fetched: see the TODO at ProtobufToLocal.asPayload(). [message] stays null in that case, - // and callers already fall back to their existing no-message sync path. + // `message_id` arm -- used for long messages instead of inlining `message`). [message] stays + // null in that case: the sync path loads the chat, and the notification fetches the one + // message to open it when the DM is end-to-end encrypted. val messageId: Long? = null, ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt index a3cb7ee7fd..75ccee2baa 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMessage.kt @@ -1,5 +1,6 @@ package com.flipcash.services.models.chat +import com.flipcash.services.chat.MessageEncryption import com.getcode.opencode.model.core.ID import kotlin.time.Instant @@ -18,4 +19,7 @@ data class ChatMessage( // Set when this copy was redacted for the viewer: it exists, but its content is a // placeholder. See messaging.v1.Message.redacted. val redacted: Boolean = false, + // Null for a plaintext message. For an end-to-end encrypted one, whether it was opened on this + // device; [content] holds the plaintext only when it was. + val encryption: MessageEncryption? = null, ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt index b39e60cd69..c530d526a0 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/ChatMetadata.kt @@ -26,6 +26,6 @@ data class ChatMetadata( // Group creator; null for DMs and for group chats reconstructed without a server round trip. val creator: ID? = null, // Transitional E2EE flag (DMs only): true means clients should send new content as - // EncryptedContent. Ignored behaviourally for now -- see chat/v1 model.proto. + // EncryptedContent. Read only by E2eePolicy -- see chat/v1 model.proto. val useE2ee: Boolean = false, ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt index ec5572ae7e..d08f12a3fb 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/models/chat/MessageContent.kt @@ -33,11 +33,9 @@ sealed interface MessageContent { val deletedTs: Instant, val deletedBy: ID?, ) : MessageContent - // Placeholder for `messaging.v1.Content.encrypted` (DMs only). Crypto (X25519/HKDF/ - // XChaCha20) is a cross-platform parity hotspot and needs its own decision; until then this - // renders as an unsupported message rather than being decoded. The raw fields are kept - // verbatim (not decrypted) so the ciphertext survives persistence and can be faithfully - // re-encoded, rather than being lost the moment it is stored locally. + // `messaging.v1.Content.encrypted` (DMs only), as it travels on the wire. Decrypted by + // ChatContentCrypto on the way into storage; a message that opened carries its plaintext in + // ChatMessage.content instead, and this only remains on one that didn't. data class Encrypted( val scheme: Int, val nonce: ByteArray, diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/ChatContentCryptoTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/ChatContentCryptoTest.kt new file mode 100644 index 0000000000..967774e60c --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/ChatContentCryptoTest.kt @@ -0,0 +1,159 @@ +package com.flipcash.services.chat + +import com.codeinc.flipcash.gen.messaging.v1.Model as MessagingModel +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.MessageContent +import com.getcode.ed25519kmp.KeyPair +import com.getcode.opencode.model.core.ID +import kotlinx.coroutines.test.runTest +import org.junit.Test +import java.io.IOException +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class ChatContentCryptoTest { + + private val chatId = ChatId(ByteArray(32) { 7 }) + private val self: ID = List(16) { 1 } + private val peer: ID = List(16) { 2 } + private val selfKeys = KeyPair(publicKey = ByteArray(32) { 11 }, privateKey = ByteArray(64) { 12 }) + private val peerKeys = KeyPair(publicKey = ByteArray(32) { 21 }, privateKey = ByteArray(64) { 22 }) + + private class Keys(own: KeyPair, private val peerPk: ByteArray) : ChatKeySource { + var own: KeyPair? = own + var peerFails: Throwable? = null + var fetches = 0 + override fun ownKeyPair() = own + override suspend fun peerPublicKey(userId: ID): Result { + fetches++ + return peerFails?.let { Result.failure(it) } ?: Result.success(peerPk) + } + } + + private val selfSide = Keys(selfKeys, peerKeys.publicKey) + private val peerSide = Keys(peerKeys, selfKeys.publicKey) + private val mine = ChatContentCrypto(FakeChatCipher, selfSide) + private val theirs = ChatContentCrypto(FakeChatCipher, peerSide) + + private suspend fun sealFromPeer(content: MessageContent) = + theirs.seal(chatId, peerId = self, content = content).getOrThrow() + + private suspend fun open(encrypted: MessageContent.Encrypted, senderId: ID? = peer) = + mine.open(chatId, selfId = self, peerId = peer, senderId = senderId, encrypted = encrypted) + + @Test + fun `text from the peer opens`() = runTest { + val sealed = sealFromPeer(MessageContent.Text("hi")) + + assertEquals(OpenedContent.Plaintext(MessageContent.Text("hi")), open(sealed)) + } + + @Test + fun `a reply to text opens with its quote`() = runTest { + val reply = MessageContent.Reply(repliedMessageId = 4, content = listOf(MessageContent.Text("yes"))) + + assertEquals(OpenedContent.Plaintext(reply), open(sealFromPeer(reply))) + } + + @Test + fun `the viewer's own message opens`() = runTest { + val sealed = mine.seal(chatId, peerId = peer, content = MessageContent.Text("mine")).getOrThrow() + + assertEquals(OpenedContent.Plaintext(MessageContent.Text("mine")), open(sealed, senderId = self)) + } + + @Test + fun `media is not sealed`() = runTest { + val result = mine.seal(chatId, peer, MessageContent.Media(items = emptyList(), caption = null)) + + assertTrue(result.isFailure) + } + + @Test + fun `an unknown scheme asks for an update without fetching keys`() = runTest { + val sealed = sealFromPeer(MessageContent.Text("hi")).copy(scheme = 2) + + assertEquals(OpenedContent.Undecryptable(UndecryptableReason.Unsupported), open(sealed)) + assertEquals(0, selfSide.fetches) + } + + @Test + fun `a plaintext type outside text and reply asks for an update`() = runTest { + val media = MessagingModel.Content.newBuilder() + .setMedia(MessagingModel.MediaContent.getDefaultInstance()) + .build() + val sealed = sealRaw(media.toByteArray()) + + assertEquals(OpenedContent.Undecryptable(UndecryptableReason.Unsupported), open(sealed)) + } + + @Test + fun `a reply to media asks for an update`() = runTest { + val replyToMedia = MessagingModel.Content.newBuilder() + .setReply( + MessagingModel.ReplyContent.newBuilder() + .setRepliedMessageId(MessagingModel.MessageId.newBuilder().setValue(1)) + .addContent( + MessagingModel.Content.newBuilder() + .setMedia(MessagingModel.MediaContent.getDefaultInstance()) + ) + ) + .build() + + assertEquals( + OpenedContent.Undecryptable(UndecryptableReason.Unsupported), + open(sealRaw(replyToMedia.toByteArray())), + ) + } + + @Test + fun `a tampered ciphertext fails authentication`() = runTest { + val sealed = sealFromPeer(MessageContent.Text("hi")) + val tampered = sealed.copy(ciphertext = sealed.ciphertext.copyOf().also { it[0] = (it[0] + 1).toByte() }) + + assertEquals(OpenedContent.Undecryptable(UndecryptableReason.Authentication), open(tampered)) + } + + @Test + fun `a message attributed to the wrong sender fails authentication`() = runTest { + val sealed = sealFromPeer(MessageContent.Text("hi")) + + assertEquals( + OpenedContent.Undecryptable(UndecryptableReason.Authentication), + open(sealed, senderId = self), + ) + } + + @Test + fun `a failed key fetch is pending, not undecryptable`() = runTest { + val sealed = sealFromPeer(MessageContent.Text("hi")) + selfSide.peerFails = IOException("offline") + + assertEquals(OpenedContent.KeyPending, open(sealed)) + + selfSide.peerFails = null + assertIs(open(sealed)) + } + + @Test + fun `no account key pair is pending`() = runTest { + val sealed = sealFromPeer(MessageContent.Text("hi")) + selfSide.own = null + + assertEquals(OpenedContent.KeyPending, open(sealed)) + } + + @Test + fun `the chat key is fetched once per chat`() = runTest { + repeat(3) { open(sealFromPeer(MessageContent.Text("$it"))) } + + assertEquals(1, selfSide.fetches) + } + + private suspend fun sealRaw(plaintext: ByteArray): MessageContent.Encrypted { + val chatKey = FakeChatCipher.chatKey(peerKeys, selfKeys.publicKey, chatId.bytes) + val payload = FakeChatCipher.encrypt(plaintext, chatKey, peerKeys.publicKey, selfKeys.publicKey, chatId.bytes) + return MessageContent.Encrypted(ChatContentCrypto.SCHEME_X25519_XCHACHA20POLY1305, payload.nonce, payload.ciphertext) + } +} diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/E2eePolicyTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/E2eePolicyTest.kt index 8219af6d39..3382fc3307 100644 --- a/services/flipcash/src/test/kotlin/com/flipcash/services/chat/E2eePolicyTest.kt +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/chat/E2eePolicyTest.kt @@ -5,6 +5,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMetadata import com.flipcash.services.models.chat.ChatType +import com.getcode.chatcipher.ChatEncryptionPolicy import org.junit.Test import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -12,9 +13,9 @@ import kotlin.time.Instant class E2eePolicyTest { - private val flipcash = List(32) { 1.toByte() } - private val friend = List(32) { 2.toByte() } - private val policy = E2eePolicy(flipcashAccountId = flipcash) + private val flipcash = ChatEncryptionPolicy.FLIPCASH_USER_ID.toList() + private val friend = List(16) { 2.toByte() } + private val policy = E2eePolicy() private fun member(id: List) = ChatMember(userId = id, userProfile = UserProfile.Empty, pointers = emptyList()) @@ -53,10 +54,4 @@ class E2eePolicyTest { fun `a chat with the flipcash account is never encrypted`() { assertFalse(policy.shouldEncrypt(chat(ChatType.TIP_DM, useE2ee = true, with = flipcash))) } - - @Test - fun `the exemption is a no-op until the flipcash id is set`() { - val unset = E2eePolicy(flipcashAccountId = null) - assertTrue(unset.shouldEncrypt(chat(ChatType.TIP_DM, useE2ee = true, with = flipcash))) - } } diff --git a/services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/FakeChatCipher.kt b/services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/FakeChatCipher.kt new file mode 100644 index 0000000000..88f038e2a4 --- /dev/null +++ b/services/flipcash/src/testFixtures/kotlin/com/flipcash/services/chat/FakeChatCipher.kt @@ -0,0 +1,56 @@ +package com.flipcash.services.chat + +import com.getcode.chatcipher.ChatCipher +import com.getcode.chatcipher.ChatCipherException +import com.getcode.chatcipher.EncryptedPayload +import com.getcode.ed25519kmp.KeyPair + +/** + * Stands in for [com.getcode.chatcipher.DefaultChatCipher], whose libsodium binding can't load on + * a JVM host. It keeps the properties callers depend on: both members derive the same chat key, + * and opening fails unless the key, the sender/recipient order and the bytes all match. + */ +object FakeChatCipher : ChatCipher { + private const val HEADER = 32 * 3 + + override fun chatKey(ownKeyPair: KeyPair, peerPublicKey: ByteArray, chatId: ByteArray): ByteArray = + ByteArray(32) { i -> (ownKeyPair.publicKey[i].toInt() xor peerPublicKey[i].toInt() xor chatId[i].toInt()).toByte() } + + override fun encrypt( + content: ByteArray, + chatKey: ByteArray, + senderPk: ByteArray, + recipientPk: ByteArray, + chatId: ByteArray, + ) = EncryptedPayload(nonce = ByteArray(24), ciphertext = chatKey + senderPk + recipientPk + content) + + override fun decrypt( + payload: EncryptedPayload, + chatKey: ByteArray, + senderPk: ByteArray, + recipientPk: ByteArray, + chatId: ByteArray, + ): ByteArray { + val header = payload.ciphertext.copyOfRange(0, HEADER) + if (!header.contentEquals(chatKey + senderPk + recipientPk)) throw ChatCipherException("authentication failed") + return payload.ciphertext.copyOfRange(HEADER, payload.ciphertext.size) + } + + override fun encryptBlob( + image: ByteArray, + chatKey: ByteArray, + senderPk: ByteArray, + recipientPk: ByteArray, + chatId: ByteArray, + blobId: ByteArray, + ): ByteArray = throw UnsupportedOperationException() + + override fun decryptBlob( + blob: ByteArray, + chatKey: ByteArray, + senderPk: ByteArray, + recipientPk: ByteArray, + chatId: ByteArray, + blobId: ByteArray, + ): ByteArray = throw UnsupportedOperationException() +}