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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
*.iml
.gradle
.idea
.kotlin
.DS_Store
/build
/captures
Expand Down
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -84,9 +84,9 @@ dependencies {
implementation(libs.activity.compose)
implementation(libs.material)
implementation(libs.datastore.preferences)
// BDK + LDK
// Crypto
implementation(libs.bdk.android)
implementation(libs.bitcoinj.core)
implementation(libs.bouncycastle.provider.jdk)
implementation(libs.ldk.node.android)
// Firebase
implementation(platform(libs.firebase.bom))
Expand Down
10 changes: 3 additions & 7 deletions app/src/main/java/to/bitkit/data/BlocktankClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.client.statement.HttpResponse
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonObject
import to.bitkit.shared.BlocktankError
import javax.inject.Inject
import javax.inject.Singleton
Expand Down Expand Up @@ -51,11 +52,6 @@ data class TestNotificationRequest(
data class Data(
val source: String,
val type: String,
val payload: Payload,
) {
@Serializable
data class Payload(
val secretMessage: String,
)
}
val payload: JsonObject,
)
}
7 changes: 4 additions & 3 deletions app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt
Original file line number Diff line number Diff line change
Expand Up @@ -56,20 +56,21 @@ class AndroidKeyStore(

fun encrypt(data: ByteArray): ByteArray {
val secretKey = keyStore.getKey(alias, password) as SecretKey

val cipher = Cipher.getInstance(transformation).apply { init(Cipher.ENCRYPT_MODE, secretKey) }
val ciphertext = cipher.doFinal(data)

val encryptedData = cipher.doFinal(data)
val iv = cipher.iv
check(iv.size == ivLength) { "Unexpected IV length: ${iv.size} ≠ $ivLength" }

// Combine the IV and encrypted data into a single byte array
return iv + encryptedData
return iv + ciphertext
}

fun decrypt(data: ByteArray): ByteArray {
val secretKey = keyStore.getKey(alias, password) as SecretKey

// Extract the IV from the beginning of the encrypted data
// Extract the IV from the beginning of the blob
val iv = data.sliceArray(0 until ivLength)
val actualEncryptedData = data.sliceArray(ivLength until data.size)

Expand Down
9 changes: 9 additions & 0 deletions app/src/main/java/to/bitkit/env/Env.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.util.Log
import to.bitkit.BuildConfig
import to.bitkit.env.Tag.APP
import to.bitkit.ext.ensureDir
import to.bitkit.models.blocktank.BlocktankNotificationType
import kotlin.io.path.Path
import org.lightningdevkit.ldknode.Network as LdkNetwork

Expand All @@ -26,6 +27,14 @@ internal object Env {
Network.Regtest -> "https://electrs-regtest.synonym.to"
else -> TODO("Not yet implemented")
}
val pushNotificationFeatures = listOf(
BlocktankNotificationType.incomingHtlc,
BlocktankNotificationType.mutualClose,
BlocktankNotificationType.orderPaymentConfirmed,
BlocktankNotificationType.cjitPaymentArrived,
BlocktankNotificationType.wakeToTimeout,
)
const val DERIVATION_NAME = "bitkit-notifications"

object Storage {
private var base = ""
Expand Down
31 changes: 15 additions & 16 deletions app/src/main/java/to/bitkit/ext/ByteArray.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,32 +2,31 @@

package to.bitkit.ext

import android.util.Base64
import java.io.ByteArrayOutputStream
import java.io.ObjectOutputStream
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi

// region hex
val ByteArray.hex: String get() = joinToString("") { "%02x".format(it) }

val String.hex: ByteArray
get() {
require(length % 2 == 0) { "Cannot convert string of uneven length to hex ByteArray: $this" }
return chunked(2)
.map { it.toInt(16).toByte() }
.toByteArray()
}
@OptIn(ExperimentalStdlibApi::class)
fun ByteArray.toHex(): String = this.toHexString()

@OptIn(ExperimentalStdlibApi::class)
fun String.fromHex(): ByteArray = this.hexToByteArray()
// endregion

// region base64
fun ByteArray.toBase64(flags: Int = Base64.DEFAULT): String = Base64.encodeToString(this, flags)
@OptIn(ExperimentalEncodingApi::class)
fun ByteArray.toBase64(): String = Base64.encode(this)

fun String.fromBase64(flags: Int = Base64.DEFAULT): ByteArray = Base64.decode(this, flags)
@OptIn(ExperimentalEncodingApi::class)
fun String.fromBase64(): ByteArray = Base64.decode(this)
// endregion

fun Any.convertToByteArray(): ByteArray {
val byteArrayOutputStream = ByteArrayOutputStream()
ObjectOutputStream(byteArrayOutputStream).use { it.writeObject(this) }
return byteArrayOutputStream.toByteArray()
val out = ByteArrayOutputStream()
ObjectOutputStream(out).use { it.writeObject(this) }
return out.toByteArray()
}

val String.uByteList get() = this.toByteArray(Charsets.UTF_8).map { it.toUByte() }
val String.uByteList get() = this.toByteArray().map { it.toUByte() }
5 changes: 5 additions & 0 deletions app/src/main/java/to/bitkit/ext/Collections.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package to.bitkit.ext

fun Map<String, String>.containsKeys(vararg keys: String): Boolean {
return keys.all { this.containsKey(it) }
}
149 changes: 100 additions & 49 deletions app/src/main/java/to/bitkit/fcm/FcmService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,44 @@ package to.bitkit.fcm

import android.os.Bundle
import android.util.Log
import androidx.core.os.bundleOf
import androidx.core.os.toPersistableBundle
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.serialization.Serializable
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonObject
import to.bitkit.data.keychain.Keychain
import to.bitkit.di.json
import to.bitkit.env.Env.DERIVATION_NAME
import to.bitkit.env.Tag.FCM
import to.bitkit.ext.fromBase64
import to.bitkit.ext.fromHex
import to.bitkit.models.blocktank.BlocktankNotificationType
import to.bitkit.shared.Crypto
import to.bitkit.ui.pushNotification
import java.util.Date
import javax.inject.Inject

@AndroidEntryPoint
internal class FcmService : FirebaseMessagingService() {
private lateinit var token: String

private var notificationType: BlocktankNotificationType? = null
private var notificationPayload: JsonObject? = null

@Inject
lateinit var crypto: Crypto

@Inject
lateinit var keychain: Keychain

/**
* Act on received messages
*
* [Debug](https://goo.gl/39bRNJ)
* Act on received messages. [Debug](https://goo.gl/39bRNJ)
*/
override fun onMessageReceived(message: RemoteMessage) {
Log.d(FCM, "New FCM at: ${Date(message.sentTime)}")
Expand All @@ -37,37 +53,87 @@ internal class FcmService : FirebaseMessagingService() {
if (message.data.isNotEmpty()) {
Log.d(FCM, "FCM data: ${message.data}")

if (message.needsScheduling()) {
scheduleJob(message.data)
} else {
handleNow(message.data)
val shouldSchedule = runCatching {
val isEncryptedNotification = message.data.tryAs<EncryptedNotification> {
decryptPayload(it)
}
isEncryptedNotification
}.getOrElse {
Log.e(FCM, "Failed to read encrypted notification payload", it)
// Let the node to spin up and handle incoming events
true
}

when (shouldSchedule) {
true -> handleAsync()
else -> handleNow(message.data)
}
}
}

private fun sendNotification(title: String?, body: String?, extras: Bundle) {
pushNotification(title, body, extras)
private fun handleAsync() {
val work = OneTimeWorkRequestBuilder<WakeNodeWorker>()
.setInputData(workDataOf(
"type" to notificationType?.name,
"payload" to notificationPayload?.toString(),
))
.build()
WorkManager.getInstance(this)
.beginWith(work)
.enqueue()
}

/**
* Handle message within 10 seconds.
*/
private fun handleNow(data: Map<String, String>) {
val isHandled = data.runAs<EncryptedNotification> {
val extras = bundleOf(
"tag" to tag,
"sound" to sound,
"publicKey" to publicKey,
)

sendNotification(title, message, extras)
Log.w(FCM, "FCM handler not implemented for: $data")
}

private fun decryptPayload(response: EncryptedNotification) {
val ciphertext = runCatching { response.cipher.fromBase64() }.getOrElse {
Log.e(FCM, ("Failed to decode cipher"), it)
return
}
val privateKey = runCatching { keychain.load(Keychain.Key.PUSH_NOTIFICATION_PRIVATE_KEY.name)!! }.getOrElse {
Log.e(FCM, "Missing PUSH_NOTIFICATION_PRIVATE_KEY", it)
return
}
val password =
runCatching { crypto.generateSharedSecret(privateKey, response.publicKey, DERIVATION_NAME) }.getOrElse {
Log.e(FCM, "Failed to generate shared secret", it)
return
}

val decrypted = crypto.decrypt(
encryptedPayload = Crypto.EncryptedPayload(ciphertext, response.iv.fromHex(), response.tag.fromHex()),
secretKey = password,
)

val decoded = decrypted.decodeToString()
Log.d(FCM, "Decrypted payload: $decoded")

val (payload, type) = runCatching { json.decodeFromString<DecryptedNotification>(decoded) }.getOrElse {
Log.e(FCM, "Failed to decode decrypted data", it)
return
}
if (!isHandled) {
Log.e(FCM, "FCM handler not implemented for: $data")

if (payload == null) {
Log.e(FCM, "Missing payload")
return
}

if (type == null) {
Log.e(FCM, "Missing type")
return
}

notificationType = type
notificationPayload = payload
}

private fun sendNotification(title: String?, body: String?, extras: Bundle) {
pushNotification(title, body, extras, context = applicationContext)
}

private inline fun <reified T> Map<String, String>.runAs(block: T.() -> Unit): Boolean {
private inline fun <reified T> Map<String, String>.tryAs(block: (T) -> Unit): Boolean {
val encoded = json.encodeToString(this)
return try {
val decoded = json.decodeFromString<T>(encoded)
Expand All @@ -78,27 +144,6 @@ internal class FcmService : FirebaseMessagingService() {
}
}

/**
* Schedule async work via WorkManager for tasks of 10+ seconds.
*/
private fun scheduleJob(messageData: Map<String, String>) {
val work = OneTimeWorkRequestBuilder<Wake2PayWorker>()
.setInputData(
workDataOf(
"bolt11" to messageData["bolt11"].orEmpty()
)
)
.build()
WorkManager.getInstance(this)
.beginWith(work)
.enqueue()
}

private fun RemoteMessage.needsScheduling(): Boolean {
return notification == null &&
data.containsKey("bolt11")
}

override fun onNewToken(token: String) {
this.token = token
Log.d(FCM, "FCM registration token refreshed: $token")
Expand All @@ -111,8 +156,14 @@ data class EncryptedNotification(
val cipher: String,
val iv: String,
val tag: String,
val sound: String,
val title: String,
val message: String,
val publicKey: String,
val sound: String = "",
val title: String = "",
val message: String = "",
val publicKey: String = "",
)

@Serializable
data class DecryptedNotification(
val payload: JsonObject? = null,
val type: BlocktankNotificationType? = null,
)
38 changes: 0 additions & 38 deletions app/src/main/java/to/bitkit/fcm/Wake2PayWorker.kt

This file was deleted.

Loading