diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 614ba292..4ed57191 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -30,7 +30,9 @@ configure { } buildTypes { debug { - ndk { abiFilters.add("x86_64") } + ndk { + abiFilters.addAll(listOf("armeabi-v7a", "arm64-v8a")) + } } release { isMinifyEnabled = true diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 0c413901..4864530b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -85,6 +85,12 @@ + + + + + + diff --git a/app/src/main/kotlin/com/tutpro/baresip/MainActivity.kt b/app/src/main/kotlin/com/tutpro/baresip/MainActivity.kt index 880f797b..e85f8f33 100644 --- a/app/src/main/kotlin/com/tutpro/baresip/MainActivity.kt +++ b/app/src/main/kotlin/com/tutpro/baresip/MainActivity.kt @@ -31,6 +31,8 @@ import androidx.activity.viewModels import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.LaunchedEffect import androidx.lifecycle.Observer +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.rememberNavController @@ -176,20 +178,26 @@ class MainActivity : ComponentActivity() { atStartup = intent.hasExtra("onStartup") - when (intent?.action) { - ACTION_DIAL, ACTION_CALL, ACTION_VIEW -> - if (BaresipService.isServiceRunning) - callAction( - applicationContext, - viewModel, - intent.data, - if (intent?.action == ACTION_CALL) "call" else "dial" - ) - else - BaresipService.callActionUri = intent.data - .toString().replace("%2B", "+") - .replace("%20", "") - .filterNot{setOf('-', ' ', '(', ')').contains(it)} + if (intent?.action == Intent.ACTION_VIEW && + "baresip".equals(intent.data?.scheme, ignoreCase = true) + ) { + handleProvisioningIntent(this, viewModel, intent) + } else { + when (intent?.action) { + ACTION_DIAL, ACTION_CALL, ACTION_VIEW -> + if (BaresipService.isServiceRunning) + callAction( + applicationContext, + viewModel, + intent.data, + if (intent?.action == ACTION_CALL) "call" else "dial" + ) + else + BaresipService.callActionUri = intent.data + .toString().replace("%2B", "+") + .replace("%20", "") + .filterNot{setOf('-', ' ', '(', ')').contains(it)} + } } val permissions = if (Build.VERSION.SDK_INT >= 33) @@ -370,6 +378,10 @@ class MainActivity : ComponentActivity() { isCallLogIntent(intent) -> { handleCallLogIntent() } + intent.action == Intent.ACTION_VIEW && + intent.data?.scheme.equals("baresip", ignoreCase = true) -> { + handleProvisioningIntent(applicationContext, viewModel, intent) + } intent.action in listOf(ACTION_DIAL, ACTION_CALL, ACTION_VIEW) -> { callAction( applicationContext, @@ -446,4 +458,36 @@ class MainActivity : ComponentActivity() { exitProcess(0) } + private fun handleProvisioningIntent(ctx: Context, viewModel: ViewModel, intent: Intent) { + val data = intent.data ?: run { + Log.w(TAG, "Provisioning intent has no data") + return + } + val endpoint = data.getQueryParameter("endpoint") ?: return + val extension = data.getQueryParameter("extension") ?: return + Log.i(TAG, "Provisioning from $endpoint ext=$extension") + + lifecycleScope.launch { + try { + val payload = Provisioning.fetchAndDecryptBundle(ctx, endpoint, extension) + val acc = Provisioning.applyBundle(ctx, payload) + if (acc == null) throw Provisioning.ProvisioningException("Failed to apply bundle") + Log.i(TAG, "Provisioning applied: ${acc.aor}") + viewModel.navigateToHome() + if (BaresipService.isServiceRunning) { + val restart = Intent(ctx, BaresipService::class.java) + restart.action = "Stop" + ContextCompat.startForegroundService(ctx, restart) + } else { + val restartApp = Intent(ctx, MainActivity::class.java) + restartApp.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK) + ctx.startActivity(restartApp) + exitProcess(0) + } + } catch (e: Provisioning.ProvisioningException) { + Log.e(TAG, "Provisioning failed: ${e.message}") + } + } + } + } diff --git a/app/src/main/kotlin/com/tutpro/baresip/Provisioning.kt b/app/src/main/kotlin/com/tutpro/baresip/Provisioning.kt new file mode 100644 index 00000000..7711310a --- /dev/null +++ b/app/src/main/kotlin/com/tutpro/baresip/Provisioning.kt @@ -0,0 +1,314 @@ +package com.tutpro.baresip + +import android.content.Context +import android.net.Uri +import android.security.keystore.KeyGenParameterSpec +import android.security.keystore.KeyProperties +import android.util.Base64 +import android.util.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File +import java.net.URL +import java.security.KeyPairGenerator +import java.security.KeyStore +import java.security.PrivateKey +import java.security.PublicKey +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec +import javax.net.ssl.HttpsURLConnection + +object Provisioning { + + const val ALIAS = "baresip_provisioning" + private const val RSA_KEY_SIZE = 2048 + private const val GCM_TAG_LENGTH = 128 + private const val SERVER_TIMEOUT_MS = 20_000 + + data class BundlePayload( + val connectString: String, + val clientCertPem: String? = null, + val clientKeyPem: String? = null, + val caCertsPem: String? = null, + val sipVerifyServer: String? = null, + val sipTransport: String? = null, + val accountName: String? = null, + val username: String? = null, + val password: String? = null, + val displayName: String? = null, + val outbound1: String? = null, + val outbound2: String? = null, + val register: Boolean? = null, + val regInt: Int? = null, + val checkOrigin: Boolean? = null, + val mediaEnc: String? = null, + val mediaNat: String? = null, + val stunServer: String? = null, + val stunUser: String? = null, + val stunPass: String? = null, + val rtcpMux: Boolean? = null, + val rel100: Boolean? = null, + val dtmfMode: Int? = null, + val answerMode: Int? = null, + val autoRedirect: Boolean? = null, + val vmUri: String? = null, + val countryCode: String? = null, + val telProvider: String? = null, + val numericKeypad: Boolean? = null, + val defaultAccount: Boolean? = null, + val customParams: String? = null, + ) + + fun hasKeyPair(): Boolean { + return try { + val ks = KeyStore.getInstance("AndroidKeyStore") + ks.load(null) + ks.containsAlias(ALIAS) + } catch (e: Exception) { + Log.w(TAG, "hasKeyPair check failed: ${e.message}") + false + } + } + + suspend fun ensureKeyPair(): PublicKey = withContext(Dispatchers.IO) { + try { + val ks = KeyStore.getInstance("AndroidKeyStore") + ks.load(null) + if (ks.containsAlias(ALIAS)) { + val pub = ks.getCertificate(ALIAS).publicKey + return@withContext pub + } + } catch (e: Exception) { + Log.w(TAG, "ensureKeyPair reload failed: ${e.message}") + } + + val kpg = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_RSA) + val spec = KeyGenParameterSpec.Builder( + ALIAS, + KeyProperties.PURPOSE_DECRYPT or KeyProperties.PURPOSE_ENCRYPT + ) + .setAlgorithmParameterSpec(java.security.spec.RSAKeyGenParameterSpec(RSA_KEY_SIZE, java.security.spec.RSAKeyGenParameterSpec.F4)) + .setDigests(KeyProperties.DIGEST_SHA256, KeyProperties.DIGEST_SHA512) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_RSA_OAEP) + .setRandomizedEncryptionRequired(true) + .build() + + kpg.initialize(spec) + val kp = kpg.generateKeyPair() + kp.public + } + + fun publicKeyPem(publicKey: PublicKey): String { + val b64 = Base64.encodeToString(publicKey.encoded, Base64.NO_WRAP) + return "-----BEGIN PUBLIC KEY-----\n$b64\n-----END PUBLIC KEY-----" + } + + suspend fun fetchAndDecryptBundle( + ctx: Context, + endpoint: String, + extension: String + ): BundlePayload = withContext(Dispatchers.IO) { + try { + val pub = ensureKeyPair() + val enrollment = JSONObject() + enrollment.put("extension", extension) + enrollment.put("public_key", Base64.encodeToString(pub.encoded, Base64.NO_WRAP)) + + val url = Uri.parse(endpoint).buildUpon() + .appendPath("bundle") + .build() + .toString() + + val conn = URL(url).openConnection() as HttpsURLConnection + conn.requestMethod = "POST" + conn.setRequestProperty("Content-Type", "application/json") + conn.doOutput = true + conn.connectTimeout = SERVER_TIMEOUT_MS + conn.readTimeout = SERVER_TIMEOUT_MS + conn.outputStream.use { os -> + os.write(enrollment.toString().toByteArray(Charsets.UTF_8)) + os.flush() + } + + val code = conn.responseCode + if (code !in 200..299) { + val err = conn.errorStream?.bufferedReader()?.use { it.readText() } ?: "HTTP $code" + throw ProvisioningException("Provisioning failed: $err") + } + + val body = conn.inputStream.bufferedReader().use { it.readText() } + val root = JSONObject(body) + val encryptedKeyB64 = root.optString("encrypted_key") + ?: throw ProvisioningException("Missing encrypted_key") + val ivB64 = root.optString("iv") + ?: throw ProvisioningException("Missing iv") + val ciphertextB64 = root.optString("ciphertext") + ?: throw ProvisioningException("Missing ciphertext") + val tagB64 = root.optString("tag") + ?: throw ProvisioningException("Missing tag") + + val encryptedKey = Base64.decode(encryptedKeyB64, Base64.NO_WRAP) + val iv = Base64.decode(ivB64, Base64.NO_WRAP) + val ciphertext = Base64.decode(ciphertextB64, Base64.NO_WRAP) + val tag = Base64.decode(tagB64, Base64.NO_WRAP) + + val aesKey = rsaDecrypt(encryptedKey) + val plaintext = aesGcmDecrypt(aesKey, iv, ciphertext, tag) + val payload = JSONObject(String(plaintext, Charsets.UTF_8)) + val connectString = payload.optString("connect_string") + ?: throw ProvisioningException("Missing connect_string in bundle") + + BundlePayload( + connectString = connectString, + clientCertPem = payload.optString("client_cert", null), + clientKeyPem = payload.optString("client_key", null), + caCertsPem = payload.optString("ca_certs", null), + sipVerifyServer = payload.optString("sip_verify_server"), + sipTransport = payload.optString("transport"), + accountName = payload.optString("account_name"), + username = payload.optString("username"), + password = payload.optString("password"), + displayName = payload.optString("display_name"), + outbound1 = payload.optString("outbound1"), + outbound2 = payload.optString("outbound2"), + register = if (payload.has("register")) payload.getBoolean("register") else null, + regInt = if (payload.has("reg_int")) payload.getInt("reg_int") else null, + checkOrigin = if (payload.has("check_origin")) payload.getBoolean("check_origin") else null, + mediaEnc = payload.optString("media_enc", null), + mediaNat = payload.optString("media_nat", null), + stunServer = payload.optString("stun_server", null), + stunUser = payload.optString("stun_user", null), + stunPass = payload.optString("stun_pass", null), + rtcpMux = if (payload.has("rtcp_mux")) payload.getBoolean("rtcp_mux") else null, + rel100 = if (payload.has("rel100")) payload.getBoolean("rel100") else null, + dtmfMode = if (payload.has("dtmf_mode")) payload.getInt("dtmf_mode") else null, + answerMode = if (payload.has("answer_mode")) payload.getInt("answer_mode") else null, + autoRedirect = if (payload.has("auto_redirect")) payload.getBoolean("auto_redirect") else null, + vmUri = payload.optString("vm_uri", null), + countryCode = payload.optString("country_code", null), + telProvider = payload.optString("tel_provider", null), + numericKeypad = if (payload.has("numeric_keypad")) payload.getBoolean("numeric_keypad") else null, + defaultAccount = if (payload.has("default_account")) payload.getBoolean("default_account") else null, + customParams = payload.optString("custom_params", null), + ) + } catch (e: ProvisioningException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Provisioning fetch failed", e) + throw ProvisioningException("Provisioning error: ${e.message}") + } + } + + suspend fun applyBundle(ctx: Context, payload: BundlePayload): Account? = withContext(Dispatchers.IO) { + try { + val filesPath = BaresipService.filesPath + val file = File(filesPath) + if (!file.exists()) file.mkdirs() + + var connectString = payload.connectString.trim() + val transport = payload.sipTransport?.trim()?.lowercase() + if ((transport == "tls" || transport == "wss") && !connectString.contains("transport=")) { + connectString = if (connectString.contains("?")) { + "$connectString&transport=$transport" + } else { + "$connectString;transport=$transport" + } + } + + val existing = Account.ofAor(connectString) + val ua = if (existing != null) UserAgent.ofAor(existing.aor) else null + val acc = ua?.account ?: run { + val aor = if (connectString.startsWith("sip:")) connectString else "sip:$connectString" + val uap = UserAgent.uaAlloc( + "<$aor>;stunserver=\"stun:stun.l.google.com:19302\";regq=0.5;pubint=0;regint=0;check_origin=no;mwi=no" + ) + if (uap == 0L) return@withContext null + UserAgent.ofUap(uap)?.account ?: return@withContext null + } + + payload.displayName?.let { acc.displayName = it } + payload.username?.let { acc.authUser = it } + if (!payload.password.isNullOrBlank()) { + BaresipService.aorPasswords[acc.aor] = payload.password + } + payload.outbound1?.let { if (acc.outbound.size > 0) acc.outbound[0] = it else acc.outbound.add(it) } + payload.outbound2?.let { if (acc.outbound.size > 1) acc.outbound[1] = it else if (it.isNotBlank()) acc.outbound.add(it) } + if (payload.register != null) { + acc.regint = if (payload.register) payload.regInt ?: REGISTRATION_INTERVAL else 0 + acc.configuredRegInt = payload.regInt ?: REGISTRATION_INTERVAL + } + payload.checkOrigin?.let { acc.checkOrigin = it } + payload.mediaEnc?.let { acc.mediaEnc = it } + payload.mediaNat?.let { acc.mediaNat = it } + payload.stunServer?.let { acc.stunServer = it } + payload.stunUser?.let { acc.stunUser = it } + payload.stunPass?.let { acc.stunPass = it } + payload.rtcpMux?.let { acc.rtcpMux = it } + if (payload.rel100 != null) { + acc.rel100Mode = if (payload.rel100) Api.REL100_ENABLED else Api.REL100_DISABLED + } + payload.dtmfMode?.let { acc.dtmfMode = it } + payload.answerMode?.let { acc.answerMode = it } + payload.autoRedirect?.let { acc.autoRedirect = it } + payload.vmUri?.let { acc.vmUri = it } + payload.countryCode?.let { acc.countryCode = it } + payload.telProvider?.let { acc.telProvider = it } + payload.numericKeypad?.let { acc.numericKeypad = it } + payload.defaultAccount?.let { if (it) UserAgent.ofAor(acc.aor)?.makeDefault() } + payload.customParams?.let { acc.customParams = it } + payload.accountName?.let { acc.nickName = it } + + payload.caCertsPem?.let { + File("$filesPath/ca_certs.crt").writeText(it, Charsets.UTF_8) + } + + payload.clientCertPem?.let { cert -> + File("$filesPath/cert.pem").writeText(cert, Charsets.UTF_8) + } + + payload.clientKeyPem?.let { key -> + File("$filesPath/cert_key.pem").writeText(key, Charsets.UTF_8) + } + + if (!payload.sipVerifyServer.isNullOrBlank()) { + val config = File("$filesPath/config") + if (config.exists()) { + val cfg = config.readText(Charsets.UTF_8) + config.writeText(cfg.replace("sip_verify_server ${Config.variable("sip_verify_server")}", "sip_verify_server ${payload.sipVerifyServer}"), Charsets.UTF_8) + } + } + + withContext(Dispatchers.Main) { + Account.saveAccounts() + UserAgent.ofAor(acc.aor)?.let { if (acc.regint > 0) it.reRegister() } + } + + acc + } catch (e: Exception) { + Log.e(TAG, "Failed to apply bundle", e) + null + } + } + + private fun rsaDecrypt(encrypted: ByteArray): ByteArray { + val ks = KeyStore.getInstance("AndroidKeyStore") + ks.load(null) + val privateKey = ks.getKey(ALIAS, null) as PrivateKey + val cipher = Cipher.getInstance("RSA/ECB/OAEPWithSHA-256AndMGF1Padding") + cipher.init(Cipher.DECRYPT_MODE, privateKey) + return cipher.doFinal(encrypted) + } + + private fun aesGcmDecrypt(key: ByteArray, iv: ByteArray, ciphertext: ByteArray, tag: ByteArray): ByteArray { + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(GCM_TAG_LENGTH, iv)) + val combined = ByteArray(ciphertext.size + tag.size) + System.arraycopy(ciphertext, 0, combined, 0, ciphertext.size) + System.arraycopy(tag, 0, combined, ciphertext.size, tag.size) + return cipher.doFinal(combined) + } + + class ProvisioningException(message: String) : Exception(message) +}