diff --git a/app/schemas/im.conversations.android.database.ConversationsDatabase/1.json b/app/schemas/im.conversations.android.database.ConversationsDatabase/1.json index c922a014c..bbce9c12b 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": "a619bdeae0408fc2250a0bf2b9ab1f4e", + "identityHash": "983b8fb918cf0019a31e3a59b37dc368", "entities": [ { "tableName": "account", @@ -362,7 +362,7 @@ }, { "tableName": "axolotl_device_list_item", - "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `deviceListId` INTEGER NOT NULL, `deviceId` INTEGER, FOREIGN KEY(`deviceListId`) REFERENCES `axolotl_device_list`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT, `deviceListId` INTEGER NOT NULL, `deviceId` INTEGER, `confirmedInPep` INTEGER NOT NULL, FOREIGN KEY(`deviceListId`) REFERENCES `axolotl_device_list`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", "fields": [ { "fieldPath": "id", @@ -381,6 +381,12 @@ "columnName": "deviceId", "affinity": "INTEGER", "notNull": false + }, + { + "fieldPath": "confirmedInPep", + "columnName": "confirmedInPep", + "affinity": "INTEGER", + "notNull": true } ], "primaryKey": { @@ -2364,7 +2370,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, 'a619bdeae0408fc2250a0bf2b9ab1f4e')" + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '983b8fb918cf0019a31e3a59b37dc368')" ] } } \ No newline at end of file diff --git a/app/src/main/java/im/conversations/android/axolotl/AxolotlAddress.java b/app/src/main/java/im/conversations/android/axolotl/AxolotlAddress.java index e325e748a..4947479fb 100644 --- a/app/src/main/java/im/conversations/android/axolotl/AxolotlAddress.java +++ b/app/src/main/java/im/conversations/android/axolotl/AxolotlAddress.java @@ -1,5 +1,6 @@ package im.conversations.android.axolotl; +import com.google.common.base.Objects; import org.jxmpp.jid.BareJid; import org.whispersystems.libsignal.SignalProtocolAddress; @@ -26,4 +27,18 @@ public class AxolotlAddress extends SignalProtocolAddress { SignalProtocolAddress.class.getSimpleName(), AxolotlAddress.class.getSimpleName())); } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + if (!super.equals(o)) return false; + AxolotlAddress that = (AxolotlAddress) o; + return Objects.equal(jid, that.jid); + } + + @Override + public int hashCode() { + return Objects.hashCode(super.hashCode(), jid); + } } diff --git a/app/src/main/java/im/conversations/android/axolotl/AxolotlPayload.java b/app/src/main/java/im/conversations/android/axolotl/AxolotlPayload.java index 48a767410..a8b818d67 100644 --- a/app/src/main/java/im/conversations/android/axolotl/AxolotlPayload.java +++ b/app/src/main/java/im/conversations/android/axolotl/AxolotlPayload.java @@ -8,16 +8,19 @@ public class AxolotlPayload { public final AxolotlAddress axolotlAddress; public final IdentityKey identityKey; public final boolean preKeyMessage; + public final boolean inDeviceList; public final byte[] payload; public AxolotlPayload( AxolotlAddress axolotlAddress, final IdentityKey identityKey, final boolean preKeyMessage, + final boolean inDeviceList, byte[] payload) { this.axolotlAddress = axolotlAddress; this.identityKey = identityKey; this.preKeyMessage = preKeyMessage; + this.inDeviceList = inDeviceList; this.payload = payload; } diff --git a/app/src/main/java/im/conversations/android/axolotl/AxolotlService.java b/app/src/main/java/im/conversations/android/axolotl/AxolotlService.java index f8426f42e..9098a1035 100644 --- a/app/src/main/java/im/conversations/android/axolotl/AxolotlService.java +++ b/app/src/main/java/im/conversations/android/axolotl/AxolotlService.java @@ -2,6 +2,10 @@ package im.conversations.android.axolotl; import android.os.Build; import com.google.common.base.Optional; +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.ImmutableMultimap; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Multimap; import eu.siacs.conversations.xmpp.jingle.OmemoVerification; import im.conversations.android.AbstractAccountService; import im.conversations.android.database.AxolotlDatabaseStore; @@ -14,6 +18,8 @@ import im.conversations.android.xmpp.model.axolotl.Payload; import java.security.InvalidAlgorithmParameterException; import java.security.NoSuchAlgorithmException; import java.security.NoSuchProviderException; +import java.util.HashSet; +import java.util.Set; import javax.crypto.BadPaddingException; import javax.crypto.Cipher; import javax.crypto.IllegalBlockSizeException; @@ -21,6 +27,7 @@ import javax.crypto.NoSuchPaddingException; import javax.crypto.SecretKey; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.SecretKeySpec; +import org.jxmpp.jid.BareJid; import org.jxmpp.jid.Jid; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -49,12 +56,21 @@ public class AxolotlService extends AbstractAccountService { private final SignalProtocolStore signalProtocolStore; + private PostDecryptionHook postDecryptionHook; + + private final Set freshSessions = new HashSet<>(); + private final Multimap devicesNotInPep = ArrayListMultimap.create(); + public AxolotlService( final Account account, final ConversationsDatabase conversationsDatabase) { super(account, conversationsDatabase); this.signalProtocolStore = new AxolotlDatabaseStore(account, conversationsDatabase); } + public void setPostDecryptionHook(final PostDecryptionHook postDecryptionHook) { + this.postDecryptionHook = postDecryptionHook; + } + private AxolotlSession buildReceivingSession( final Jid from, final IdentityKey identityKey, final Header header) { final Optional sid = header.getSourceDevice(); @@ -88,8 +104,9 @@ public class AxolotlService extends AbstractAccountService { public AxolotlPayload decrypt(final Jid from, final Encrypted encrypted) throws AxolotlDecryptionException { + final AxolotlPayload axolotlPayload; try { - return decryptOrThrow(from, encrypted); + axolotlPayload = decryptOrThrow(from, encrypted); } catch (final IllegalArgumentException | NotEncryptedForThisDeviceException | InvalidMessageException @@ -110,6 +127,8 @@ public class AxolotlService extends AbstractAccountService { | BadPaddingException e) { throw new AxolotlDecryptionException(e); } + registerForHook(axolotlPayload); + return axolotlPayload; } private AxolotlPayload decryptOrThrow(final Jid from, final Encrypted encrypted) @@ -151,9 +170,10 @@ public class AxolotlService extends AbstractAccountService { throw new OutdatedSenderException( "Key did not contain auth tag. Sender needs to update their OMEMO client"); } + final var inDeviceList = database.axolotlDao().hasDeviceId(account, session.axolotlAddress); if (payload == null) { return new AxolotlPayload( - session.axolotlAddress, session.identityKey, preKeyMessage, null); + session.axolotlAddress, session.identityKey, preKeyMessage, inDeviceList, null); } final byte[] key = new byte[16]; final byte[] authTag = new byte[16]; @@ -175,7 +195,44 @@ public class AxolotlService extends AbstractAccountService { System.arraycopy(authTag, 0, payloadWithAuthTag, payloadAsBytes.length, authTag.length); final byte[] decryptedPayload = cipher.doFinal(payloadWithAuthTag); return new AxolotlPayload( - session.axolotlAddress, session.identityKey, preKeyMessage, decryptedPayload); + session.axolotlAddress, + session.identityKey, + preKeyMessage, + inDeviceList, + decryptedPayload); + } + + private void registerForHook(final AxolotlPayload axolotlPayload) { + synchronized (this.freshSessions) { + if (axolotlPayload.preKeyMessage) { + this.freshSessions.add(axolotlPayload.axolotlAddress); + } + } + synchronized (this.devicesNotInPep) { + if (!axolotlPayload.inDeviceList) { + this.devicesNotInPep.put( + axolotlPayload.axolotlAddress.getJid(), + axolotlPayload.axolotlAddress.getDeviceId()); + } + } + } + + public void executePostDecryptionHook() { + final var hook = this.postDecryptionHook; + if (hook == null) { + return; + } + final Set freshSessions; + synchronized (this.freshSessions) { + freshSessions = ImmutableSet.copyOf(this.freshSessions); + this.freshSessions.clear(); + } + final Multimap devicesNotInPep; + synchronized (this.devicesNotInPep) { + devicesNotInPep = ImmutableMultimap.copyOf(this.devicesNotInPep); + } + hook.executeHook(freshSessions); + hook.executeHook(devicesNotInPep); } public SignalProtocolStore getSignalProtocolStore() { @@ -212,4 +269,10 @@ public class AxolotlService extends AbstractAccountService { super(message); } } + + public interface PostDecryptionHook { + void executeHook(final Set freshSessions); + + void executeHook(final Multimap devicesNotInPep); + } } diff --git a/app/src/main/java/im/conversations/android/database/dao/AxolotlDao.java b/app/src/main/java/im/conversations/android/database/dao/AxolotlDao.java index 7a490eb65..9a59c9f9d 100644 --- a/app/src/main/java/im/conversations/android/database/dao/AxolotlDao.java +++ b/app/src/main/java/im/conversations/android/database/dao/AxolotlDao.java @@ -6,6 +6,7 @@ import androidx.room.OnConflictStrategy; import androidx.room.Query; import androidx.room.Transaction; import com.google.common.collect.Collections2; +import im.conversations.android.axolotl.AxolotlAddress; import im.conversations.android.database.entity.AxolotlDeviceListEntity; import im.conversations.android.database.entity.AxolotlDeviceListItemEntity; import im.conversations.android.database.entity.AxolotlIdentityEntity; @@ -35,14 +36,34 @@ public abstract class AxolotlDao { @Insert protected abstract void insert(Collection entities); + @Insert(onConflict = OnConflictStrategy.IGNORE) + protected abstract void insertUnconfirmed(Collection entities); + @Transaction public void setDeviceList(Account account, BareJid from, Set deviceIds) { final var listId = insert(AxolotlDeviceListEntity.of(account.id, from)); insert( Collections2.transform( - deviceIds, deviceId -> AxolotlDeviceListItemEntity.of(listId, deviceId))); + deviceIds, + deviceId -> AxolotlDeviceListItemEntity.of(listId, deviceId, true))); } + @Transaction + public void setUnconfirmedDevices( + final Account account, final BareJid address, Set unconfirmedDeviceIds) { + final Long listId = getDeviceListId(account.id, address); + if (listId == null) { + return; + } + insertUnconfirmed( + Collections2.transform( + unconfirmedDeviceIds, + deviceId -> AxolotlDeviceListItemEntity.of(listId, deviceId, false))); + } + + @Query("SELECT id FROM axolotl_device_list WHERE accountId=:account AND address=:address") + abstract Long getDeviceListId(long account, final BareJid address); + @Query( "SELECT EXISTS(SELECT deviceId FROM axolotl_device_list JOIN axolotl_device_list_item" + " ON axolotl_device_list.id=axolotl_device_list_item.deviceListId WHERE" @@ -50,6 +71,10 @@ public abstract class AxolotlDao { public abstract boolean hasDeviceId( final long account, final BareJid address, final int deviceId); + public boolean hasDeviceId(final Account account, final AxolotlAddress axolotlAddress) { + return hasDeviceId(account.id, axolotlAddress.getJid(), axolotlAddress.getDeviceId()); + } + @Transaction public void setDeviceListError( final Account account, final BareJid address, Condition condition) { diff --git a/app/src/main/java/im/conversations/android/database/entity/AxolotlDeviceListItemEntity.java b/app/src/main/java/im/conversations/android/database/entity/AxolotlDeviceListItemEntity.java index b2789eb25..2a2b9c6bd 100644 --- a/app/src/main/java/im/conversations/android/database/entity/AxolotlDeviceListItemEntity.java +++ b/app/src/main/java/im/conversations/android/database/entity/AxolotlDeviceListItemEntity.java @@ -29,10 +29,14 @@ public class AxolotlDeviceListItemEntity { public Integer deviceId; - public static AxolotlDeviceListItemEntity of(final long deviceListId, final int deviceId) { + public boolean confirmedInPep; + + public static AxolotlDeviceListItemEntity of( + final long deviceListId, final int deviceId, final boolean confirmedInPep) { final var entity = new AxolotlDeviceListItemEntity(); entity.deviceListId = deviceListId; entity.deviceId = deviceId; + entity.confirmedInPep = confirmedInPep; return entity; } } 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 ed63d315e..13f97ff8e 100644 --- a/app/src/main/java/im/conversations/android/transformer/Transformer.java +++ b/app/src/main/java/im/conversations/android/transformer/Transformer.java @@ -47,7 +47,12 @@ public class Transformer { } public boolean transform(final MessageTransformation transformation) { - return database.runInTransaction(() -> transform(database, transformation)); + return database.runInTransaction( + () -> { + final var sendDeliveryReceipts = transform(database, transformation); + axolotlService.executePostDecryptionHook(); + return sendDeliveryReceipts; + }); } /** diff --git a/app/src/main/java/im/conversations/android/xmpp/manager/AxolotlManager.java b/app/src/main/java/im/conversations/android/xmpp/manager/AxolotlManager.java index 079096c30..84f675bf3 100644 --- a/app/src/main/java/im/conversations/android/xmpp/manager/AxolotlManager.java +++ b/app/src/main/java/im/conversations/android/xmpp/manager/AxolotlManager.java @@ -2,9 +2,12 @@ package im.conversations.android.xmpp.manager; import android.content.Context; import androidx.annotation.NonNull; +import com.google.common.base.Function; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.ImmutableSet; +import com.google.common.collect.Multimap; +import com.google.common.collect.Sets; import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; @@ -51,7 +54,7 @@ import org.whispersystems.libsignal.state.SignalProtocolStore; import org.whispersystems.libsignal.state.SignedPreKeyRecord; import org.whispersystems.libsignal.util.KeyHelper; -public class AxolotlManager extends AbstractManager { +public class AxolotlManager extends AbstractManager implements AxolotlService.PostDecryptionHook { private static final Logger LOGGER = LoggerFactory.getLogger(AxolotlManager.class); @@ -64,6 +67,7 @@ public class AxolotlManager extends AbstractManager { this.axolotlService = new AxolotlService( connection.getAccount(), ConversationsDatabase.getInstance(context)); + this.axolotlService.setPostDecryptionHook(this); } public AxolotlService getAxolotlService() { @@ -301,7 +305,6 @@ public class AxolotlManager extends AbstractManager { signedPreKeyRecord.getKeyPair().getPublicKey(), signedPreKeyRecord.getSignature()); bundle.addPreKeys(getDatabase().axolotlDao().getPreKeys(getAccount().id)); - LOGGER.info("bundle {}", bundle); return bundle; } @@ -492,4 +495,63 @@ public class AxolotlManager extends AbstractManager { private SignalProtocolStore signalProtocolStore() { return this.axolotlService.getSignalProtocolStore(); } + + @Override + public void executeHook(final Set freshSessions) { + for (final AxolotlAddress axolotlAddress : freshSessions) { + LOGGER.info( + "fresh session from {}/{}", + axolotlAddress.getJid(), + axolotlAddress.getDeviceId()); + } + } + + @Override + public void executeHook(Multimap devicesNotInPep) { + for (final Map.Entry> entries : + devicesNotInPep.asMap().entrySet()) { + if (entries.getValue().isEmpty()) { + continue; + } + // Warning. This will leak our resource to anyone who knows our jid + device id + // TODO we could limit this to addresses in our roster; however the point of this + // exercise is mostly to improve reliability with people not in our roster + confirmDeviceInPep(entries.getKey(), ImmutableSet.copyOf(entries.getValue())); + } + } + + private void confirmDeviceInPep(final BareJid address, final Set devices) { + final var deviceListFuture = this.fetchDeviceIds(address); + final var caughtDeviceListFuture = + Futures.catching( + deviceListFuture, + Exception.class, + (Function>) input -> Collections.emptySet(), + MoreExecutors.directExecutor()); + Futures.addCallback( + caughtDeviceListFuture, + new FutureCallback<>() { + @Override + public void onSuccess(final Set devicesInPep) { + final Set unconfirmedDevices = + Sets.difference(devices, devicesInPep); + if (unconfirmedDevices.isEmpty()) { + return; + } + LOGGER.info( + "Found unconfirmed devices for {}: {}", + address, + unconfirmedDevices); + getDatabase() + .axolotlDao() + .setUnconfirmedDevices(getAccount(), address, unconfirmedDevices); + } + + @Override + public void onFailure(@NonNull Throwable throwable) { + LOGGER.error("Could not confirm device list for {}", address, throwable); + } + }, + getDatabase().getQueryExecutor()); + } } diff --git a/app/src/main/java/im/conversations/android/xmpp/processor/MessageProcessor.java b/app/src/main/java/im/conversations/android/xmpp/processor/MessageProcessor.java index eebf86c5b..3b2e5af10 100644 --- a/app/src/main/java/im/conversations/android/xmpp/processor/MessageProcessor.java +++ b/app/src/main/java/im/conversations/android/xmpp/processor/MessageProcessor.java @@ -70,7 +70,7 @@ public class MessageProcessor extends XmppConnection.Delegate implements Consume return; } - LOGGER.info( + LOGGER.debug( "Message from {} with {} in level {}", message.getFrom(), message.getExtensionIds(),