Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,12 @@ class MessageCollector
deliveryMethod = receivedMessage.deliveryMethod,
// Local reception time for sort ordering
receivedAt = now,
// LXMF signature-verification state from the backend.
// false = sender identity was unknown to our RNS at
// receive time (potential forgery — see MessageEntity
// for full semantics); the UI renders an "unverified
// sender" warning on these.
signatureVerified = receivedMessage.signatureVerified,
)

// Get peer name from cache, existing conversation, or use formatted hash
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package network.columba.app.ui.components

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp

/**
* Inline warning banner for messages whose LXMF signature could not be verified
* against a known sender identity (`UnverifiedReason.SOURCE_UNKNOWN`). Mirrors
* Sideband's "this message is likely to be fake" banner; rendered above a
* message bubble when `MessageUi.signatureVerified == false`.
*
* Threat model: the LXMF wire layer encrypts to the recipient's public key, so
* decryption alone does NOT prove the sender's identity — the signature does.
* When we don't yet hold the sender's identity (their announce hasn't reached
* us), the signature can't be checked, and the message could be a legitimate
* first-contact OR a forgery from anyone who generated a fresh identity hash.
* Rendered with `errorContainer` for the same warning affordance the app uses
* for failed states.
*/
@Composable
fun UnverifiedSenderChip(modifier: Modifier = Modifier) {
Surface(
modifier = modifier,
color = MaterialTheme.colorScheme.errorContainer,
shape = RoundedCornerShape(8.dp),
) {
Row(
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
Icon(
imageVector = Icons.Default.Warning,
// Decorative — the adjacent Text already conveys the meaning, so
// a contentDescription here would double-read in TalkBack.
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer,
modifier = Modifier.size(14.dp),
)
Text(
text = "Unverified sender — may be forged",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onErrorContainer,
fontWeight = FontWeight.Medium,
)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ fun Message.toMessageUi(): MessageUi {
receivedSnr = receivedSnr,
receivedAt = receivedAt,
sentInterface = sentInterface,
signatureVerified = signatureVerified,
)
}

Expand Down
13 changes: 13 additions & 0 deletions app/src/main/java/network/columba/app/ui/model/MessageUi.kt
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,19 @@ data class MessageUi(
* Null for received messages or messages sent before this feature was added.
*/
val sentInterface: String? = null,
/**
* Whether the LXMF signature was verified against the sender's known
* identity at receive time.
* - true = signature checked against a known source identity, valid.
* - false = the sender's signature could not be verified — could be a
* legitimate first-contact or a forgery; the UI MUST warn.
* Kotlin backend: SOURCE_UNKNOWN only (LXMF-kt drops
* SIGNATURE_INVALID at the router). Python backend: may also be
* a failed signature check.
* - null = sent messages (signed locally) or legacy rows; treated as
* "no warning" to preserve historical display.
*/
val signatureVerified: Boolean? = null,
) {
/**
* Whether this message should be displayed as a standalone media item without a bubble.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ import androidx.compose.material.icons.filled.Error
import androidx.compose.material.icons.filled.HourglassEmpty
import androidx.compose.material.icons.filled.Hub
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.VerifiedUser
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
Expand Down Expand Up @@ -194,6 +196,34 @@ fun MessageDetailScreen(
} else {
// Received message info: delivery method, hop count, receiving interface.

// Signature verification card. Only emitted for received
// messages where the flag is non-null. Null (sent messages,
// legacy rows) renders no card — we don't claim a
// verification state either way for those, matching the
// bubble behavior in MessagingScreen.
msg.signatureVerified?.let { verified ->
if (verified) {
MessageInfoCard(
icon = Icons.Default.VerifiedUser,
title = "Signature",
content = "Verified",
subtitle = "The sender's identity was confirmed against their announce.",
)
} else {
MessageInfoCard(
icon = Icons.Default.Warning,
iconTint = MaterialTheme.colorScheme.error,
title = "Signature",
content = "Unverified",
subtitle = "The sender's identity was unknown to your node when this " +
"message arrived. It could be legitimate (their announce hasn't " +
"reached you yet) or a forgery from anyone who knows your address. " +
"Treat with caution.",
contentColor = MaterialTheme.colorScheme.error,
)
}
}

// Delivery method card. Especially important for propagation-fetched
// messages — hop count / interface / signal metrics are all null for
// those, so without this card the detail screen renders empty below
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ import network.columba.app.ui.components.SelectableTextDialog
import network.columba.app.ui.components.StarToggleButton
import network.columba.app.ui.components.SwipeableMessageBubble
import network.columba.app.ui.components.SyncStatusBottomSheet
import network.columba.app.ui.components.UnverifiedSenderChip
import network.columba.app.ui.components.simpleVerticalScrollbar
import network.columba.app.ui.model.CodecProfile
import network.columba.app.ui.model.LocationSharingState
Expand Down Expand Up @@ -1831,6 +1832,17 @@ fun MessageBubble(
modifier = Modifier.fillMaxWidth(),
horizontalAlignment = if (isFromMe) Alignment.End else Alignment.Start,
) {
// Unverified-sender warning — mirrors Sideband's "this message is
// likely to be fake" banner. Rendered above every bubble type (text,
// media-only, attachment, pending-file notification) because a forged
// message could carry any payload shape. Trigger is exactly
// `signatureVerified == false` (an unverified sender); null (sent,
// legacy) and true both skip the chip. See
// MessageEntity.signatureVerified for the full state table.
if (message.signatureVerified == false) {
UnverifiedSenderChip(modifier = Modifier.padding(bottom = 4.dp))
}

// Handle pending file notifications (system messages for files arriving via relay)
if (message.isPendingFileNotification) {
if (message.isSuperseded) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import network.columba.app.data.db.entity.RmspServerEntity
BlockedPeerEntity::class,
InterfaceFirstSeenEntity::class,
],
version = 2,
version = 3,
exportSchema = false,
)
abstract class ColumbaDatabase : RoomDatabase() {
Expand Down Expand Up @@ -108,6 +108,23 @@ abstract class ColumbaDatabase : RoomDatabase() {
}
}

/**
* v2 → v3: add nullable `messages.signatureVerified INTEGER` column.
*
* Surfaces LXMF signature-verification state per received message so
* the UI can warn on unverified senders. Pure additive `ALTER` — no
* data transform. Existing rows backfill to
* NULL, which the UI treats as "no warning": showing every legacy
* message as unverified would be inaccurate (most are from peers we
* already had on file) and alarming.
*/
val MIGRATION_2_3: Migration =
object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE messages ADD COLUMN signatureVerified INTEGER")
}
}

/**
* Extract the `fields[16].reactions` blob out of a legacy
* `fieldsJson`, returning `(newFieldsJson, reactionsJson)`.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,17 @@ data class MessageEntity(
val receivedAt: Long? = null,
// Interface name through which message was sent (null for received messages or pre-feature messages)
val sentInterface: String? = null,
// For received messages: whether the LXMF signature was verified against
// the sender's known identity.
// true = signature checked against a known source identity, valid.
// false = the sender's signature could not be verified — could be a
// forgery; the UI must warn. Kotlin backend: always
// SOURCE_UNKNOWN (we don't hold the sender's identity yet;
// LXMF-kt drops SIGNATURE_INVALID at the router). Python backend:
// may also be a failed signature check (python LXMF delivers
// those to the app).
// null = sent by us (signed locally — implicitly authentic) or legacy
// rows from before this column (migration backfills null). Treat
// as "no warning" to preserve historical display.
val signatureVerified: Boolean? = null,
)
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ object DatabaseModule {
context,
ColumbaDatabase::class.java,
DATABASE_NAME,
).addMigrations(ColumbaDatabase.MIGRATION_1_2)
).addMigrations(ColumbaDatabase.MIGRATION_1_2, ColumbaDatabase.MIGRATION_2_3)
.fallbackToDestructiveMigration()
.fallbackToDestructiveMigrationOnDowngrade()
.enableMultiInstanceInvalidation()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,11 @@ data class Message(
val receivedAt: Long? = null,
// Interface name through which message was sent (null for received messages or pre-feature messages)
val sentInterface: String? = null,
// For received messages: whether the LXMF signature was verified against
// the sender's known identity. false = potential forgery (sender identity
// unknown to us at receive time); null = sent by us, legacy rows, or the
// Python backend. See MessageEntity.signatureVerified for full semantics.
val signatureVerified: Boolean? = null,
// DB-local per-target-message reactions aggregation. Flat shape:
// {"👍": ["sender_hex_1", ...], "❤️": [...]}
// Never appears on the wire — the LXMF reaction wire format is
Expand Down Expand Up @@ -313,29 +318,12 @@ class ConversationRepository
val processedFieldsJson = extractLargeAttachments(message.id, message.fieldsJson)

val messageEntity =
MessageEntity(
id = message.id,
buildMessageEntity(
message = message,
conversationHash = peerHash,
identityHash = identityHash,
content = sanitizedContent, // Store SANITIZED content
timestamp = message.timestamp,
isFromMe = message.isFromMe,
status = message.status,
isRead = message.isFromMe, // Our own messages are always "read"
fieldsJson = processedFieldsJson, // LXMF fields with large attachments extracted
deliveryMethod = message.deliveryMethod,
errorMessage = message.errorMessage,
replyToMessageId = message.replyToMessageId, // Reply reference
// Received-side routing / signal metadata. Previously these
// were dropped here, which meant the message-details screen
// never rendered RSSI/SNR/hopcount/receiving-interface even
// when the upstream producer populated them.
receivedHopCount = message.receivedHopCount,
receivedInterface = message.receivedInterface,
receivedRssi = message.receivedRssi,
receivedSnr = message.receivedSnr,
receivedAt = message.receivedAt,
sentInterface = message.sentInterface,
sanitizedContent = sanitizedContent,
processedFieldsJson = processedFieldsJson,
)
messageDao.insertMessage(messageEntity)

Expand Down Expand Up @@ -605,9 +593,46 @@ class ConversationRepository
receivedInterface = receivedInterface,
receivedAt = receivedAt,
sentInterface = sentInterface,
signatureVerified = signatureVerified,
reactionsJson = reactionsJson,
)

/**
* Build a [MessageEntity] for persistence. Extracted from [saveMessage]
* (which is at the detekt LongMethod budget) — the caller supplies the
* resolved conversation/identity hashes plus the already-sanitized
* content and attachment-extracted fieldsJson. Received-side routing /
* signal metadata and `signatureVerified` are carried straight through
* so the message-details screen and unverified-sender warning render.
*/
private fun buildMessageEntity(
message: Message,
conversationHash: String,
identityHash: String,
sanitizedContent: String,
processedFieldsJson: String?,
) = MessageEntity(
id = message.id,
conversationHash = conversationHash,
identityHash = identityHash,
content = sanitizedContent,
timestamp = message.timestamp,
isFromMe = message.isFromMe,
status = message.status,
isRead = message.isFromMe, // Our own messages are always "read"
fieldsJson = processedFieldsJson,
deliveryMethod = message.deliveryMethod,
errorMessage = message.errorMessage,
replyToMessageId = message.replyToMessageId,
receivedHopCount = message.receivedHopCount,
receivedInterface = message.receivedInterface,
receivedRssi = message.receivedRssi,
receivedSnr = message.receivedSnr,
receivedAt = message.receivedAt,
sentInterface = message.sentInterface,
signatureVerified = message.signatureVerified,
)

/**
* Update the sent interface name for a message (active identity scoped).
*/
Expand Down
Loading
Loading