Add baresip:// provisioning link support via encrypted bundle fetch
This commit is contained in:
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