Skip to content

Commit fd8e29d

Browse files
committed
add ability for the app to detect e2e encrypted messages
1 parent b75c362 commit fd8e29d

13 files changed

Lines changed: 260 additions & 24 deletions

File tree

android/app/src/main/java/com/httpsms/Constants.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ class Constants {
99
const val KEY_MESSAGE_CONTENT = "KEY_MESSAGE_CONTENT"
1010
const val KEY_MESSAGE_TIMESTAMP = "KEY_MESSAGE_TIMESTAMP"
1111
const val KEY_MESSAGE_REASON = "KEY_MESSAGE_REASON"
12+
const val KEY_MESSAGE_ENCRYPTED = "KEY_MESSAGE_ENCRYPTED"
1213

1314

1415
const val KEY_HEARTBEAT_ID = "KEY_HEARTBEAT_ID"
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.httpsms
2+
3+
import timber.log.Timber
4+
import java.security.MessageDigest
5+
import java.util.Base64
6+
import java.util.Random
7+
import javax.crypto.Cipher
8+
import javax.crypto.spec.IvParameterSpec
9+
import javax.crypto.spec.SecretKeySpec
10+
11+
object Encrypter {
12+
private const val ALGORITHM = "AES/CFB/NoPadding"
13+
private const val IV_SIZE = 16
14+
15+
fun decrypt(key: String, cipherText: String): String {
16+
val cipher = Cipher.getInstance(ALGORITHM)
17+
val cipherBytes = Base64.getDecoder().decode(cipherText)
18+
Timber.d("iv = ${Base64.getEncoder().encodeToString(cipherBytes.take(IV_SIZE).toByteArray())}")
19+
Timber.d("cipher = ${Base64.getEncoder().encodeToString(cipherBytes.drop(IV_SIZE).toByteArray())}")
20+
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(hash(key), "AES"), IvParameterSpec(cipherBytes.take(IV_SIZE).toByteArray()))
21+
val plainText = cipher.doFinal(cipherBytes.drop(IV_SIZE).toByteArray())
22+
return String(plainText)
23+
}
24+
25+
fun encrypt(key: String, inputText: String): String {
26+
val cipher = Cipher.getInstance(ALGORITHM)
27+
val iv = generateIv()
28+
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(hash(key),"AES"), IvParameterSpec(iv))
29+
val cipherBytes = iv + cipher.doFinal(inputText.toByteArray())
30+
return Base64.getEncoder().encodeToString(cipherBytes)
31+
}
32+
33+
private fun generateIv(): ByteArray {
34+
val b = ByteArray(IV_SIZE)
35+
Random().nextBytes(b)
36+
return b
37+
}
38+
39+
private fun hash(key: String): ByteArray {
40+
val bytes = key.toByteArray()
41+
val md = MessageDigest.getInstance("SHA-256")
42+
return md.digest(bytes)
43+
}
44+
}

android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,13 @@
11
package com.httpsms
22

3+
import android.app.Application
34
import android.app.PendingIntent
45
import android.content.Context
56
import android.content.Intent
67
import androidx.work.*
78
import com.google.firebase.messaging.FirebaseMessagingService
89
import com.google.firebase.messaging.RemoteMessage
910
import timber.log.Timber
10-
import java.time.ZoneOffset
11-
import java.time.ZonedDateTime
1211

1312
class MyFirebaseMessagingService : FirebaseMessagingService() {
1413
// [START receive_message]
@@ -133,14 +132,29 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
133132
val message = getMessage(applicationContext, messageID) ?: return Result.failure()
134133
if (!Settings.getActiveStatus(applicationContext, message.sim)) {
135134
Timber.w("[${message.sim}] SIM is not active, stopping processing")
136-
handleFailed(applicationContext, messageID)
135+
handleFailed(applicationContext, messageID, "Outgoing messages have been disabled on the mobile app")
137136
return Result.failure()
138137
}
139138

139+
if (message.encrypted && Settings.getEncryptionKey(applicationContext).isNullOrEmpty()) {
140+
Timber.w("[${message.sim}] message is encrypted but the encryption key is empty")
141+
handleFailed(applicationContext, messageID, "Outgoing message is encrypted but mobile app has no encryption key")
142+
return Result.failure()
143+
}
144+
if (message.encrypted) {
145+
try {
146+
Encrypter.decrypt(Settings.getEncryptionKey(applicationContext)!!, message.content)
147+
} catch (exception: Exception) {
148+
Timber.e(exception)
149+
handleFailed(applicationContext, messageID, "Cannot decrypt the outgoing message. Check your encryption key on the Android app.")
150+
return Result.failure()
151+
}
152+
}
153+
140154
Receiver.register(applicationContext)
141155
val parts = getMessageParts(applicationContext, message)
142156
if (parts.size == 1) {
143-
return handleSingleMessage(message)
157+
return handleSingleMessage(message, parts.first())
144158
}
145159
return handleMultipartMessage(message, parts)
146160
}
@@ -174,16 +188,17 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
174188
}
175189

