diff --git a/.gitignore b/.gitignore index 34535f178..3e5251c7d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.iml .gradle .idea +.kotlin .DS_Store /build /captures diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a4a8eee65..95515cc11 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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)) diff --git a/app/src/main/java/to/bitkit/data/BlocktankClient.kt b/app/src/main/java/to/bitkit/data/BlocktankClient.kt index 9ac3c5b0b..77a6c8446 100644 --- a/app/src/main/java/to/bitkit/data/BlocktankClient.kt +++ b/app/src/main/java/to/bitkit/data/BlocktankClient.kt @@ -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 @@ -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, + ) } diff --git a/app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt b/app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt index 33f1a20e4..46d5b2dc2 100644 --- a/app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt +++ b/app/src/main/java/to/bitkit/data/keychain/AndroidKeyStore.kt @@ -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) diff --git a/app/src/main/java/to/bitkit/env/Env.kt b/app/src/main/java/to/bitkit/env/Env.kt index accac8517..03f18c6d1 100644 --- a/app/src/main/java/to/bitkit/env/Env.kt +++ b/app/src/main/java/to/bitkit/env/Env.kt @@ -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 @@ -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 = "" diff --git a/app/src/main/java/to/bitkit/ext/ByteArray.kt b/app/src/main/java/to/bitkit/ext/ByteArray.kt index cfab705ad..3fae78207 100644 --- a/app/src/main/java/to/bitkit/ext/ByteArray.kt +++ b/app/src/main/java/to/bitkit/ext/ByteArray.kt @@ -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() } diff --git a/app/src/main/java/to/bitkit/ext/Collections.kt b/app/src/main/java/to/bitkit/ext/Collections.kt new file mode 100644 index 000000000..0aaf96a77 --- /dev/null +++ b/app/src/main/java/to/bitkit/ext/Collections.kt @@ -0,0 +1,5 @@ +package to.bitkit.ext + +fun Map.containsKeys(vararg keys: String): Boolean { + return keys.all { this.containsKey(it) } +} diff --git a/app/src/main/java/to/bitkit/fcm/FcmService.kt b/app/src/main/java/to/bitkit/fcm/FcmService.kt index 0f3b71a4a..c2d0a4272 100644 --- a/app/src/main/java/to/bitkit/fcm/FcmService.kt +++ b/app/src/main/java/to/bitkit/fcm/FcmService.kt @@ -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)}") @@ -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 { + 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() + .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) { - val isHandled = data.runAs { - 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(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 Map.runAs(block: T.() -> Unit): Boolean { + private inline fun Map.tryAs(block: (T) -> Unit): Boolean { val encoded = json.encodeToString(this) return try { val decoded = json.decodeFromString(encoded) @@ -78,27 +144,6 @@ internal class FcmService : FirebaseMessagingService() { } } - /** - * Schedule async work via WorkManager for tasks of 10+ seconds. - */ - private fun scheduleJob(messageData: Map) { - val work = OneTimeWorkRequestBuilder() - .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") @@ -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, ) diff --git a/app/src/main/java/to/bitkit/fcm/Wake2PayWorker.kt b/app/src/main/java/to/bitkit/fcm/Wake2PayWorker.kt deleted file mode 100644 index 397e0bfff..000000000 --- a/app/src/main/java/to/bitkit/fcm/Wake2PayWorker.kt +++ /dev/null @@ -1,38 +0,0 @@ -package to.bitkit.fcm - -import android.content.Context -import android.util.Log -import androidx.hilt.work.HiltWorker -import androidx.work.CoroutineWorker -import androidx.work.WorkerParameters -import androidx.work.workDataOf -import dagger.assisted.Assisted -import dagger.assisted.AssistedInject -import to.bitkit.env.Tag.FCM -import to.bitkit.services.LightningService -import to.bitkit.services.warmupNode - -@HiltWorker -class Wake2PayWorker @AssistedInject constructor( - @Assisted private val appContext: Context, - @Assisted private val workerParams: WorkerParameters, -) : CoroutineWorker(appContext, workerParams) { - override suspend fun doWork(): Result { - Log.d(FCM, "Node wakeup from notification…") - - warmupNode() - - val bolt11 = workerParams.inputData.getString("bolt11") ?: return Result.failure( - workDataOf("reason" to "bolt11 field missing") - ) - - val isSuccess = LightningService.shared.payInvoice(bolt11) - return if (isSuccess) { - Result.success() - } else { - Result.failure( - workDataOf("reason" to "payment error") - ) - } - } -} diff --git a/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt new file mode 100644 index 000000000..2f8421abe --- /dev/null +++ b/app/src/main/java/to/bitkit/fcm/WakeNodeWorker.kt @@ -0,0 +1,163 @@ +package to.bitkit.fcm + +import android.content.Context +import android.util.Log +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.jsonObject +import org.lightningdevkit.ldknode.Event +import to.bitkit.di.json +import to.bitkit.env.Tag.LDK +import to.bitkit.models.blocktank.BlocktankNotificationType +import to.bitkit.models.blocktank.BlocktankNotificationType.cjitPaymentArrived +import to.bitkit.models.blocktank.BlocktankNotificationType.incomingHtlc +import to.bitkit.models.blocktank.BlocktankNotificationType.mutualClose +import to.bitkit.models.blocktank.BlocktankNotificationType.orderPaymentConfirmed +import to.bitkit.models.blocktank.BlocktankNotificationType.wakeToTimeout +import to.bitkit.services.LightningService +import to.bitkit.shared.withPerformanceLogging +import to.bitkit.ui.pushNotification + +@HiltWorker +class WakeNodeWorker @AssistedInject constructor( + @Assisted private val appContext: Context, + @Assisted private val workerParams: WorkerParameters, +) : CoroutineWorker(appContext, workerParams) { + class VisibleNotification( + var title: String = "", + var body: String = "", + ) + + private var bestAttemptContent: VisibleNotification? = VisibleNotification() + + private var notificationType: BlocktankNotificationType? = null + private var notificationPayload: JsonObject? = null + + private val self = this + + override suspend fun doWork(): Result { + Log.d(LDK, "Node wakeup from notification…") + + notificationType = workerParams.inputData.getString("type")?.let { BlocktankNotificationType.valueOf(it) } + notificationPayload = workerParams.inputData.getString("payload")?.let { + runCatching { json.parseToJsonElement(it).jsonObject }.getOrNull() + } + + Log.d(LDK, "Worker notification type: $notificationType") + Log.d(LDK, "Worker notification payload: $notificationPayload") + + try { + withPerformanceLogging { + LightningService.shared.apply { + setup() + start { handleEvent(it) } + // sync() // TODO why (not) ? + // stop() is done by deliver() via handleEvent() + } + + if (self.notificationType == orderPaymentConfirmed) { + val orderId = (notificationPayload?.get("orderId") as? JsonPrimitive)?.contentOrNull + + if (orderId == null) { + Log.e(LDK, "Missing orderId") + } else { + try { + // TODO: #2122 Background task for opening closing channels + // BlocktankService.shared.openChannel(orderId) + Log.i(LDK, "Open channel request for order $orderId") + } catch (e: Exception) { + Log.e(LDK, "failed to open channel", e) + } + } + } + } + return Result.success() + } catch (e: Exception) { + val reason = e.message ?: "Unknown error" + + self.bestAttemptContent?.title = "Lightning error" + self.bestAttemptContent?.body = reason + Log.e(LDK, "Lightning error", e) + self.deliver() + + return Result.failure(workDataOf("Reason" to reason)) + } + } + + /** + * Listens for LDK events and delivers the notification if the event matches the notification type. + * @param event The LDK event to check. + */ + private suspend fun handleEvent(event: Event) { + when (event) { + is Event.PaymentReceived -> { + bestAttemptContent?.title = "Payment Received" + bestAttemptContent?.body = "⚡ ${event.amountMsat / 1000u}" + if (self.notificationType == incomingHtlc) { + self.deliver() + } + } + + is Event.ChannelPending -> { + self.bestAttemptContent?.title = "Channel Opened" + self.bestAttemptContent?.body = "Pending" + // Don't deliver, give a chance for channelReady event to update the content if it's a turbo channel + } + + is Event.ChannelReady -> { + if (self.notificationType == cjitPaymentArrived) { + self.bestAttemptContent?.title = "Payment received" + self.bestAttemptContent?.body = "Via new channel" + + LightningService.shared.channels?.first { it.channelId == event.channelId }?.let { channel -> + self.bestAttemptContent?.title = "Received ⚡ ${channel.outboundCapacityMsat / 1000u} sats" + } + } else if (self.notificationType == orderPaymentConfirmed) { + self.bestAttemptContent?.title = "Channel opened" + self.bestAttemptContent?.body = "Ready to send" + } + self.deliver() + } + + is Event.ChannelClosed -> { + if (self.notificationType == mutualClose) { + self.bestAttemptContent?.title = "Channel closed" + self.bestAttemptContent?.body = "Balance moved from spending to savings" + } else if (self.notificationType == orderPaymentConfirmed) { + self.bestAttemptContent?.title = "Channel failed to open in the background" + self.bestAttemptContent?.body = "Please try again" + } + self.deliver() + } + + is Event.PaymentSuccessful -> Unit + is Event.PaymentClaimable -> Unit + + is Event.PaymentFailed -> { + self.bestAttemptContent?.title = "Payment failed" + self.bestAttemptContent?.body = "⚡ ${event.reason}" + self.deliver() + + if (self.notificationType == wakeToTimeout) { + self.deliver() + } + } + } + } + + private suspend fun deliver() { + LightningService.shared.stop() + + bestAttemptContent?.run { + pushNotification(title, body, context = appContext) + Log.i(LDK, "Delivered notification") + } + } +} diff --git a/app/src/main/java/to/bitkit/models/blocktank/BlocktankNotificationType.kt b/app/src/main/java/to/bitkit/models/blocktank/BlocktankNotificationType.kt new file mode 100644 index 000000000..7eecf9084 --- /dev/null +++ b/app/src/main/java/to/bitkit/models/blocktank/BlocktankNotificationType.kt @@ -0,0 +1,15 @@ +package to.bitkit.models.blocktank + +import kotlinx.serialization.Serializable + +@Suppress("EnumEntryName") +@Serializable +enum class BlocktankNotificationType { + incomingHtlc, + mutualClose, + orderPaymentConfirmed, + cjitPaymentArrived, + wakeToTimeout; + + override fun toString(): String = "blocktank.$name" +} diff --git a/app/src/main/java/to/bitkit/services/BlocktankService.kt b/app/src/main/java/to/bitkit/services/BlocktankService.kt index db454ac65..201751099 100644 --- a/app/src/main/java/to/bitkit/services/BlocktankService.kt +++ b/app/src/main/java/to/bitkit/services/BlocktankService.kt @@ -2,7 +2,8 @@ package to.bitkit.services import android.util.Log import kotlinx.coroutines.CoroutineDispatcher -import org.bitcoinj.core.ECKey +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive import to.bitkit.async.BaseCoroutineScope import to.bitkit.async.ServiceQueue import to.bitkit.data.BlocktankClient @@ -11,10 +12,13 @@ import to.bitkit.data.TestNotificationRequest import to.bitkit.data.keychain.Keychain import to.bitkit.data.keychain.Keychain.Key import to.bitkit.di.BgDispatcher +import to.bitkit.env.Env +import to.bitkit.env.Env.DERIVATION_NAME import to.bitkit.env.Tag.LSP +import to.bitkit.ext.toHex +import to.bitkit.shared.Crypto import to.bitkit.shared.ServiceError import java.time.Instant -import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit import javax.inject.Inject @@ -23,6 +27,7 @@ class BlocktankService @Inject constructor( private val client: BlocktankClient, private val lightningService: LightningService, private val keychain: Keychain, + private val crypto: Crypto, ) : BaseCoroutineScope(bgDispatcher) { // region notifications @@ -31,25 +36,25 @@ class BlocktankService @Inject constructor( Log.d(LSP, "Registering device for notifications…") - val isoTimestamp = DateTimeFormatter.ISO_INSTANT.format(Instant.now().truncatedTo(ChronoUnit.SECONDS)) - val messageToSign = "bitkit-notifications$deviceToken$isoTimestamp" + val isoTimestamp = Instant.now().truncatedTo(ChronoUnit.SECONDS).toString() + val messageToSign = "$DERIVATION_NAME$deviceToken$isoTimestamp" val signature = lightningService.sign(messageToSign) - val keypair = ECKey() - val publicKey = keypair.publicKeyAsHex + val keypair = crypto.generateKeyPair() + val publicKey = keypair.publicKey.toHex() Log.d(LSP, "Notification encryption public key: $publicKey") // New keypair for each token registration if (keychain.exists(Key.PUSH_NOTIFICATION_PRIVATE_KEY.name)) { keychain.delete(Key.PUSH_NOTIFICATION_PRIVATE_KEY.name) } - keychain.save(Key.PUSH_NOTIFICATION_PRIVATE_KEY.name, keypair.privKeyBytes) + keychain.save(Key.PUSH_NOTIFICATION_PRIVATE_KEY.name, keypair.privateKey) val payload = RegisterDeviceRequest( deviceToken = deviceToken, publicKey = publicKey, - features = listOf("blocktank.incomingHtlc"), + features = Env.pushNotificationFeatures.map { it.toString() }, nodeId = nodeId, isoTimestamp = isoTimestamp, signature = signature, @@ -67,7 +72,11 @@ class BlocktankService @Inject constructor( data = TestNotificationRequest.Data( source = "blocktank", type = "incomingHtlc", - payload = TestNotificationRequest.Data.Payload(secretMessage = "hello") + payload = JsonObject( + mapOf( + "secretMessage" to JsonPrimitive("hello") + ) + ) ) ) diff --git a/app/src/main/java/to/bitkit/services/LightningService.kt b/app/src/main/java/to/bitkit/services/LightningService.kt index 712905f1d..0ab26184f 100644 --- a/app/src/main/java/to/bitkit/services/LightningService.kt +++ b/app/src/main/java/to/bitkit/services/LightningService.kt @@ -28,6 +28,8 @@ import to.bitkit.shared.LdkError import to.bitkit.shared.ServiceError import javax.inject.Inject +typealias NodeEventHandler = suspend (Event) -> Unit + class LightningService @Inject constructor( @BgDispatcher bgDispatcher: CoroutineDispatcher, ) : BaseCoroutineScope(bgDispatcher) { @@ -77,7 +79,7 @@ class LightningService @Inject constructor( Log.i(LDK, "Node set up") } - suspend fun start() { + suspend fun start(onEvent: NodeEventHandler? = null) { val node = this.node ?: throw ServiceError.NodeNotSetup Log.d(LDK, "Starting node…") @@ -85,7 +87,9 @@ class LightningService @Inject constructor( node.start() } Log.i(LDK, "Node started") + connectToTrustedPeers() + listen(onEvent) } suspend fun stop() { @@ -141,7 +145,7 @@ class LightningService @Inject constructor( node.connect(peer.nodeId, peer.address, persist = true) } Log.i(LDK, "Connection succeeded with: $peer") - } catch(e: NodeException) { + } catch (e: NodeException) { Log.w(LDK, "Connection failed with: $peer", LdkError(e)) } } @@ -246,6 +250,92 @@ class LightningService @Inject constructor( } // endregion + // region events + private suspend fun listen(onEvent: NodeEventHandler? = null) { + while (true) { + val node = this.node ?: let { + Log.e(LDK, ServiceError.NodeNotStarted.message.orEmpty()) + return + } + val event = node.nextEventAsync() + onEvent?.invoke(event)?.let { node.eventHandled() } + + // TODO: actual event handler + when (event) { + is Event.PaymentSuccessful -> { + val paymentId = event.paymentId ?: "?" + val paymentHash = event.paymentHash + val feePaidMsat = event.feePaidMsat ?: 0 + Log.i( + LDK, + "✅ Payment successful: paymentId: $paymentId paymentHash: $paymentHash feePaidMsat: $feePaidMsat" + ) + } + + is Event.PaymentFailed -> { + val paymentId = event.paymentId ?: "?" + val paymentHash = event.paymentHash + val reason = event.reason + Log.i(LDK, "❌ Payment failed: paymentId: $paymentId paymentHash: $paymentHash reason: $reason") + } + + is Event.PaymentReceived -> { + val paymentId = event.paymentId ?: "?" + val paymentHash = event.paymentHash + val amountMsat = event.amountMsat + Log.i( + LDK, + "🤑 Payment received: paymentId: $paymentId paymentHash: $paymentHash amountMsat: $amountMsat" + ) + } + + is Event.PaymentClaimable -> { + val paymentId = event.paymentId + val paymentHash = event.paymentHash + val claimableAmountMsat = event.claimableAmountMsat + Log.i( + LDK, + "🫰 Payment claimable: paymentId: $paymentId paymentHash: $paymentHash claimableAmountMsat: $claimableAmountMsat" + ) + } + + is Event.ChannelPending -> { + val channelId = event.channelId + val userChannelId = event.userChannelId + val formerTemporaryChannelId = event.formerTemporaryChannelId + val counterpartyNodeId = event.counterpartyNodeId + val fundingTxo = event.fundingTxo + Log.i( + LDK, + "⏳ Channel pending: channelId: $channelId userChannelId: $userChannelId formerTemporaryChannelId: $formerTemporaryChannelId counterpartyNodeId: $counterpartyNodeId fundingTxo: $fundingTxo" + ) + } + + is Event.ChannelReady -> { + val channelId = event.channelId + val userChannelId = event.userChannelId + val counterpartyNodeId = event.counterpartyNodeId ?: "?" + Log.i( + LDK, + "👐 Channel ready: channelId: $channelId userChannelId: $userChannelId counterpartyNodeId: $counterpartyNodeId" + ) + } + + is Event.ChannelClosed -> { + val channelId = event.channelId + val userChannelId = event.userChannelId + val counterpartyNodeId = event.counterpartyNodeId ?: "?" + val reason = event.reason + Log.i( + LDK, + "⛔ Channel closed: channelId: $channelId userChannelId: $userChannelId counterpartyNodeId: $counterpartyNodeId reason: $reason" + ) + } + } + } + } + // endregion + // region state val nodeId: String? get() = node?.nodeId() val balances: BalanceDetails? get() = node?.listBalances() @@ -255,19 +345,3 @@ class LightningService @Inject constructor( val payments: List? get() = node?.listPayments() // endregion } - -internal suspend fun warmupNode() { - runCatching { - LightningService.shared.apply { - setup() - start() - sync() - } - OnChainService.shared.apply { - setup() - fullScan() - } - }.onFailure { - Log.e(LDK, "Node warmup error", it) - } -} diff --git a/app/src/main/java/to/bitkit/services/MigrationService.kt b/app/src/main/java/to/bitkit/services/MigrationService.kt index 6d8ab85a0..50456c92d 100644 --- a/app/src/main/java/to/bitkit/services/MigrationService.kt +++ b/app/src/main/java/to/bitkit/services/MigrationService.kt @@ -9,7 +9,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import org.ldk.structs.KeysManager import to.bitkit.env.Env import to.bitkit.env.Tag.LDK -import to.bitkit.ext.hex +import to.bitkit.ext.toHex import to.bitkit.shared.ServiceError import java.io.File import javax.inject.Inject @@ -73,7 +73,7 @@ class MigrationService @Inject constructor( val channelMonitor = read32BytesChannelMonitor(monitor, entropySource, signerProvider).takeIf { it.is_ok } ?.let { it as? ChannelMonitorDecodeResultTuple }?.res?._b ?: throw ServiceError.LdkToLdkNodeMigration - val fundingTx = channelMonitor._funding_txo._a._txid?.reversedArray()?.hex + val fundingTx = channelMonitor._funding_txo._a._txid?.reversedArray()?.toHex() ?: throw ServiceError.LdkToLdkNodeMigration val index = channelMonitor._funding_txo._a._index val key = "${fundingTx}_$index" diff --git a/app/src/main/java/to/bitkit/shared/Crypto.kt b/app/src/main/java/to/bitkit/shared/Crypto.kt new file mode 100644 index 000000000..297029505 --- /dev/null +++ b/app/src/main/java/to/bitkit/shared/Crypto.kt @@ -0,0 +1,148 @@ +package to.bitkit.shared + +import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPrivateKey +import org.bouncycastle.jcajce.provider.asymmetric.ec.BCECPublicKey +import org.bouncycastle.jce.ECNamedCurveTable +import org.bouncycastle.jce.interfaces.ECPrivateKey +import org.bouncycastle.jce.interfaces.ECPublicKey +import org.bouncycastle.jce.provider.BouncyCastleProvider +import org.bouncycastle.jce.spec.ECPrivateKeySpec +import org.bouncycastle.jce.spec.ECPublicKeySpec +import org.bouncycastle.util.BigIntegers +import to.bitkit.ext.fromHex +import java.math.BigInteger +import java.security.KeyFactory +import java.security.KeyPairGenerator +import java.security.MessageDigest +import java.security.Security +import javax.crypto.Cipher +import javax.crypto.KeyAgreement +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class Crypto @Inject constructor() { + @Suppress("ArrayInDataClass") + data class KeyPair( + val privateKey: ByteArray, + val publicKey: ByteArray, + ) + + @Suppress("ArrayInDataClass") + data class EncryptedPayload( + val cipher: ByteArray, + val iv: ByteArray, + val tag: ByteArray, + ) + + private val params = ECNamedCurveTable.getParameterSpec("secp256k1") + private val transformation = "AES/GCM/NoPadding" + + init { + // TODO move init to VM (to enable error handling on UI)? + try { + val provider = Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) + when { + provider == null -> Security.addProvider(BouncyCastleProvider()) + provider::class.java != BouncyCastleProvider::class.java -> { + // We substitute the outdated BC provider registered in Android + Security.removeProvider(BouncyCastleProvider.PROVIDER_NAME) + Security.insertProviderAt(BouncyCastleProvider(), 1) + } + } + } catch (e: Exception) { + throw CryptoError.SecurityProviderSetupFailed + } + } + + fun generateKeyPair(): KeyPair { + try { + val (privateKey, publicKey) = KeyPairGenerator.getInstance("EC", "BC").run { + initialize(params) + val keys = generateKeyPair() + val private = (keys.private as BCECPrivateKey).run { BigIntegers.asUnsignedByteArray(32, d) } + val public = (keys.public as BCECPublicKey).run { q.getEncoded(true) } + private to public + } + + return KeyPair( + privateKey = privateKey, + publicKey = publicKey, + ) + } catch (e: Exception) { + throw CryptoError.KeypairGenerationFailed + } + } + + fun generateSharedSecret( + privateKeyBytes: ByteArray, + nodePubkey: String, + derivationName: String? = null, + ): ByteArray { + try { + val keyFactory = KeyFactory.getInstance("EC", "BC") + val privateKey = keyFactory.generatePrivate(ECPrivateKeySpec(BigInteger(1, privateKeyBytes), params)) + val publicKey = let { + val publicKeyPoint = params.curve.decodePoint(nodePubkey.fromHex()) + keyFactory.generatePublic(ECPublicKeySpec(publicKeyPoint, params)) + } + + val baseSecret = KeyAgreement.getInstance("ECDH", "BC").run { + // init(privateKey); doPhase(publicKey, true); generateSecret() + val sharedPoint = (publicKey as ECPublicKey).q.multiply((privateKey as ECPrivateKey).d) + sharedPoint.getEncoded(true) + } + + if (derivationName != null) { + val bytes = derivationName.toByteArray() + val merged = baseSecret + bytes + return sha256d(merged) + } + + return baseSecret + } catch (e: Exception) { + throw CryptoError.SharedSecretGenerationFailed + } + } + + fun encrypt(blob: ByteArray, secretKey: ByteArray): EncryptedPayload { + require(secretKey.size == 32) { "Key must be 256 bits (32 bytes) for AES-256-GCM" } + val key = SecretKeySpec(secretKey, "AES") + + val cipher = Cipher.getInstance(transformation).apply { init(Cipher.ENCRYPT_MODE, key) } + val result = cipher.doFinal(blob) + + return EncryptedPayload( + cipher = result.sliceArray(0 until result.size - 16), + tag = result.sliceArray(result.size - 16 until result.size), + iv = cipher.iv, + ) + } + + fun decrypt(encryptedPayload: EncryptedPayload, secretKey: ByteArray): ByteArray { + try { + require(encryptedPayload.tag.size == 16) { "Tag must be 128 bits (8 bytes) for AES-GCM" } + val key = SecretKeySpec(secretKey, "AES") + + val spec = GCMParameterSpec(128, encryptedPayload.iv) + val cipher = Cipher.getInstance(transformation).apply { init(Cipher.DECRYPT_MODE, key, spec) } + + return cipher.doFinal(encryptedPayload.cipher + encryptedPayload.tag) + } catch (e: Exception) { + throw CryptoError.DecryptionFailed + } + } + + private fun sha256d(input: ByteArray): ByteArray { + return MessageDigest.getInstance("SHA-256").run { digest(digest(input)) } + } +} + +sealed class CryptoError(message: String) : AppError(message) { + data object SharedSecretGenerationFailed : CryptoError("Shared secret generation failed") + data object SecurityProviderSetupFailed : CryptoError("Security provider setup failed") + data object KeypairGenerationFailed : CryptoError("Keypair generation failed") + data object DecryptionFailed : CryptoError("Decryption failed") +} diff --git a/app/src/main/java/to/bitkit/shared/Errors.kt b/app/src/main/java/to/bitkit/shared/Errors.kt index 8cc500bcb..4d576022d 100644 --- a/app/src/main/java/to/bitkit/shared/Errors.kt +++ b/app/src/main/java/to/bitkit/shared/Errors.kt @@ -24,7 +24,8 @@ import org.bitcoindevkit.WalletCreationException import org.lightningdevkit.ldknode.BuildException import org.lightningdevkit.ldknode.NodeException -open class AppError(override val message: String) : Exception(message) { +// TODO add cause as inner exception +open class AppError(override val message: String? = null) : Exception(message) { companion object { @Suppress("ConstPropertyName") private const val serialVersionUID = 1L diff --git a/app/src/main/java/to/bitkit/shared/Perf.kt b/app/src/main/java/to/bitkit/shared/Perf.kt index b39ea367e..3e2d01320 100644 --- a/app/src/main/java/to/bitkit/shared/Perf.kt +++ b/app/src/main/java/to/bitkit/shared/Perf.kt @@ -2,6 +2,7 @@ package to.bitkit.shared import android.util.Log import to.bitkit.env.Tag.PERF +import java.time.Instant import kotlin.system.measureTimeMillis internal inline fun measured( @@ -19,3 +20,18 @@ internal inline fun measured( return result } + +internal inline fun withPerformanceLogging(block: () -> T): T { + val startTime = System.currentTimeMillis() + val startTimeFormatted = Instant.ofEpochMilli(startTime).toString() + Log.v(PERF, "Start Time: $startTimeFormatted") + + val result: T = block() + + val endTime = System.currentTimeMillis() + val endTimeFormatted = Instant.ofEpochMilli(endTime).toString() + val duration = (endTime - startTime) / 1000.0 + Log.v(PERF, "End Time: $endTimeFormatted, Duration: $duration seconds") + + return result +} diff --git a/app/src/main/java/to/bitkit/ui/MainActivity.kt b/app/src/main/java/to/bitkit/ui/MainActivity.kt index cd25cc5ee..552d2e8f3 100644 --- a/app/src/main/java/to/bitkit/ui/MainActivity.kt +++ b/app/src/main/java/to/bitkit/ui/MainActivity.kt @@ -40,7 +40,6 @@ import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.runtime.toMutableStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext @@ -201,7 +200,7 @@ fun ErrorScreen(uiState: MainUiState.Error) { private fun NotificationButton() { val context = LocalContext.current var canPush by remember { - mutableStateOf(!context.requiresPermission(notificationPermission)) + mutableStateOf(!context.requiresPermission(postNotificationsPermission)) } val permissionLauncher = rememberLauncherForActivityResult( @@ -212,8 +211,8 @@ private fun NotificationButton() { } val onClick = { - if (context.requiresPermission(notificationPermission)) { - permissionLauncher.launch(notificationPermission) + if (context.requiresPermission(postNotificationsPermission)) { + permissionLauncher.launch(postNotificationsPermission) } else { pushNotification( title = "Bitkit Notification", diff --git a/app/src/main/java/to/bitkit/ui/Notifications.kt b/app/src/main/java/to/bitkit/ui/Notifications.kt index 35e1b30b2..6427396bb 100644 --- a/app/src/main/java/to/bitkit/ui/Notifications.kt +++ b/app/src/main/java/to/bitkit/ui/Notifications.kt @@ -46,6 +46,7 @@ internal fun Context.notificationBuilder( extra?.let { putExtras(it) } } val flags = FLAG_IMMUTABLE or FLAG_ONE_SHOT + // TODO: review if needed: val pendingIntent = PendingIntent.getActivity(this, 0, intent, flags) return NotificationCompat.Builder(this, channelId) @@ -63,8 +64,9 @@ internal fun pushNotification( extras: Bundle? = null, bigText: String? = null, id: Int = Random.nextInt(), + context: Context = currentActivity(), ): Int { - currentActivity().withPermission(notificationPermission) { + context.withPermission(postNotificationsPermission) { val builder = notificationBuilder(extras) .setContentTitle(title) .setContentText(text) @@ -83,7 +85,7 @@ inline fun Context.withPermission(permission: String, block: Context.() -> T block() } -val notificationPermission +val postNotificationsPermission get() = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { Manifest.permission.POST_NOTIFICATIONS } else { diff --git a/app/src/main/java/to/bitkit/ui/SharedViewModel.kt b/app/src/main/java/to/bitkit/ui/SharedViewModel.kt index 8af2b7dce..846eaa14f 100644 --- a/app/src/main/java/to/bitkit/ui/SharedViewModel.kt +++ b/app/src/main/java/to/bitkit/ui/SharedViewModel.kt @@ -12,10 +12,11 @@ import kotlinx.coroutines.tasks.await import to.bitkit.data.AppDb import to.bitkit.data.keychain.Keychain import to.bitkit.di.BgDispatcher -import to.bitkit.env.Tag.APP import to.bitkit.env.Tag.DEV +import to.bitkit.env.Tag.LDK import to.bitkit.env.Tag.LSP import to.bitkit.services.BlocktankService +import to.bitkit.services.LightningService import to.bitkit.services.OnChainService import javax.inject.Inject @@ -30,7 +31,21 @@ class SharedViewModel @Inject constructor( ) : ViewModel() { fun warmupNode() { // TODO make it concurrent, and wait for all to finish before trying to access `lightningService.node`, etc… - runBlocking { to.bitkit.services.warmupNode() } + runBlocking { + runCatching { + LightningService.shared.apply { + setup() + start() + sync() + } + OnChainService.shared.apply { + setup() + fullScan() + } + }.onFailure { + Log.e(LDK, "Node warmup error", it) + } + } } fun registerForNotifications(fcmToken: String? = null) { @@ -59,7 +74,7 @@ class SharedViewModel @Inject constructor( val key = "test" if (keychain.exists(key)) { val value = keychain.loadString(key) - Log.d(APP, "Keychain entry: $key = $value") + Log.d(DEV, "Keychain entry: $key = $value") keychain.delete(key) } keychain.saveString(key, "testValue") diff --git a/app/src/test/java/to/bitkit/ExampleUnitTest.kt b/app/src/test/java/to/bitkit/ExampleUnitTest.kt deleted file mode 100644 index 3ee8b816e..000000000 --- a/app/src/test/java/to/bitkit/ExampleUnitTest.kt +++ /dev/null @@ -1,16 +0,0 @@ -package to.bitkit - -import org.junit.Assert.assertEquals -import org.junit.Test - -/** - * Example local unit test, which will execute on the development machine (host). - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -class ExampleUnitTest { - @Test - fun addition_isCorrect() { - assertEquals(4, 2 + 2) - } -} \ No newline at end of file diff --git a/app/src/test/java/to/bitkit/shared/CryptoTest.kt b/app/src/test/java/to/bitkit/shared/CryptoTest.kt new file mode 100644 index 000000000..1b04feb87 --- /dev/null +++ b/app/src/test/java/to/bitkit/shared/CryptoTest.kt @@ -0,0 +1,107 @@ +package to.bitkit.shared + +import org.junit.Before +import org.junit.Test +import to.bitkit.env.Env.DERIVATION_NAME +import to.bitkit.ext.fromBase64 +import to.bitkit.ext.fromHex +import to.bitkit.ext.toHex +import to.bitkit.ext.toBase64 +import to.bitkit.fcm.EncryptedNotification +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +class CryptoTest { + private lateinit var sut: Crypto + + @Before + fun setUp() { + sut = Crypto() + } + + @Test + fun `it should generate valid shared secret from keypair`() { + val (privateKey, publicKey) = sut.generateKeyPair() + assertEquals(32, privateKey.size) + assertEquals(33, publicKey.size) + + val sharedSecret = sut.generateSharedSecret(privateKey, publicKey.toHex()) + assertEquals(33, sharedSecret.size) + + val sharedSecretHash = sut.generateSharedSecret(privateKey, publicKey.toHex(), DERIVATION_NAME) + assertEquals(32, sharedSecretHash.size) + } + + @Test + fun `it should decrypt payload it encrypted`() { + val derivationName = DERIVATION_NAME + + // Step 1: Client generates a key pair + val clientKeys = sut.generateKeyPair() + val clientPublicKey = clientKeys.publicKey + val clientPrivateKey = clientKeys.privateKey + + // Step 2: Server generates a key pair + val serverKeys = sut.generateKeyPair() + val serverPublicKey = serverKeys.publicKey + val serverPrivateKey = serverKeys.privateKey + + // Step 3: Server generates shared secret using its private key and client public key + val serverSecret = sut.generateSharedSecret(serverPrivateKey, clientPublicKey.toHex(), derivationName) + + // Step 4: Server encrypts data using the shared secret + val dataToEncrypt = "Hello from the server!" + val encrypted = sut.encrypt(dataToEncrypt.toByteArray(), serverSecret) + val response = EncryptedNotification( + cipher = encrypted.cipher.toBase64(), + iv = encrypted.iv.toHex(), + tag = encrypted.tag.toHex(), + publicKey = serverPublicKey.toHex(), + ) + + // Step 5: Client generates its shared secret using its private key and server public key + val clientSecret = sut.generateSharedSecret(clientPrivateKey, response.publicKey, derivationName) + + // Step 6: Client decrypts the payload using the shared secret + val decrypted = sut.decrypt( + encryptedPayload = Crypto.EncryptedPayload( + cipher = response.cipher.fromBase64(), + iv = response.iv.fromHex(), + tag = response.tag.fromHex(), + ), + secretKey = clientSecret, + ) + val decoded = decrypted.decodeToString() + + assertContentEquals(clientSecret, serverSecret) + assertEquals(dataToEncrypt, decoded) + } + + @Test + @Suppress("SpellCheckingInspection") + fun testBlocktankEncryptedPayload() { + val clientPrivateKey = "cc74b1a4fdcd35916c766d3318c5a93b7e33a36ebeff0463128bf284975c2680" + val serverPublicKey = "031e9923e689a181a803486b7d8c0d4a5aad360edb70c8bb413a98458d91652213" + val derivationName = "bitkit-notifications" + + val ciphertext = "l2fInfyw64gO12odo8iipISloQJ45Rc4WjFmpe95brdaAMDq+T/L9ZChcmMCXnR0J6BXd8sSIJe/0bmby8uSZZJuVCzwF76XHfY5oq0Y1/hKzyZTn8nG3dqfiLHnAPy1tZFQfm5ALgjwWnViYJLXoGFpXs7kLMA=".fromBase64() + val iv = "2b8ed77fd2198e3ed88cfaa794a246e8" + val tag = "caddd13746d6a6aed16176734964d3a3" + val decryptedPayload = """{"source":"blocktank","type":"incomingHtlc","payload":{"secretMessage":"hello"},"createdAt":"2024-09-18T13:33:52.555Z"}""" + + // Without derivationName + val sharedSecret = sut.generateSharedSecret(clientPrivateKey.fromHex(), serverPublicKey) + val sharedSecretOnServer = "028ce542975d6d7b2307c92e527d507b03ffb3d897eb2e0830d29f40d5efd80ee3".fromHex() + assertEquals(sharedSecretOnServer.toHex(), sharedSecret.toHex()) + + val sharedHash = sut.generateSharedSecret(clientPrivateKey.fromHex(), serverPublicKey, derivationName) + val sharedHashOnServer = "3a9d552cb16dfae40feae644254c4ca46cab82e570de5662aacc4018e33b609b".fromHex() + assertEquals(sharedHashOnServer.toHex(), sharedHash.toHex()) + + val encryptedPayload = Crypto.EncryptedPayload(ciphertext, iv.fromHex(), tag.fromHex()) + + val value = sut.decrypt(encryptedPayload, sharedHash) + + assertEquals(decryptedPayload, value.decodeToString()) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 636c04717..e039adfca 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ activityCompose = "1.9.2" agp = "8.6.0" appcompat = "1.7.0" bdk = "1.0.0-alpha.11" -bitcoinj = "0.16.3" +bouncyCastle = "1.78.1" composeBom = "2024.09.01" # https://developer.android.com/develop/ui/compose/bom/bom-mapping coreKtx = "1.13.1" datastorePrefs = "1.1.1" @@ -33,7 +33,7 @@ workRuntimeKtx = "2.9.1" activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" } appcompat = { module = "androidx.appcompat:appcompat", version.ref = "appcompat" } bdk-android = { module = "org.bitcoindevkit:bdk-android", version.ref = "bdk" } -bitcoinj-core = { module = "org.bitcoinj:bitcoinj-core", version.ref = "bitcoinj" } # replace with secp-ffi if published +bouncycastle-provider-jdk = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncyCastle" } # replace with secp-ffi if published compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" } compose-material3 = { module = "androidx.compose.material3:material3" }