add 'encryption' and 'identityKey' to message version entity

This commit is contained in:
Daniel Gultsch 2023-02-25 12:28:36 +01:00
parent 677cfcd34c
commit cf5910e96e
No known key found for this signature in database
GPG key ID: F43D18AD2A0982C2
9 changed files with 157 additions and 65 deletions

View file

@ -2,7 +2,7 @@
"formatVersion": 1, "formatVersion": 1,
"database": { "database": {
"version": 1, "version": 1,
"identityHash": "6186e2691813f4fbd804b90fd770e18b", "identityHash": "a619bdeae0408fc2250a0bf2b9ab1f4e",
"entities": [ "entities": [
{ {
"tableName": "account", "tableName": "account",
@ -1834,7 +1834,7 @@
}, },
{ {
"tableName": "message_version", "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": [ "fields": [
{ {
"fieldPath": "id", "fieldPath": "id",
@ -1889,6 +1889,18 @@
"columnName": "receivedAt", "columnName": "receivedAt",
"affinity": "INTEGER", "affinity": "INTEGER",
"notNull": false "notNull": false
},
{
"fieldPath": "encryption",
"columnName": "encryption",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "identityKey",
"columnName": "identityKey",
"affinity": "BLOB",
"notNull": false
} }
], ],
"primaryKey": { "primaryKey": {
@ -2352,7 +2364,7 @@
"views": [], "views": [],
"setupQueries": [ "setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", "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')"
] ]
} }
} }

View file

@ -8,6 +8,7 @@ import com.google.common.collect.Iterables;
import im.conversations.android.IDs; import im.conversations.android.IDs;
import im.conversations.android.database.ConversationsDatabase; import im.conversations.android.database.ConversationsDatabase;
import im.conversations.android.database.entity.AccountEntity; 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.MessageEmbedded;
import im.conversations.android.database.model.Modification; import im.conversations.android.database.model.Modification;
import im.conversations.android.database.model.PartType; import im.conversations.android.database.model.PartType;
@ -83,6 +84,7 @@ public class MessageTransformationTest {
final var message = Iterables.getOnlyElement(messages); final var message = Iterables.getOnlyElement(messages);
final var onlyContent = Iterables.getOnlyElement(message.contents); final var onlyContent = Iterables.getOnlyElement(message.contents);
Assert.assertEquals(GREETING, onlyContent.body); Assert.assertEquals(GREETING, onlyContent.body);
Assert.assertEquals(Encryption.CLEARTEXT,message.encryption);
final var onlyReaction = Iterables.getOnlyElement(message.reactions); final var onlyReaction = Iterables.getOnlyElement(message.reactions);
Assert.assertEquals("Y", onlyReaction.reaction); Assert.assertEquals("Y", onlyReaction.reaction);
Assert.assertEquals(REMOTE, onlyReaction.reactionBy); Assert.assertEquals(REMOTE, onlyReaction.reactionBy);

View file

@ -16,11 +16,12 @@ import im.conversations.android.database.entity.MessageStateEntity;
import im.conversations.android.database.entity.MessageVersionEntity; import im.conversations.android.database.entity.MessageVersionEntity;
import im.conversations.android.database.model.Account; import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.ChatIdentifier; 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.MessageIdentifier;
import im.conversations.android.database.model.MessageState; import im.conversations.android.database.model.MessageState;
import im.conversations.android.database.model.MessageWithContentReactions; import im.conversations.android.database.model.MessageWithContentReactions;
import im.conversations.android.database.model.Modification; import im.conversations.android.database.model.Modification;
import im.conversations.android.transformer.MessageContentWrapper;
import im.conversations.android.transformer.MessageTransformation; import im.conversations.android.transformer.MessageTransformation;
import im.conversations.android.xmpp.model.reactions.Reactions; import im.conversations.android.xmpp.model.reactions.Reactions;
import im.conversations.android.xmpp.model.stanza.Message; 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.jxmpp.jid.parts.Resourcepart;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.whispersystems.libsignal.IdentityKey;
@Dao @Dao
public abstract class MessageDao { public abstract class MessageDao {
@ -324,19 +326,37 @@ public abstract class MessageDao {
+ " chatId=:chatId AND stanzaId=:stanzaId") + " chatId=:chatId AND stanzaId=:stanzaId")
protected abstract MessageIdentifier getByStanzaId(final long chatId, final String stanzaId); protected abstract MessageIdentifier getByStanzaId(final long chatId, final String stanzaId);
public void insertMessageContent(Long latestVersion, List<MessageContent> contents) { public void insertMessageContent(
final Long latestVersion, final MessageContentWrapper messageContentWrapper) {
Preconditions.checkNotNull( Preconditions.checkNotNull(
latestVersion, "Contents can only be inserted for a specific version"); latestVersion, "Contents can only be inserted for a specific version");
Preconditions.checkArgument( Preconditions.checkArgument(
contents.size() > 0, messageContentWrapper.contents.size() > 0,
"If you are trying to insert empty contents something went wrong"); "If you are trying to insert empty contents something went wrong");
insertMessageContent( 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 @Insert
protected abstract void insertMessageContent(Collection<MessageContentEntity> contentEntities); protected abstract void insertMessageContent(Collection<MessageContentEntity> 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( public void insertMessageState(
ChatIdentifier chatIdentifier, ChatIdentifier chatIdentifier,
final String messageId, final String messageId,
@ -402,9 +422,10 @@ public abstract class MessageDao {
@Query( @Query(
"SELECT message.id as" "SELECT message.id as"
+ " id,sentAt,outgoing,toBare,toResource,fromBare,fromResource,modification,latestVersion" + " id,sentAt,outgoing,toBare,toResource,fromBare,fromResource,modification,latestVersion"
+ " as version,inReplyToMessageEntityId FROM message JOIN message_version ON" + " as version,inReplyToMessageEntityId,encryption,identityKey FROM message JOIN"
+ " message.latestVersion=message_version.id WHERE message.chatId=:chatId AND" + " message_version ON message.latestVersion=message_version.id WHERE"
+ " latestVersion IS NOT NULL ORDER BY message.receivedAt") + " message.chatId=:chatId AND latestVersion IS NOT NULL ORDER BY"
+ " message.receivedAt")
public abstract List<MessageWithContentReactions> getMessages(long chatId); public abstract List<MessageWithContentReactions> getMessages(long chatId);
public void setInReplyTo( public void setInReplyTo(

View file

@ -1,17 +1,20 @@
package im.conversations.android.database.entity; package im.conversations.android.database.entity;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.room.Entity; import androidx.room.Entity;
import androidx.room.ForeignKey; import androidx.room.ForeignKey;
import androidx.room.Index; import androidx.room.Index;
import androidx.room.PrimaryKey; import androidx.room.PrimaryKey;
import com.google.common.base.Preconditions; import com.google.common.base.Preconditions;
import im.conversations.android.database.model.Encryption;
import im.conversations.android.database.model.Modification; import im.conversations.android.database.model.Modification;
import im.conversations.android.transformer.MessageTransformation; import im.conversations.android.transformer.MessageTransformation;
import im.conversations.android.xmpp.model.stanza.Message; import im.conversations.android.xmpp.model.stanza.Message;
import java.time.Instant; import java.time.Instant;
import org.jxmpp.jid.BareJid; import org.jxmpp.jid.BareJid;
import org.jxmpp.jid.parts.Resourcepart; import org.jxmpp.jid.parts.Resourcepart;
import org.whispersystems.libsignal.IdentityKey;
@Entity( @Entity(
tableName = "message_version", tableName = "message_version",
@ -36,6 +39,8 @@ public class MessageVersionEntity {
public Resourcepart modifiedByResource; public Resourcepart modifiedByResource;
public String occupantId; public String occupantId;
public Instant receivedAt; public Instant receivedAt;
@Nullable public Encryption encryption;
@Nullable public IdentityKey identityKey;
// the version order is determined by the receivedAt // the version order is determined by the receivedAt
// the actual display time and display order comes from the parent MessageEntity // the actual display time and display order comes from the parent MessageEntity

View file

@ -0,0 +1,7 @@
package im.conversations.android.database.model;
public enum Encryption {
OMEMO,
CLEARTEXT,
PGP
}

View file

@ -1,8 +1,5 @@
package im.conversations.android.database.model; package im.conversations.android.database.model;
import com.google.common.collect.ImmutableList;
import java.util.List;
public class MessageContent { public class MessageContent {
public final String language; public final String language;
@ -27,7 +24,4 @@ public class MessageContent {
public static MessageContent file(final String url) { public static MessageContent file(final String url) {
return new MessageContent(null, PartType.FILE, null, url); return new MessageContent(null, PartType.FILE, null, url);
} }
public static final List<MessageContent> RETRACTION =
ImmutableList.of(new MessageContent(null, PartType.RETRACTION, null, null));
} }

View file

@ -14,6 +14,7 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
import org.jxmpp.jid.Jid; import org.jxmpp.jid.Jid;
import org.whispersystems.libsignal.IdentityKey;
public class MessageWithContentReactions { public class MessageWithContentReactions {
@ -31,6 +32,8 @@ public class MessageWithContentReactions {
public Modification modification; public Modification modification;
public long version; public long version;
public Long inReplyToMessageEntityId; public Long inReplyToMessageEntityId;
public Encryption encryption;
public IdentityKey identityKey;
@Relation( @Relation(
entity = MessageEntity.class, entity = MessageEntity.class,

View file

@ -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<MessageContent> contents;
public final Encryption encryption;
public final IdentityKey identityKey;
private MessageContentWrapper(
List<MessageContent> 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<Body> bodies = transformation.getExtensions(Body.class);
final Collection<OutOfBandData> outOfBandData =
transformation.getExtensions(OutOfBandData.class);
final ImmutableList.Builder<MessageContent> 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<MessageContent> 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();
}
}

View file

@ -1,32 +1,24 @@
package im.conversations.android.transformer; package im.conversations.android.transformer;
import com.google.common.base.Preconditions; 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.AxolotlDecryptionException;
import im.conversations.android.axolotl.AxolotlService; import im.conversations.android.axolotl.AxolotlService;
import im.conversations.android.database.ConversationsDatabase; import im.conversations.android.database.ConversationsDatabase;
import im.conversations.android.database.model.Account; import im.conversations.android.database.model.Account;
import im.conversations.android.database.model.ChatIdentifier; 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.MessageIdentifier;
import im.conversations.android.database.model.MessageState; import im.conversations.android.database.model.MessageState;
import im.conversations.android.database.model.Modification; import im.conversations.android.database.model.Modification;
import im.conversations.android.xmpp.model.DeliveryReceipt; import im.conversations.android.xmpp.model.DeliveryReceipt;
import im.conversations.android.xmpp.model.axolotl.Encrypted; import im.conversations.android.xmpp.model.axolotl.Encrypted;
import im.conversations.android.xmpp.model.correction.Replace; 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.markers.Displayed;
import im.conversations.android.xmpp.model.muc.user.MultiUserChat; 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.reactions.Reactions;
import im.conversations.android.xmpp.model.reply.Reply; import im.conversations.android.xmpp.model.reply.Reply;
import im.conversations.android.xmpp.model.retract.Retract; import im.conversations.android.xmpp.model.retract.Retract;
import im.conversations.android.xmpp.model.stanza.Message; import im.conversations.android.xmpp.model.stanza.Message;
import java.util.Arrays; import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Objects; import java.util.Objects;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -89,13 +81,12 @@ public class Transformer {
final Reactions reactions = transformation.getExtension(Reactions.class); final Reactions reactions = transformation.getExtension(Reactions.class);
final Retract retract = transformation.getExtension(Retract.class); final Retract retract = transformation.getExtension(Retract.class);
final Encrypted encrypted = transformation.getExtension(Encrypted.class); final Encrypted encrypted = transformation.getExtension(Encrypted.class);
final List<MessageContent> contents; final MessageContentWrapper contents;
if (encrypted != null) { if (encrypted != null) {
try { try {
final var payload = axolotlService.decrypt(transformation.from, encrypted); final var payload = axolotlService.decrypt(transformation.from, encrypted);
if (payload.hasPayload()) { if (payload.hasPayload()) {
contents = contents = MessageContentWrapper.ofAxolotl(payload);
ImmutableList.of(MessageContent.text(payload.payloadAsString(), null));
} else { } else {
return true; return true;
} }
@ -107,7 +98,7 @@ public class Transformer {
} else { } else {
// TODO we need to remove fallbacks for reactions, retractions and potentially other // TODO we need to remove fallbacks for reactions, retractions and potentially other
// things // things
contents = parseContent(transformation); contents = MessageContentWrapper.parseCleartext(transformation);
} }
final boolean identifiableSender = final boolean identifiableSender =
@ -131,7 +122,8 @@ public class Transformer {
.getOrCreateVersion( .getOrCreateVersion(
chat, transformation, retract.getId(), Modification.RETRACTION); chat, transformation, retract.getId(), Modification.RETRACTION);
database.messageDao() database.messageDao()
.insertMessageContent(messageIdentifier.version, MessageContent.RETRACTION); .insertMessageContent(
messageIdentifier.version, MessageContentWrapper.RETRACTION);
return true; return true;
} else if (contents.isEmpty()) { } else if (contents.isEmpty()) {
LOGGER.info("Received message from {} w/o contents", transformation.from); LOGGER.info("Received message from {} w/o contents", transformation.from);
@ -173,42 +165,6 @@ public class Transformer {
return true; return true;
} }
protected List<MessageContent> parseContent(final MessageTransformation transformation) {
final var encrypted = transformation.getExtension(Encrypted.class);
final var encryptedWithPayload = encrypted != null && encrypted.hasPayload();
final Collection<Body> bodies = transformation.getExtensions(Body.class);
final Collection<OutOfBandData> outOfBandData =
transformation.getExtensions(OutOfBandData.class);
final ImmutableList.Builder<MessageContent> 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( private void transformMessageState(
final ChatIdentifier chat, final MessageTransformation transformation) { final ChatIdentifier chat, final MessageTransformation transformation) {
final var displayed = transformation.getExtension(Displayed.class); final var displayed = transformation.getExtension(Displayed.class);