Skip to content

Commit 33f39e0

Browse files
committed
Add Android encryption code
1 parent 0ab293f commit 33f39e0

10 files changed

+294
-103
lines changed

android/TTFirebaseMessagingService.java

-94
This file was deleted.

android/TTFirebaseMessagingService.kt

+61
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package com.teamtailor.app
2+
3+
import com.google.firebase.messaging.RemoteMessage
4+
import expo.modules.notifications.service.ExpoFirebaseMessagingService
5+
import android.util.Log
6+
import org.json.JSONObject
7+
import com.teamtailor.modules.encryption.CryptoUtils
8+
9+
class TTFirebaseMessagingService : ExpoFirebaseMessagingService() {
10+
companion object {
11+
private const val TAG = "TTFirebaseMessagingService"
12+
}
13+
14+
override fun onMessageReceived(remoteMessage: RemoteMessage) {
15+
val originalData = remoteMessage.getData()
16+
Log.d(TAG, "Received message with data: ${originalData}")
17+
18+
if (originalData["encrypted_data"]?.let { encryptedDataString ->
19+
try {
20+
val encryptedData = parseJsonToMap(encryptedDataString)
21+
val decryptedDataString = CryptoUtils.hybridDecrypt(
22+
encryptedData["encrypted_key"]!!,
23+
encryptedData["cipher_text"]!!,
24+
encryptedData["nonce"]!!,
25+
encryptedData["tag"]!!
26+
)
27+
28+
val decryptedData = parseJsonToMap(decryptedDataString)
29+
val modifiedData = originalData.toMutableMap().apply {
30+
remove("encrypted_data")
31+
putAll(decryptedData)
32+
}
33+
34+
val newMessage = RemoteMessage.Builder("/topics/default").apply {
35+
remoteMessage.getMessageId()?.let { setMessageId(it) }
36+
setData(modifiedData)
37+
setTtl(remoteMessage.getTtl())
38+
setCollapseKey(remoteMessage.getCollapseKey())
39+
}.build()
40+
41+
super.onMessageReceived(newMessage)
42+
true
43+
} catch (e: Exception) {
44+
Log.e(TAG, "Failed to decrypt or parse encrypted data", e)
45+
false
46+
}
47+
} == true) {
48+
Log.d(TAG, "Successfully handled encrypted data")
49+
} else {
50+
Log.d(TAG, "No encrypted data found or decryption failed, passing original message")
51+
super.onMessageReceived(remoteMessage)
52+
}
53+
}
54+
55+
private fun parseJsonToMap(jsonString: String): Map<String, String> =
56+
JSONObject(jsonString).let { json ->
57+
json.keys().asSequence().associateWith { key ->
58+
json.getString(key)
59+
}
60+
}
61+
}

android/build.gradle

+92
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
apply plugin: 'com.android.library'
2+
apply plugin: 'kotlin-android'
3+
apply plugin: 'maven-publish'
4+
5+
group = 'com.teamtailor.modules.encryption'
6+
version = '0.5.0'
7+
8+
buildscript {
9+
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
10+
if (expoModulesCorePlugin.exists()) {
11+
apply from: expoModulesCorePlugin
12+
applyKotlinExpoModulesCorePlugin()
13+
}
14+
15+
// Simple helper that allows the root project to override versions declared by this library.
16+
ext.safeExtGet = { prop, fallback ->
17+
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
18+
}
19+
20+
// Ensures backward compatibility
21+
ext.getKotlinVersion = {
22+
if (ext.has("kotlinVersion")) {
23+
ext.kotlinVersion()
24+
} else {
25+
ext.safeExtGet("kotlinVersion", "1.8.10")
26+
}
27+
}
28+
29+
repositories {
30+
mavenCentral()
31+
}
32+
33+
dependencies {
34+
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}")
35+
}
36+
}
37+
38+
afterEvaluate {
39+
publishing {
40+
publications {
41+
release(MavenPublication) {
42+
from components.release
43+
}
44+
}
45+
repositories {
46+
maven {
47+
url = mavenLocal().url
48+
}
49+
}
50+
}
51+
}
52+
53+
android {
54+
compileSdkVersion safeExtGet("compileSdkVersion", 33)
55+
56+
def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION
57+
if (agpVersion.tokenize('.')[0].toInteger() < 8) {
58+
compileOptions {
59+
sourceCompatibility JavaVersion.VERSION_11
60+
targetCompatibility JavaVersion.VERSION_11
61+
}
62+
63+
kotlinOptions {
64+
jvmTarget = JavaVersion.VERSION_11.majorVersion
65+
}
66+
}
67+
68+
namespace "com.teamtailor.modules.encryption"
69+
defaultConfig {
70+
minSdkVersion safeExtGet("minSdkVersion", 21)
71+
targetSdkVersion safeExtGet("targetSdkVersion", 34)
72+
versionCode 1
73+
versionName "0.5.0"
74+
}
75+
lintOptions {
76+
abortOnError false
77+
}
78+
publishing {
79+
singleVariant("release") {
80+
withSourcesJar()
81+
}
82+
}
83+
}
84+
85+
repositories {
86+
mavenCentral()
87+
}
88+
89+
dependencies {
90+
implementation project(':expo-modules-core')
91+
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
92+
}

