Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,12 @@ works best for you:

## Features

### End-to-end Encryption

You can encrypt your messages end-to-end ysubg the military grade [AES-256 encryption](https://en.wikipedia.org/wiki/Advanced_Encryption_Standard)
algorithm. Your encryption key is stored only on our mobile phone so the even the server won't have any way to view the
content of your SMS messages which are sent and received on your Android phone.

### Webhook

If you want to build advanced integrations, we support webhooks. The httpSMS platform can forward SMS messages received
Expand Down
1 change: 1 addition & 0 deletions android/app/src/main/java/com/httpsms/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ class Constants {
const val KEY_MESSAGE_CONTENT = "KEY_MESSAGE_CONTENT"
const val KEY_MESSAGE_TIMESTAMP = "KEY_MESSAGE_TIMESTAMP"
const val KEY_MESSAGE_REASON = "KEY_MESSAGE_REASON"
const val KEY_MESSAGE_ENCRYPTED = "KEY_MESSAGE_ENCRYPTED"


const val KEY_HEARTBEAT_ID = "KEY_HEARTBEAT_ID"
Expand Down
44 changes: 44 additions & 0 deletions android/app/src/main/java/com/httpsms/Encrypter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.httpsms

import timber.log.Timber
import java.security.MessageDigest
import java.util.Base64
import java.util.Random
import javax.crypto.Cipher
import javax.crypto.spec.IvParameterSpec
import javax.crypto.spec.SecretKeySpec

object Encrypter {
private const val ALGORITHM = "AES/CFB/NoPadding"
private const val IV_SIZE = 16

fun decrypt(key: String, cipherText: String): String {
val cipher = Cipher.getInstance(ALGORITHM)
val cipherBytes = Base64.getDecoder().decode(cipherText)
Timber.d("iv = ${Base64.getEncoder().encodeToString(cipherBytes.take(IV_SIZE).toByteArray())}")
Timber.d("cipher = ${Base64.getEncoder().encodeToString(cipherBytes.drop(IV_SIZE).toByteArray())}")
cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(hash(key), "AES"), IvParameterSpec(cipherBytes.take(IV_SIZE).toByteArray()))
val plainText = cipher.doFinal(cipherBytes.drop(IV_SIZE).toByteArray())
return String(plainText)
}

fun encrypt(key: String, inputText: String): String {
val cipher = Cipher.getInstance(ALGORITHM)
val iv = generateIv()
cipher.init(Cipher.ENCRYPT_MODE, SecretKeySpec(hash(key),"AES"), IvParameterSpec(iv))
val cipherBytes = iv + cipher.doFinal(inputText.toByteArray())
return Base64.getEncoder().encodeToString(cipherBytes)
}

private fun generateIv(): ByteArray {
val b = ByteArray(IV_SIZE)
Random().nextBytes(b)
return b
}

private fun hash(key: String): ByteArray {
val bytes = key.toByteArray()
val md = MessageDigest.getInstance("SHA-256")
return md.digest(bytes)
}
}
44 changes: 33 additions & 11 deletions android/app/src/main/java/com/httpsms/FirebaseMessagingService.kt
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
package com.httpsms

import android.app.Application
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import androidx.work.*
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import timber.log.Timber
import java.time.ZoneOffset
import java.time.ZonedDateTime

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

if (message.encrypted && Settings.getEncryptionKey(applicationContext).isNullOrEmpty()) {
Timber.w("[${message.sim}] message is encrypted but the encryption key is empty")
handleFailed(applicationContext, messageID, "Outgoing message is encrypted but mobile app has no encryption key")
return Result.failure()
}
if (message.encrypted) {
try {
Encrypter.decrypt(Settings.getEncryptionKey(applicationContext)!!, message.content)
} catch (exception: Exception) {
Timber.e(exception)
handleFailed(applicationContext, messageID, "Cannot decrypt the outgoing message. Check your encryption key on the Android app.")
return Result.failure()
}
}

Receiver.register(applicationContext)
val parts = getMessageParts(applicationContext, message)
if (parts.size == 1) {
return handleSingleMessage(message)
return handleSingleMessage(message, parts.first())
}
return handleMultipartMessage(message, parts)
}
Expand Down Expand Up @@ -174,16 +188,17 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
}


