Add baresip:// provisioning link support via encrypted bundle fetch

This commit is contained in:
2026-08-18 19:35:20 +01:00
parent 11619a9130
commit 77bfe351f2
4 changed files with 381 additions and 15 deletions

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