android/src/main/AndroidManifest.xml

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
<manifest>
2+
</manifest>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package com.teamtailor.modules.encryption
2+
3+
import android.security.keystore.KeyGenParameterSpec
4+
import android.security.keystore.KeyProperties
5+
import android.util.Base64
6+
import java.security.KeyPairGenerator
7+
import java.security.KeyStore
8+
import java.security.SecureRandom
9+
import javax.crypto.Cipher
10+
import javax.crypto.spec.GCMParameterSpec
11+
import javax.crypto.spec.SecretKeySpec
12+
13+
object CryptoUtils {
14+
private const val KEY_ALIAS = "com.teamtailor.modules.encryption.cryptoutils2"
15+
private const val ANDROID_KEYSTORE = "AndroidKeyStore"
16+
private const val GCM_TAG_LENGTH = 128
17+
18+
private fun getOrCreateKeyPair(): KeyStore.PrivateKeyEntry {
19+
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
20+
keyStore.load(null)
21+
22+
if (!keyStore.containsAlias(KEY_ALIAS)) {
23+
val spec = KeyGenParameterSpec.Builder(
24+
KEY_ALIAS,
25+
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
26+
)
27+
.setDigests(KeyProperties.DIGEST_SHA1) // Changed to SHA1 to match Ruby/iOS
28+
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP)
29+
.setKeySize(2048) // Explicitly set key size to match other implementations
30+
.build()
31+
32+
val generator = KeyPairGenerator.getInstance(
33+
KeyProperties.KEY_ALGORITHM_RSA,
34+
ANDROID_KEYSTORE
35+
)
36+
generator.initialize(spec)
37+
generator.generateKeyPair()
38+
}
39+
40+
return keyStore.getEntry(KEY_ALIAS, null) as KeyStore.PrivateKeyEntry
41+
}
42+
43+
fun getPublicKey(): String {
44+
val entry = getOrCreateKeyPair()
45+
return Base64.encodeToString(entry.certificate.publicKey.encoded, Base64.NO_WRAP)
46+
}
47+
48+
fun hybridDecrypt(encryptedKey: String, cipherText: String, nonce: String, tag: String): String {
49+
val aesKey = rsaDecrypt(encryptedKey)
50+
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
51+
52+
val nonceBytes = Base64.decode(nonce, Base64.NO_WRAP)
53+
val cipherBytes = Base64.decode(cipherText, Base64.NO_WRAP)
54+
val tagBytes = Base64.decode(tag, Base64.NO_WRAP)
55+
56+
val spec = GCMParameterSpec(GCM_TAG_LENGTH, nonceBytes)
57+
val secretKey = SecretKeySpec(aesKey, "AES") // AES-256 key from RSA decryption
58+
59+
cipher.init(Cipher.DECRYPT_MODE, secretKey, spec)
60+
val decryptedBytes = cipher.doFinal(cipherBytes + tagBytes)
61+
62+
return String(decryptedBytes, Charsets.UTF_8)
63+
}
64+
65+
fun rsaDecrypt(encryptedBase64: String): ByteArray {
66+
val entry = getOrCreateKeyPair()
67+
// Use RSA/ECB/OAEPWithSHA-1AndMGF1Padding to match Ruby's OpenSSL::PKey::RSA::PKCS1_OAEP_PADDING
68+
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding")
69+
cipher.init(Cipher.DECRYPT_MODE, entry.privateKey)
70+
71+
val encryptedBytes = Base64.decode(encryptedBase64, Base64.NO_WRAP)
72+
return cipher.doFinal(encryptedBytes)
73+
}
74+
75+
fun deleteKeyPair() {
76+
val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE)
77+
keyStore.load(null)
78+
keyStore.deleteEntry(KEY_ALIAS)
79+
}
80+
81+
fun testEncryption(message: String): Boolean {
82+
val entry = getOrCreateKeyPair()
83+
val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-1AndMGF1Padding")
84+
85+
// Encrypt
86+
cipher.init(Cipher.ENCRYPT_MODE, entry.certificate.publicKey)
87+
val encrypted = cipher.doFinal(message.toByteArray())
88+
89+
// Decrypt
90+
cipher.init(Cipher.DECRYPT_MODE, entry.privateKey)
91+
val decrypted = cipher.doFinal(encrypted)
92+
93+
return message == String(decrypted)
94+
}
95+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package com.teamtailor.modules.encryption
2+
3+
import expo.modules.kotlin.modules.Module
4+
import expo.modules.kotlin.modules.ModuleDefinition
5+
6+
class EncryptionModule : Module() {
7+
override fun definition() = ModuleDefinition {
8+
Name("EncryptionModule")
9+
10+
Function("getPublicKey") {
11+
CryptoUtils.getPublicKey()
12+
}
13+
14+
Function("hybridDecrypt") { encryptedKey: String, cipherText: String, nonce: String, tag: String ->
15+
CryptoUtils.hybridDecrypt(encryptedKey, cipherText, nonce, tag)
16+
}
17+
18+
Function("deleteKeyPair") {
19+
CryptoUtils.deleteKeyPair()
20+
}
21+
22+
Function("testEncryption") { message: String ->
23+
CryptoUtils.testEncryption(message)
24+
}
25+
26+
Function("rsaDecrypt") { encryptedBase64: String ->
27+
CryptoUtils.rsaDecrypt(encryptedBase64)
28+
}
29+
}
30+
}

expo-module.config.json

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
{
22
"name": "ttmobile-notification-service",
3-
"platforms": ["ios"],
3+
"platforms": ["ios", "android"],
44
"ios": {
55
"modules": ["EncryptionModule"]
6+
},
7+
"android": {
8+
"modules": ["com.teamtailor.modules.encryption.EncryptionModule"]
69
}
710
}

0 commit comments

Comments
 (0)