private fun handleSingleMessage(message:Message): Result {
private fun handleSingleMessage(message:Message, content: String): Result {
sendMessage(
message,
content,
createPendingIntent(message.id, SmsManagerService.sentAction()),
createPendingIntent(message.id, SmsManagerService.deliveredAction())
)
return Result.success()
}

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

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

val inputData: Data = workDataOf(
Constants.KEY_MESSAGE_ID to messageID,
Constants.KEY_MESSAGE_REASON to "MOBILE_APP_INACTIVE",
Constants.KEY_MESSAGE_REASON to reason,
Constants.KEY_MESSAGE_TIMESTAMP to Settings.currentTimestamp()
)

Expand Down Expand Up @@ -222,10 +237,10 @@ class MyFirebaseMessagingService : FirebaseMessagingService() {
return null
}

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

private fun getMessageParts(context: Context, message: Message): ArrayList<String> {
Timber.d("getting parts for message with ID [${message.id}]")

var messageBody = message.content
val encryptionKey = Settings.getEncryptionKey(context)
if (message.encrypted && !encryptionKey.isNullOrEmpty()) {
messageBody = Encrypter.decrypt(encryptionKey, messageBody)
}

return try {
val parts = SmsManagerService().messageParts(context, message.content)
val parts = SmsManagerService().messageParts(context, messageBody)
Timber.d("message with ID [${message.id}] has [${parts.size}] parts")
parts
} catch (e: Exception) {
Timber.e(e)
Timber.d("could not get parts message with ID [${message.id}] returning [1] part with entire content")
val list = ArrayList<String>()
list.add(message.content)
list.add(messageBody)
list
}
}
Expand Down
3 changes: 2 additions & 1 deletion android/app/src/main/java/com/httpsms/HttpSmsApiService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,14 @@ class HttpSmsApiService(private val apiKey: String, private val baseURL: URI) {
return sendEvent(messageId, "FAILED", timestamp, reason)
}

fun receive(sim: String, from: String, to: String, content: String, timestamp: String): Boolean {
fun receive(sim: String, from: String, to: String, content: String, encrypted: Boolean, timestamp: String): Boolean {
val body = """
{
"content": "${StringEscapeUtils.escapeJson(content)}",
"sim": "$sim",
"from": "$from",
"timestamp": "$timestamp",
"encrypted": $encrypted,
"to": "$to"
}
""".trimIndent()
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/java/com/httpsms/Models.kt
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ data class Message (
@Json(name = "received_at")
val receivedAt: String?,

val encrypted: Boolean,

@Json(name = "request_received_at")
val requestReceivedAt: String,

Expand Down
9 changes: 8 additions & 1 deletion android/app/src/main/java/com/httpsms/ReceivedReceiver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ class ReceivedReceiver: BroadcastReceiver()
return
}

var body = content;
if (Settings.encryptReceivedMessages(context)) {
body = Encrypter.encrypt(Settings.getEncryptionKey(context)!!, content)
}

val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()
Expand All @@ -77,7 +82,8 @@ class ReceivedReceiver: BroadcastReceiver()
Constants.KEY_MESSAGE_FROM to from,
Constants.KEY_MESSAGE_TO to to,
Constants.KEY_MESSAGE_SIM to sim,
Constants.KEY_MESSAGE_CONTENT to content,
Constants.KEY_MESSAGE_CONTENT to body,
Constants.KEY_MESSAGE_ENCRYPTED to Settings.encryptReceivedMessages(context),
Constants.KEY_MESSAGE_TIMESTAMP to DateTimeFormatter.ofPattern(Constants.TIMESTAMP_PATTERN).format(timestamp).replace("+", "Z")
)

Expand All @@ -103,6 +109,7 @@ class ReceivedReceiver: BroadcastReceiver()
this.inputData.getString(Constants.KEY_MESSAGE_FROM)!!,
this.inputData.getString(Constants.KEY_MESSAGE_TO)!!,
this.inputData.getString(Constants.KEY_MESSAGE_CONTENT)!!,
this.inputData.getBoolean(Constants.KEY_MESSAGE_ENCRYPTED, false),
this.inputData.getString(Constants.KEY_MESSAGE_TIMESTAMP)!!,
)) {
return Result.success()
Expand Down
39 changes: 39 additions & 0 deletions android/app/src/main/java/com/httpsms/Settings.kt
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ object Settings {
private const val SETTINGS_USER_ID = "SETTINGS_USER_ID"
private const val SETTINGS_FCM_TOKEN_UPDATE_TIMESTAMP = "SETTINGS_FCM_TOKEN_UPDATE_TIMESTAMP"
private const val SETTINGS_HEARTBEAT_TIMESTAMP = "SETTINGS_HEARTBEAT_TIMESTAMP"
private const val SETTINGS_ENCRYPTION_KEY = "SETTINGS_ENCRYPTION_KEY"
private const val SETTINGS_ENCRYPT_RECEIVED_MESSAGES = "SETTINGS_ENCRYPT_RECEIVED_MESSAGES"

fun getSIM1PhoneNumber(context: Context): String {
Timber.d(Settings::getSIM1PhoneNumber.name)
Expand Down Expand Up @@ -120,6 +122,43 @@ object Settings {
.apply()
}

fun setEncryptReceivedMessages(context: Context, status: Boolean) {
Timber.d(Settings::setEncryptReceivedMessages.name)

PreferenceManager.getDefaultSharedPreferences(context)
.edit()
.putBoolean(this.SETTINGS_ENCRYPT_RECEIVED_MESSAGES, status)
.apply()
}

fun encryptReceivedMessages(context: Context): Boolean {
Timber.d(Settings::encryptReceivedMessages.name)

val encryptReceivedMessages = PreferenceManager
.getDefaultSharedPreferences(context)
.getBoolean(this.SETTINGS_ENCRYPT_RECEIVED_MESSAGES,false)

Timber.d("SETTINGS_ENCRYPT_RECEIVED_MESSAGES: [$encryptReceivedMessages]")
return encryptReceivedMessages && !getEncryptionKey(context).isNullOrEmpty()
}

fun setEncryptionKey(context: Context, key: String?) {
Timber.d(Settings::setEncryptionKey.name)

PreferenceManager.getDefaultSharedPreferences(context)
.edit()
.putString(this.SETTINGS_ENCRYPTION_KEY, key)
.apply()
}

fun getEncryptionKey(context: Context): String? {
Timber.d(Settings::getEncryptionKey.name)

return PreferenceManager
.getDefaultSharedPreferences(context)
.getString(this.SETTINGS_ENCRYPTION_KEY, "")
}

fun setIncomingActiveSIM2(context: Context, status: Boolean) {
Timber.d(Settings::setIncomingActiveSIM2.name)

Expand Down
35 changes: 34 additions & 1 deletion android/app/src/main/java/com/httpsms/SettingsActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.core.widget.doAfterTextChanged
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.button.MaterialButton
import com.google.android.material.dialog.MaterialAlertDialogBuilder
Expand Down Expand Up @@ -55,6 +56,37 @@ class SettingsActivity : AppCompatActivity() {
val sim2OutgoingMessages = findViewById<SwitchMaterial>(R.id.settings_sim2_outgoing_messages)
sim2OutgoingMessages.isChecked = Settings.getActiveStatus(context, Constants.SIM2)
sim2OutgoingMessages.setOnCheckedChangeListener{ _, isChecked -> run { Settings.setActiveStatusAsync(context, isChecked, Constants.SIM2) } }

handleEncryptionSettings(context)
}

private fun handleEncryptionSettings(context: Context) {
val encryptionKey = findViewById<TextInputEditText>(R.id.settingsEncryptionKeyInputEdit)
val encryptReceivedMessages = findViewById<SwitchMaterial>(R.id.settingsEncryptReceivedMessages)

val key = Settings.getEncryptionKey(context)
if(key.isNullOrEmpty()) {
encryptReceivedMessages.isEnabled = false
} else {
encryptionKey.setText(key.trim())
}

encryptionKey.doAfterTextChanged{
if (it == null || it.toString().isEmpty()) {
Settings.setEncryptionKey(context, null)
Settings.setEncryptReceivedMessages(context, false)
encryptReceivedMessages.isChecked = false
encryptReceivedMessages.isEnabled = false
} else {
encryptReceivedMessages.isEnabled = true
Settings.setEncryptionKey(context, it.toString().trim())
}
}

encryptReceivedMessages.isChecked = Settings.encryptReceivedMessages(context)
encryptReceivedMessages.setOnCheckedChangeListener{ _, isChecked -> run {
Settings.setEncryptReceivedMessages(context, isChecked)
}}
}

private fun registerListeners() {
Expand All @@ -67,7 +99,6 @@ class SettingsActivity : AppCompatActivity() {
redirectToMain()
}


private fun redirectToMain() {
finish()
val switchActivityIntent = Intent(this, MainActivity::class.java)
Expand All @@ -94,6 +125,8 @@ class SettingsActivity : AppCompatActivity() {
Settings.setIncomingActiveSIM1(this, true)
Settings.setIncomingActiveSIM2(this, true)
Settings.setUserID(this, null)
Settings.setEncryptionKey(this, null)
Settings.setEncryptReceivedMessages(this, false)
Settings.setFcmTokenLastUpdateTimestampAsync(this, 0)
redirectToLogin()
}
Expand Down
27 changes: 27 additions & 0 deletions android/app/src/main/res/layout/activity_settings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,34 @@
android:layout_marginBottom="16dp"
tools:ignore="TouchTargetSizeCheck" />

<com.google.android.material.textfield.TextInputLayout
android:id="@+id/settingsEncryptionKeyLayout"
style="@style/Widget.MaterialComponents.TextInputLayout.OutlinedBox"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:errorEnabled="true"
android:hint="@string/encryption_key"
app:layout_constraintTop_toTopOf="parent"
tools:layout_editor_absoluteX="16dp">

<com.google.android.material.textfield.TextInputEditText
android:id="@+id/settingsEncryptionKeyInputEdit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="textMultiLine"
tools:ignore="TextContrastCheck" />

</com.google.android.material.textfield.TextInputLayout>

<com.google.android.material.switchmaterial.SwitchMaterial
android:id="@+id/settingsEncryptReceivedMessages"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textSize="18sp"
android:text="@string/encrypt_received_messages"
android:minHeight="48dp"
android:layout_marginBottom="16dp"
tools:ignore="TouchTargetSizeCheck" />
</LinearLayout>

<com.google.android.material.button.MaterialButton
Expand Down
2 changes: 2 additions & 0 deletions android/app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,6 @@
<string name="settings_outgoing_messages_sim2">Enable Outgoing Messages (SIM2)</string>
<string name="login_phone_number_sim1">Phone Number (SIM1)</string>
<string name="login_phone_number_sim2">Phone Number (SIM2)</string>
<string name="encryption_key">Encryption Key</string>
<string name="encrypt_received_messages">Encrypt Received Messages</string>
</resources>
Loading