Add baresip:// provisioning link support via encrypted bundle fetch
This commit is contained in:
@ -30,7 +30,9 @@ configure<ApplicationExtension> {
|
||||
}
|
||||
buildTypes {
|
||||
debug {
|
||||
ndk { abiFilters.add("x86_64") }
|
||||
ndk {
|
||||
abiFilters.addAll(listOf("armeabi-v7a", "arm64-v8a"))
|
||||
}
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
|
||||
@ -85,6 +85,12 @@
|
||||
<action android:name="android.intent.action.DIAL" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="baresip" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<action android:name="android.intent.action.DIAL" />
|
||||
|
||||
@ -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}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
314
app/src/main/kotlin/com/tutpro/baresip/Provisioning.kt
Normal file
314
app/src/main/kotlin/com/tutpro/baresip/Provisioning.kt
Normal file
@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user