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) }