Scott Conway

Information Security Researcher

Client-side Trust in Private Messaging Services

I’ve been an avid user of Signal for almost five years now. It’s a fantastic messaging platform, and brings end-to-end encryption to people that otherwise would not seek it out in a seamless fashion. A few years ago, Signal started to introduce client-side privacy features, including disappearing messages, view-once media, and remote message deletion. One commonality between these features is that the sender simply requests that the recipients deal with the message in a certain matter. If it’s a disappearing message, the recipients should delete the message after the configured amount of time. If it’s view-once media, it should be deleted after it has been been viewed by a given recipient. This sounds great in practice, but it’s not as if the recipients can be forced to heed the sender’s requests. Other messaging services (see: Snapchat) have monolithic terms of service that prohibit a user from using a 3rd-party client, and use obfuscation techniques to make it difficult to reverse engineer their applications. Although weaknesses can certainly be exploited in these applications to mitigate client-side privacy controls, most want to make it difficult for the “attacker”. With Signal and other FOSS messaging applications however, there can be no such enforcements. Everyone has access to the source code, and may fork it at their leisure. So, I did.

In actuality I ended up forking Molly, a fork of Signal that focuses on device hardening. My intent was simple — I wanted to find a way to ignore the above client-side privacy features as a recipient (and not as a sender). In total, this only required 10 lines of changes, and it built on my first attempt. Functionality on this client was as you’d expect. If a “disappearing message” came in, the client simply ignored the timer attribute. Similarly, view-once media appears as regular media, and remote deletion requests do nothing whatsoever. Here’s the diff:

diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java
index f677feca5..db830fcc7 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/MmsDatabase.java
@@ -836,6 +836,8 @@ public class MmsDatabase extends MessageDatabase {
 
   @Override
   public void markAsRemoteDelete(long messageId) {
+    // Don't honor remote delete requests
+    /*
     SQLiteDatabase db = databaseHelper.getSignalWritableDatabase();
 
     long threadId;
@@ -864,6 +866,7 @@ public class MmsDatabase extends MessageDatabase {
     } finally {
       db.endTransaction();
     }
+    */
     ApplicationDependencies.getDatabaseObserver().notifyMessageUpdateObservers(new MessageId(messageId, true));
   }
 
@@ -1323,8 +1326,8 @@ public class MmsDatabase extends MessageDatabase {
     contentValues.put(DATE_RECEIVED, retrieved.isPushMessage() ? retrieved.getReceivedTimeMillis() : generatePduCompatTimestamp(retrieved.getReceivedTimeMillis()));
     contentValues.put(PART_COUNT, retrieved.getAttachments().size());
     contentValues.put(SUBSCRIPTION_ID, retrieved.getSubscriptionId());
-    contentValues.put(EXPIRES_IN, retrieved.getExpiresIn());
-    contentValues.put(VIEW_ONCE, retrieved.isViewOnce() ? 1 : 0);
+    contentValues.put(EXPIRES_IN, 0);   // Don't honor disappearing messages
+    contentValues.put(VIEW_ONCE, 0);    // Don't honor view-once media
     contentValues.put(READ, retrieved.isExpirationUpdate() ? 1 : 0);
     contentValues.put(UNIDENTIFIED, retrieved.isUnidentified());
     contentValues.put(SERVER_GUID, retrieved.getServerGuid());
diff --git a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java
index 3318ec6d5..af41ec39f 100644
--- a/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java
+++ b/app/src/main/java/org/thoughtcrime/securesms/database/SmsDatabase.java
@@ -381,6 +381,8 @@ public class SmsDatabase extends MessageDatabase {
 
   @Override
   public void markAsRemoteDelete(long id) {
+    // Don't honor remote delete requests
+    /*
     SQLiteDatabase db = databaseHelper.getSignalWritableDatabase();
 
     long threadId;
@@ -402,7 +404,8 @@ public class SmsDatabase extends MessageDatabase {
     } finally {
       db.endTransaction();
     }
-    notifyConversationListeners(threadId);
+    */
+    notifyConversationListeners(getThreadIdForMessage(id));
   }
 
   @Override
@@ -692,7 +695,8 @@ public class SmsDatabase extends MessageDatabase {
 
   @Override
   public @NonNull Pair<Long, Long> insertReceivedCall(@NonNull RecipientId address, long expiresIn, boolean isVideoOffer) {
-    return insertCallLog(address, isVideoOffer ? Types.INCOMING_VIDEO_CALL_TYPE : Types.INCOMING_AUDIO_CALL_TYPE, expiresIn, false, System.currentTimeMillis());
+    // Don't honor disappearing timer for incoming calls
+    return insertCallLog(address, isVideoOffer ? Types.INCOMING_VIDEO_CALL_TYPE : Types.INCOMING_AUDIO_CALL_TYPE, 0, false, System.currentTimeMillis());
   }
 
   @Override
@@ -702,7 +706,8 @@ public class SmsDatabase extends MessageDatabase {
 
   @Override
   public @NonNull Pair<Long, Long> insertMissedCall(@NonNull RecipientId address, long expiresIn, long timestamp, boolean isVideoOffer) {
-    return insertCallLog(address, isVideoOffer ? Types.MISSED_VIDEO_CALL_TYPE : Types.MISSED_AUDIO_CALL_TYPE, expiresIn, true, timestamp);
+    // Don't honor disappearing timer for missed calls
+    return insertCallLog(address, isVideoOffer ? Types.MISSED_VIDEO_CALL_TYPE : Types.MISSED_AUDIO_CALL_TYPE, 0, true, timestamp);
   }
 
   @Override
@@ -1160,7 +1165,7 @@ public class SmsDatabase extends MessageDatabase {
     values.put(PROTOCOL, message.getProtocol());
     values.put(READ, unread ? 0 : 1);
     values.put(SUBSCRIPTION_ID, message.getSubscriptionId());
-    values.put(EXPIRES_IN, message.getExpiresIn());
+    values.put(EXPIRES_IN, 0);  // Don't honor incoming disappearing messages
     values.put(UNIDENTIFIED, message.isUnidentified());
 
     if (!TextUtils.isEmpty(message.getPseudoSubject()))

Just to make this a bit harder for anyone looking to (ab)use an “evil client”, I won’t be releasing a build of these changes. Build it yourself, creep.

It’s worth noting that you wouldn’t have to re-register a Signal client to achieve this functionality. You could install Signal and an evil fork side-by-side, and copy Signal’s databases into the evil fork’s files, causing it to gain ownership of the Signal account without causing the account’s Safety Number to change. This would effectively allow a verified account to “become evil” without their safety number changing. Obviously, this requires some level of access to the mobile device running Signal, but is easily achievable on Android if the “attacker” has either root access or access to developer settings and adb.

Last, I don’t mean to discredit these or any other client-side privacy features - they’re great, and have a variety of use-cases for many users. However, as I’ve hopefully shown in this post, creating a client that simply ignores these rules is a trivial matter. When sending any message over any medium, you should assume that it will be recorded forever. If that’s a bit much for you to take in, you must have absolute trust in your conversation partners (and their messaging devices) to follow your privacy requests.