176190

177-
private fun handleSingleMessage(message:Message): Result {
191+
private fun handleSingleMessage(message:Message, content: String): Result {
178192
sendMessage(
179193
message,
194+
content,
180195
createPendingIntent(message.id, SmsManagerService.sentAction()),
181196
createPendingIntent(message.id, SmsManagerService.deliveredAction())
182197
)
183198
return Result.success()
184199
}
185200

186-
private fun handleFailed(context: Context, messageID: String) {
201+
private fun handleFailed(context: Context, messageID: String, reason: String) {
187202
Timber.d("sending [FAILED] event for message with ID [${messageID}]")
188203

189204
val constraints = Constraints.Builder()
@@ -192,7 +207,7 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
192207

193208
val inputData: Data = workDataOf(
194209
Constants.KEY_MESSAGE_ID to messageID,
195-
Constants.KEY_MESSAGE_REASON to "MOBILE_APP_INACTIVE",
210+
Constants.KEY_MESSAGE_REASON to reason,
196211
Constants.KEY_MESSAGE_TIMESTAMP to Settings.currentTimestamp()
197212
)
198213

@@ -222,10 +237,10 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
222237
return null
223238
}
224239

225-
private fun sendMessage(message: Message, sentIntent: PendingIntent, deliveredIntent: PendingIntent) {
240+
private fun sendMessage(message: Message, content: String, sentIntent: PendingIntent, deliveredIntent: PendingIntent) {
226241
Timber.d("sending SMS for message with ID [${message.id}]")
227242
try {
228-
SmsManagerService().sendTextMessage(this.applicationContext,message.contact, message.content, message.sim, sentIntent, deliveredIntent)
243+
SmsManagerService().sendTextMessage(this.applicationContext,message.contact, content, message.sim, sentIntent, deliveredIntent)
229244
} catch (e: Exception) {
230245
Timber.e(e)
231246
Timber.d("could not send SMS for message with ID [${message.id}]")
@@ -236,15 +251,22 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
236251

237252
private fun getMessageParts(context: Context, message: Message): ArrayList<String> {
238253
Timber.d("getting parts for message with ID [${message.id}]")
254+
255+
var messageBody = message.content
256+
val encryptionKey = Settings.getEncryptionKey(context)
257+
if (message.encrypted && !encryptionKey.isNullOrEmpty()) {
258+
messageBody = Encrypter.decrypt(encryptionKey, messageBody)
259+
}
260+
239261
return try {
240-
val parts = SmsManagerService().messageParts(context, message.content)
262+
val parts = SmsManagerService().messageParts(context, messageBody)
241263
Timber.d("message with ID [${message.id}] has [${parts.size}] parts")
242264
parts
243265
} catch (e: Exception) {
244266
Timber.e(e)
245267
Timber.d("could not get parts message with ID [${message.id}] returning [1] part with entire content")
246268
val list = ArrayList<String>()
247-
list.add(message.content)
269+
list.add(messageBody)
248270
list
249271
}
250272
}

android/app/src/main/java/com/httpsms/HttpSmsApiService.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,13 +71,14 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) {
7171
return sendEvent(messageId, "FAILED", timestamp, reason)
7272
}
7373

74-
fun receive(sim: String, from: String, to: String, content: String, timestamp: String): Boolean {
74+
fun receive(sim: String, from: String, to: String, content: String, encrypted: Boolean, timestamp: String): Boolean {
7575
val body = """
7676
{
7777
"content": "${StringEscapeUtils.escapeJson(content)}",
7878
"sim": "$sim",
7979
"from": "$from",
8080
"timestamp": "$timestamp",
81+
"encrypted": $encrypted,
8182
"to": "$to"
8283
}
8384
""".trimIndent()

android/app/src/main/java/com/httpsms/Models.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ data class Message (
5353
@Json(name = "received_at")
5454
val receivedAt: String?,
5555

56+
val encrypted: Boolean,
57+
5658
@Json(name = "request_received_at")
5759
val requestReceivedAt: String,
5860

android/app/src/main/java/com/httpsms/ReceivedReceiver.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ class ReceivedReceiver: BroadcastReceiver()
6969
return
7070
}
7171

72+
var body = content;
73+
if (Settings.encryptReceivedMessages(context)) {
74+
body = Encrypter.encrypt(Settings.getEncryptionKey(context)!!, content)
75+
}
76+
7277
val constraints = Constraints.Builder()
7378
.setRequiredNetworkType(NetworkType.CONNECTED)
7479
.build()
@@ -77,7 +82,8 @@ class ReceivedReceiver: BroadcastReceiver()
7782
Constants.KEY_MESSAGE_FROM to from,
7883
Constants.KEY_MESSAGE_TO to to,
7984
Constants.KEY_MESSAGE_SIM to sim,
80-
Constants.KEY_MESSAGE_CONTENT to content,
85+
Constants.KEY_MESSAGE_CONTENT to body,
86+
Constants.KEY_MESSAGE_ENCRYPTED to Settings.encryptReceivedMessages(context),
8187
Constants.KEY_MESSAGE_TIMESTAMP to DateTimeFormatter.ofPattern(Constants.TIMESTAMP_PATTERN).format(timestamp).replace("+", "Z")
8288
)
8389

@@ -103,6 +109,7 @@ class ReceivedReceiver: BroadcastReceiver()
103109
this.inputData.getString(Constants.KEY_MESSAGE_FROM)!!,
104110
this.inputData.getString(Constants.KEY_MESSAGE_TO)!!,
105111
this.inputData.getString(Constants.KEY_MESSAGE_CONTENT)!!,
112+
this.inputData.getBoolean(Constants.KEY_MESSAGE_ENCRYPTED, false),
106113
this.inputData.getString(Constants.KEY_MESSAGE_TIMESTAMP)!!,
107114
)) {
108115
return Result.success()

android/app/src/main/java/com/httpsms/Settings.kt

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ object Settings {
2222
private const val SETTINGS_USER_ID = "SETTINGS_USER_ID"
2323
private const val SETTINGS_FCM_TOKEN_UPDATE_TIMESTAMP = "SETTINGS_FCM_TOKEN_UPDATE_TIMESTAMP"
2424
private const val SETTINGS_HEARTBEAT_TIMESTAMP = "SETTINGS_HEARTBEAT_TIMESTAMP"
25+
private const val SETTINGS_ENCRYPTION_KEY = "SETTINGS_ENCRYPTION_KEY"
26+
private const val SETTINGS_ENCRYPT_RECEIVED_MESSAGES = "SETTINGS_ENCRYPT_RECEIVED_MESSAGES"
2527

2628
fun getSIM1PhoneNumber(context: Context): String {
2729
Timber.d(Settings::getSIM1PhoneNumber.name)
@@ -120,6 +122,43 @@ object Settings {
120122
.apply()
121123
}
122124

125+
fun setEncryptReceivedMessages(context: Context, status: Boolean) {
126+
Timber.d(Settings::setEncryptReceivedMessages.name)
127+
128+
PreferenceManager.getDefaultSharedPreferences(context)
129+
.edit()
130+
.putBoolean(this.SETTINGS_ENCRYPT_RECEIVED_MESSAGES, status)
131+
.apply()
132+
}
133+
134+
fun encryptReceivedMessages(context: Context): Boolean {
135+
Timber.d(Settings::encryptReceivedMessages.name)
136+
137+
val encryptReceivedMessages = PreferenceManager
138+
.getDefaultSharedPreferences(context)
139+
.getBoolean(this.SETTINGS_ENCRYPT_RECEIVED_MESSAGES,false)
140+
141+
Timber.d("SETTINGS_ENCRYPT_RECEIVED_MESSAGES: [$encryptReceivedMessages]")
142+
return encryptReceivedMessages && !getEncryptionKey(context).isNullOrEmpty()
143+
}
144+
145+
fun setEncryptionKey(context: Context, key: String?) {
146+
Timber.d(Settings::setEncryptionKey.name)
147+
148+
PreferenceManager.getDefaultSharedPreferences(context)
149+
.edit()
150+
.putString(this.SETTINGS_ENCRYPTION_KEY, key)
151+
.apply()
152+
}
153+
154+
fun getEncryptionKey(context: Context): String? {
155+
Timber.d(Settings::getEncryptionKey.name)
156+
157+
return PreferenceManager
158+
.getDefaultSharedPreferences(context)
159+
.getString(this.SETTINGS_ENCRYPTION_KEY, "")
160+
}
161+
123162
fun setIncomingActiveSIM2(context: Context, status: Boolean) {
124163
Timber.d(Settings::setIncomingActiveSIM2.name)
125164

android/app/src/main/java/com/httpsms/SettingsActivity.kt

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.content.Context
44
import android.content.Intent
55
import android.os.Bundle
66
import androidx.appcompat.app.AppCompatActivity
7+
import androidx.core.widget.doAfterTextChanged
78
import com.google.android.material.appbar.MaterialToolbar
89
import com.google.android.material.button.MaterialButton
910
import com.google.android.material.dialog.MaterialAlertDialogBuilder
@@ -55,6 +56,37 @@ class SettingsActivity : AppCompatActivity() {
5556
val sim2OutgoingMessages = findViewById<SwitchMaterial>(R.id.settings_sim2_outgoing_messages)
5657
sim2OutgoingMessages.isChecked = Settings.getActiveStatus(context, Constants.SIM2)
5758
sim2OutgoingMessages.setOnCheckedChangeListener{ _, isChecked -> run { Settings.setActiveStatusAsync(context, isChecked, Constants.SIM2) } }
59+
60+
handleEncryptionSettings(context)
61+
}
62+
63+
private fun handleEncryptionSettings(context: Context) {
64+
val encryptionKey = findViewById<TextInputEditText>(R.id.settingsEncryptionKeyInputEdit)
65+
val encryptReceivedMessages = findViewById<SwitchMaterial>(R.id.settingsEncryptReceivedMessages)
66+
67+
val key = Settings.getEncryptionKey(context)
68+
if(key.isNullOrEmpty()) {
69+
encryptReceivedMessages.isEnabled = false
70+
} else {
71+
encryptionKey.setText(key.trim())
72+
}
73+
74+
encryptionKey.doAfterTextChanged{
75+
if (it == null || it.toString().isEmpty()) {
76+
Settings.setEncryptionKey(context, null)
77+
Settings.setEncryptReceivedMessages(context, false)
78+
encryptReceivedMessages.isChecked = false
79+
encryptReceivedMessages.isEnabled = false
80+
} else {
81+
encryptReceivedMessages.isEnabled = true
82+
Settings.setEncryptionKey(context, it.toString().trim())
83+
}
84+
}
85+
86+
encryptReceivedMessages.isChecked = Settings.encryptReceivedMessages(context)
87+
encryptReceivedMessages.setOnCheckedChangeListener{ _, isChecked -> run {
88+
Settings.setEncryptReceivedMessages(context, isChecked)
89+
}}
5890
}
5991

6092
private fun registerListeners() {
@@ -67,7 +99,6 @@ class SettingsActivity : AppCompatActivity() {
6799
redirectToMain()
68100
}
69101

70-
71102
private fun redirectToMain() {
72103
finish()
73104
val switchActivityIntent = Intent(this, MainActivity::class.java)
@@ -94,6 +125,8 @@ class SettingsActivity : AppCompatActivity() {
94125
Settings.setIncomingActiveSIM1(this, true)
95126
Settings.setIncomingActiveSIM2(this, true)
96127
Settings.setUserID(this, null)
128+
Settings.setEncryptionKey(this, null)
129+
Settings.setEncryptReceivedMessages(this, false)
97130
Settings.setFcmTokenLastUpdateTimestampAsync(this, 0)
98131
redirectToLogin()
99132
}

android/app/src/main/res/layout/activity_settings.xml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,34 @@
113113
android:layout_marginBottom="16dp"
114114
tools:ignore="TouchTargetSizeCheck" />
115115

116+
<com.google.android.material.textfield.TextInputLayout
117+
android:id="@+id/settingsEncryptionKeyLayout"
118+
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
119+
android:layout_width="match_parent"
120+
android:layout_height="wrap_content"
121+
app:errorEnabled="true"
122+
android:hint="@string/encryption_key"
123+
app:layout_constraintTop_toTopOf="parent"
124+
tools:layout_editor_absoluteX="16dp">
125+
126+
<com.google.android.material.textfield.TextInputEditText
127+
android:id="@+id/settingsEncryptionKeyInputEdit"
128+
android:layout_width="match_parent"
129+
android:layout_height="wrap_content"
130+
android:inputType="textMultiLine"
131+
tools:ignore="TextContrastCheck" />
132+
133+
</com.google.android.material.textfield.TextInputLayout>
116134

135+
<com.google.android.material.switchmaterial.SwitchMaterial
136+
android:id="@+id/settingsEncryptReceivedMessages"
137+
android:layout_width="match_parent"
138+
android:layout_height="wrap_content"
139+
android:textSize="18sp"
140+
android:text="@string/encrypt_received_messages"
141+
android:minHeight="48dp"
142+
android:layout_marginBottom="16dp"
143+
tools:ignore="TouchTargetSizeCheck" />
117144
</LinearLayout>
118145

119146
<com.google.android.material.button.MaterialButton

android/app/src/main/res/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,6 @@
2626
<string name="settings_outgoing_messages_sim2">Enable Outgoing Messages (SIM2)</string>
2727
<string name="login_phone_number_sim1">Phone Number (SIM1)</string>
2828
<string name="login_phone_number_sim2">Phone Number (SIM2)</string>
29+
<string name="encryption_key">Encryption Key</string>
30+
<string name="encrypt_received_messages">Encrypt Received Messages</string>
2931
</resources>

0 commit comments

Comments
 (0)