From cf5910e96e3acc87895d371d2f5d4155cff9692c Mon Sep 17 00:00:00 2001 From: Daniel Gultsch Date: Sat, 25 Feb 2023 12:28:36 +0100 Subject: [PATCH] add 'encryption' and 'identityKey' to message version entity --- .../1.json | 18 +++- .../xmpp/MessageTransformationTest.java | 2 + .../android/database/dao/MessageDao.java | 35 +++++-- .../database/entity/MessageVersionEntity.java | 5 + .../android/database/model/Encryption.java | 7 ++ .../database/model/MessageContent.java | 6 -- .../model/MessageWithContentReactions.java | 3 + .../transformer/MessageContentWrapper.java | 92 +++++++++++++++++++ .../android/transformer/Transformer.java | 54 +---------- 9 files changed, 157 insertions(+), 65 deletions(-) create mode 100644 app/src/main/java/im/conversations/android/database/model/Encryption.java create mode 100644 app/src/main/java/im/conversations/android/transformer/MessageContentWrapper.java diff --git a/app/schemas/im.conversations.android.database.ConversationsDatabase/1.json b/app/schemas/im.conversations.android.database.ConversationsDatabase/1.json index 0c726c566..c922a014c 100644 --- a/app/schemas/im.conversations.android.database.ConversationsDatabase/1.json +++ b/app/schemas/im.conversations.android.database.ConversationsDatabase/1.json @@ -2,7 +2,7 @@ "formatVersion": 1, "database": { "version": 1, - "identityHash": "6186e2691813f4fbd804b90fd770e18b", + "identityHash": "a619bdeae0408fc2250a0bf2b9ab1f4e", "entities": [ { "tableName": "account", @@ -1834,7 +1834,7 @@ }, { "tableName": "message_version", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `messageEntityId` INTEGER NOT NULL, `messageId` TEXT, `stanzaId` TEXT, `modification` TEXT, `modifiedBy` TEXT, `modifiedByResource` TEXT, `occupantId` TEXT, `receivedAt` INTEGER, FOREIGN KEY(`messageEntityId`) REFERENCES `message`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `messageEntityId` INTEGER NOT NULL, `messageId` TEXT, `stanzaId` TEXT, `modification` TEXT, `modifiedBy` TEXT, `modifiedByResource` TEXT, `occupantId` TEXT, `receivedAt` INTEGER, `encryption` TEXT, `identityKey` BLOB, FOREIGN KEY(`messageEntityId`) REFERENCES `message`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", @@ -1889,6 +1889,18 @@ "columnName": "receivedAt", "affinity": "INTEGER", "notNull": false + }, + { + "fieldPath": "encryption", + "columnName": "encryption", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "identityKey", + "columnName": "identityKey", + "affinity": "BLOB", + "notNull": false } ], "primaryKey": { @@ -2352,7 +2364,7 @@ "views": [], "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, '6186e2691813f4fbd804b90fd770e18b')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'a619bdeae0408fc2250a0bf2b9ab1f4e')" ] } } \ No newline at end of file diff --git a/app/src/androidTest/java/im/conversations/android/xmpp/MessageTransformationTest.java b/app/src/androidTest/java/im/conversations/android/xmpp/MessageTransformationTest.java index 4e6a756e7..dd78e2b6f 100644 --- a/app/src/androidTest/java/im/conversations/android/xmpp/MessageTransformationTest.java +++ b/app/src/androidTest/java/im/conversations/android/xmpp/MessageTransformationTest.java @@ -8,6 +8,7 @@ import com.google.common.collect.Iterables; import im.conversations.android.IDs; import im.conversations.android.database.ConversationsDatabase; import im.conversations.android.database.entity.AccountEntity; +import im.conversations.android.database.model.Encryption; import im.conversations.android.database.model.MessageEmbedded; import im.conversations.android.database.model.Modification; import im.conversations.android.database.model.PartType; @@ -83,6 +84,7 @@ public class MessageTransformationTest { final var message = Iterables.getOnlyElement(messages); final var onlyContent = Iterables.getOnlyElement(message.contents); Assert.assertEquals(GREETING, onlyContent.body); + Assert.assertEquals(Encryption.CLEARTEXT,message.encryption); final var onlyReaction = Iterables.getOnlyElement(message.reactions); Assert.assertEquals("Y", onlyReaction.reaction); Assert.assertEquals(REMOTE, onlyReaction.reactionBy); diff --git a/app/src/main/java/im/conversations/android/database/dao/MessageDao.java b/app/src/main/java/im/conversations/android/database/dao/MessageDao.java index af51c50c1..4dd0943ab 100644 --- a/app/src/main/java/im/conversations/android/database/dao/MessageDao.java +++ b/app/src/main/java/im/conversations/android/database/dao/MessageDao.java @@ -16,11 +16,12 @@ import im.conversations.android.database.entity.MessageStateEntity; import im.conversations.android.database.entity.MessageVersionEntity; import im.conversations.android.database.model.Account; import im.conversations.android.database.model.ChatIdentifier; -import im.conversations.android.database.model.MessageContent; +import im.conversations.android.database.model.Encryption; import im.conversations.android.database.model.MessageIdentifier; import im.conversations.android.database.model.MessageState; import im.conversations.android.database.model.MessageWithContentReactions; import im.conversations.android.database.model.Modification; +import im.conversations.android.transformer.MessageContentWrapper; import im.conversations.android.transformer.MessageTransformation; import im.conversations.android.xmpp.model.reactions.Reactions; import im.conversations.android.xmpp.model.stanza.Message; @@ -31,6 +32,7 @@ import org.jxmpp.jid.Jid; import org.jxmpp.jid.parts.Resourcepart; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.whispersystems.libsignal.IdentityKey; @Dao public abstract class MessageDao { @@ -324,19 +326,37 @@ public abstract class MessageDao { + " chatId=:chatId AND stanzaId=:stanzaId") protected abstract MessageIdentifier getByStanzaId(final long chatId, final String stanzaId); - public void insertMessageContent(Long latestVersion, List contents) { + public void insertMessageContent( + final Long latestVersion, final MessageContentWrapper messageContentWrapper) { Preconditions.checkNotNull( latestVersion, "Contents can only be inserted for a specific version"); Preconditions.checkArgument( - contents.size() > 0, + messageContentWrapper.contents.size() > 0, "If you are trying to insert empty contents something went wrong"); insertMessageContent( - Lists.transform(contents, c -> MessageContentEntity.of(latestVersion, c))); + Lists.transform( + messageContentWrapper.contents, + c -> MessageContentEntity.of(latestVersion, c))); + final int rows = + updateMessageVersionEncryption( + latestVersion, + messageContentWrapper.encryption, + messageContentWrapper.identityKey); + if (rows != 1) { + throw new IllegalStateException( + "We expected to update encryption information on exactly 1 row"); + } } @Insert protected abstract void insertMessageContent(Collection contentEntities); + @Query( + "UPDATE message_version SET encryption=:encryption,identityKey=:identityKey WHERE" + + " id=:messageVersionId") + protected abstract int updateMessageVersionEncryption( + long messageVersionId, Encryption encryption, IdentityKey identityKey); + public void insertMessageState( ChatIdentifier chatIdentifier, final String messageId, @@ -402,9 +422,10 @@ public abstract class MessageDao { @Query( "SELECT message.id as" + " id,sentAt,outgoing,toBare,toResource,fromBare,fromResource,modification,latestVersion" - + " as version,inReplyToMessageEntityId FROM message JOIN message_version ON" - + " message.latestVersion=message_version.id WHERE message.chatId=:chatId AND" - + " latestVersion IS NOT NULL ORDER BY message.receivedAt") + + " as version,inReplyToMessageEntityId,encryption,identityKey FROM message JOIN" + + " message_version ON message.latestVersion=message_version.id WHERE" + + " message.chatId=:chatId AND latestVersion IS NOT NULL ORDER BY" + + " message.receivedAt") public abstract List getMessages(long chatId); public void setInReplyTo( diff --git a/app/src/main/java/im/conversations/android/database/entity/MessageVersionEntity.java b/app/src/main/java/im/conversations/android/database/entity/MessageVersionEntity.java index 3a89208e7..5103cbff8 100644 --- a/app/src/main/java/im/conversations/android/database/entity/MessageVersionEntity.java +++ b/app/src/main/java/im/conversations/android/database/entity/MessageVersionEntity.java @@ -1,17 +1,20 @@ package im.conversations.android.database.entity; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.room.Entity; import androidx.room.ForeignKey; import androidx.room.Index; import androidx.room.PrimaryKey; import com.google.common.base.Preconditions; +import im.conversations.android.database.model.Encryption; import im.conversations.android.database.model.Modification; import im.conversations.android.transformer.MessageTransformation; import im.conversations.android.xmpp.model.stanza.Message; import java.time.Instant; import org.jxmpp.jid.BareJid; import org.jxmpp.jid.parts.Resourcepart; +import org.whispersystems.libsignal.IdentityKey; @Entity( tableName = "message_version", @@ -36,6 +39,8 @@ public class MessageVersionEntity { public Resourcepart modifiedByResource; public String occupantId; public Instant receivedAt; + @Nullable public Encryption encryption; + @Nullable public IdentityKey identityKey; // the version order is determined by the receivedAt // the actual display time and display order comes from the parent MessageEntity diff --git a/app/src/main/java/im/conversations/android/database/model/Encryption.java b/app/src/main/java/im/conversations/android/database/model/Encryption.java new file mode 100644 index 000000000..60f6f6244 --- /dev/null +++ b/app/src/main/java/im/conversations/android/database/model/Encryption.java @@ -0,0 +1,7 @@ +package im.conversations.android.database.model; + +public enum Encryption { + OMEMO, + CLEARTEXT, + PGP +} diff --git a/app/src/main/java/im/conversations/android/database/model/MessageContent.java b/app/src/main/java/im/conversations/android/database/model/MessageContent.java index 095f324a7..98ceb3ffc 100644 --- a/app/src/main/java/im/conversations/android/database/model/MessageContent.java +++ b/app/src/main/java/im/conversations/android/database/model/MessageContent.java @@ -1,8 +1,5 @@ package im.conversations.android.database.model; -import com.google.common.collect.ImmutableList; -import java.util.List; - public class MessageContent { public final String language; @@ -27,7 +24,4 @@ public class MessageContent { public static MessageContent file(final String url) { return new MessageContent(null, PartType.FILE, null, url); } - - public static final List RETRACTION = - ImmutableList.of(new MessageContent(null, PartType.RETRACTION, null, null)); } diff --git a/app/src/main/java/im/conversations/android/database/model/MessageWithContentReactions.java b/app/src/main/java/im/conversations/android/database/model/MessageWithContentReactions.java index cc74e0976..6305ded9f 100644 --- a/app/src/main/java/im/conversations/android/database/model/MessageWithContentReactions.java +++ b/app/src/main/java/im/conversations/android/database/model/MessageWithContentReactions.java @@ -14,6 +14,7 @@ import java.util.List; import java.util.Map; import java.util.Set; import org.jxmpp.jid.Jid; +import org.whispersystems.libsignal.IdentityKey; public class MessageWithContentReactions { @@ -31,6 +32,8 @@ public class MessageWithContentReactions { public Modification modification; public long version; public Long inReplyToMessageEntityId; + public Encryption encryption; + public IdentityKey identityKey; @Relation( entity = MessageEntity.class, diff --git a/app/src/main/java/im/conversations/android/transformer/MessageContentWrapper.java b/app/src/main/java/im/conversations/android/transformer/MessageContentWrapper.java new file mode 100644 index 000000000..37166c717 --- /dev/null +++ b/app/src/main/java/im/conversations/android/transformer/MessageContentWrapper.java @@ -0,0 +1,92 @@ +package im.conversations.android.transformer; + +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.Iterables; +import im.conversations.android.axolotl.AxolotlPayload; +import im.conversations.android.database.model.Encryption; +import im.conversations.android.database.model.MessageContent; +import im.conversations.android.database.model.PartType; +import im.conversations.android.xmpp.model.jabber.Body; +import im.conversations.android.xmpp.model.oob.OutOfBandData; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import org.whispersystems.libsignal.IdentityKey; + +public class MessageContentWrapper { + + public static final MessageContentWrapper RETRACTION = + new MessageContentWrapper( + ImmutableList.of(new MessageContent(null, PartType.RETRACTION, null, null)), + Encryption.CLEARTEXT, + null); + + public final List contents; + public final Encryption encryption; + public final IdentityKey identityKey; + + private MessageContentWrapper( + List contents, Encryption encryption, IdentityKey identityKey) { + if (encryption == Encryption.OMEMO) { + Preconditions.checkArgument( + Objects.nonNull(identityKey), + "OMEMO encrypted content must provide an identity key"); + } + this.contents = contents; + this.encryption = encryption; + this.identityKey = identityKey; + } + + public static MessageContentWrapper parseCleartext(final MessageTransformation transformation) { + final Collection bodies = transformation.getExtensions(Body.class); + final Collection outOfBandData = + transformation.getExtensions(OutOfBandData.class); + final ImmutableList.Builder messageContentBuilder = ImmutableList.builder(); + + if (bodies.size() == 1 && outOfBandData.size() == 1) { + final String text = Iterables.getOnlyElement(bodies).getContent(); + final String url = Iterables.getOnlyElement(outOfBandData).getURL(); + if (!Strings.isNullOrEmpty(url) && url.equals(text)) { + return cleartext(ImmutableList.of(MessageContent.file(url))); + } + } + + // TODO verify that body is not fallback + for (final Body body : bodies) { + final String text = body.getContent(); + if (Strings.isNullOrEmpty(text)) { + continue; + } + messageContentBuilder.add(MessageContent.text(text, body.getLang())); + } + for (final OutOfBandData data : outOfBandData) { + final String url = data.getURL(); + if (Strings.isNullOrEmpty(url)) { + continue; + } + messageContentBuilder.add(MessageContent.file(url)); + } + return cleartext(messageContentBuilder.build()); + } + + private static MessageContentWrapper cleartext(final List contents) { + return new MessageContentWrapper(contents, Encryption.CLEARTEXT, null); + } + + public static MessageContentWrapper ofAxolotl(final AxolotlPayload payload) { + if (payload.hasPayload()) { + return new MessageContentWrapper( + ImmutableList.of(MessageContent.text(payload.payloadAsString(), null)), + Encryption.OMEMO, + payload.identityKey); + } + throw new IllegalArgumentException( + String.format("%s does not have payload", payload.getClass().getSimpleName())); + } + + public boolean isEmpty() { + return this.contents.isEmpty(); + } +} diff --git a/app/src/main/java/im/conversations/android/transformer/Transformer.java b/app/src/main/java/im/conversations/android/transformer/Transformer.java index 8e2ef90de..ed63d315e 100644 --- a/app/src/main/java/im/conversations/android/transformer/Transformer.java +++ b/app/src/main/java/im/conversations/android/transformer/Transformer.java @@ -1,32 +1,24 @@ package im.conversations.android.transformer; import com.google.common.base.Preconditions; -import com.google.common.base.Strings; -import com.google.common.collect.ImmutableList; -import com.google.common.collect.Iterables; import im.conversations.android.axolotl.AxolotlDecryptionException; import im.conversations.android.axolotl.AxolotlService; import im.conversations.android.database.ConversationsDatabase; import im.conversations.android.database.model.Account; import im.conversations.android.database.model.ChatIdentifier; -import im.conversations.android.database.model.MessageContent; import im.conversations.android.database.model.MessageIdentifier; import im.conversations.android.database.model.MessageState; import im.conversations.android.database.model.Modification; import im.conversations.android.xmpp.model.DeliveryReceipt; import im.conversations.android.xmpp.model.axolotl.Encrypted; import im.conversations.android.xmpp.model.correction.Replace; -import im.conversations.android.xmpp.model.jabber.Body; import im.conversations.android.xmpp.model.markers.Displayed; import im.conversations.android.xmpp.model.muc.user.MultiUserChat; -import im.conversations.android.xmpp.model.oob.OutOfBandData; import im.conversations.android.xmpp.model.reactions.Reactions; import im.conversations.android.xmpp.model.reply.Reply; import im.conversations.android.xmpp.model.retract.Retract; import im.conversations.android.xmpp.model.stanza.Message; import java.util.Arrays; -import java.util.Collection; -import java.util.List; import java.util.Objects; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -89,13 +81,12 @@ public class Transformer { final Reactions reactions = transformation.getExtension(Reactions.class); final Retract retract = transformation.getExtension(Retract.class); final Encrypted encrypted = transformation.getExtension(Encrypted.class); - final List contents; + final MessageContentWrapper contents; if (encrypted != null) { try { final var payload = axolotlService.decrypt(transformation.from, encrypted); if (payload.hasPayload()) { - contents = - ImmutableList.of(MessageContent.text(payload.payloadAsString(), null)); + contents = MessageContentWrapper.ofAxolotl(payload); } else { return true; } @@ -107,7 +98,7 @@ public class Transformer { } else { // TODO we need to remove fallbacks for reactions, retractions and potentially other // things - contents = parseContent(transformation); + contents = MessageContentWrapper.parseCleartext(transformation); } final boolean identifiableSender = @@ -131,7 +122,8 @@ public class Transformer { .getOrCreateVersion( chat, transformation, retract.getId(), Modification.RETRACTION); database.messageDao() - .insertMessageContent(messageIdentifier.version, MessageContent.RETRACTION); + .insertMessageContent( + messageIdentifier.version, MessageContentWrapper.RETRACTION); return true; } else if (contents.isEmpty()) { LOGGER.info("Received message from {} w/o contents", transformation.from); @@ -173,42 +165,6 @@ public class Transformer { return true; } - protected List parseContent(final MessageTransformation transformation) { - final var encrypted = transformation.getExtension(Encrypted.class); - final var encryptedWithPayload = encrypted != null && encrypted.hasPayload(); - final Collection bodies = transformation.getExtensions(Body.class); - final Collection outOfBandData = - transformation.getExtensions(OutOfBandData.class); - final ImmutableList.Builder messageContentBuilder = ImmutableList.builder(); - - // TODO decrypt - - if (bodies.size() == 1 && outOfBandData.size() == 1) { - final String text = Iterables.getOnlyElement(bodies).getContent(); - final String url = Iterables.getOnlyElement(outOfBandData).getURL(); - if (!Strings.isNullOrEmpty(url) && url.equals(text)) { - return ImmutableList.of(MessageContent.file(url)); - } - } - - // TODO verify that body is not fallback - for (final Body body : bodies) { - final String text = body.getContent(); - if (Strings.isNullOrEmpty(text)) { - continue; - } - messageContentBuilder.add(MessageContent.text(text, body.getLang())); - } - for (final OutOfBandData data : outOfBandData) { - final String url = data.getURL(); - if (Strings.isNullOrEmpty(url)) { - continue; - } - messageContentBuilder.add(MessageContent.file(url)); - } - return messageContentBuilder.build(); - } - private void transformMessageState( final ChatIdentifier chat, final MessageTransformation transformation) { final var displayed = transformation.getExtension(Displayed.class);