diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 5a4cc48..16cf27c 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -17,7 +17,7 @@ runOnUiThread { progressBar.visibility = View.GONE + // Hide the server/connect controls so the pairing code has the + // whole screen and stays visible on short/landscape phones. + serverSection.visibility = View.GONE + connectBtn.visibility = View.GONE pairingSection.visibility = View.VISIBLE pairingCodeText.text = wsService?.getPairingCode() ?: "------" - statusText.text = "Enter this code in the dashboard to pair this display" + // The instruction is shown once, inside the pairing section; don't + // duplicate it in statusText. + statusText.text = "" connectBtn.isEnabled = false } } diff --git a/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt b/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt index 588a8c6..2042afa 100644 --- a/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt +++ b/android/app/src/main/java/com/remotedisplay/player/service/UpdateChecker.kt @@ -5,6 +5,9 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.content.pm.Signature import android.net.Uri import android.os.Build import android.os.Environment @@ -17,6 +20,7 @@ import okhttp3.OkHttpClient import okhttp3.Request import org.json.JSONObject import java.io.File +import java.security.MessageDigest import java.util.concurrent.TimeUnit class UpdateChecker(private val context: Context) { @@ -107,6 +111,20 @@ class UpdateChecker(private val context: Context) { Log.i(TAG, "APK downloaded: ${apkFile.absolutePath} (${apkFile.length()} bytes)") + // SECURITY (#5 review): never install an APK we didn't sign. The update + // is fetched from a server-supplied URL, often over cleartext with no + // pinning - a MITM or compromised server could otherwise return a + // malicious APK and get it silently installed (REQUEST_INSTALL_PACKAGES). + // Verify the downloaded APK is our package AND signed by the same key as + // the currently-installed app before installing. An attacker can't forge + // our signature, so this holds even over an untrusted transport. + if (!verifyApkSignature(apkFile)) { + Log.e(TAG, "Refusing update: APK signature/package verification failed (tampered or MITM'd APK)") + apkFile.delete() + return + } + Log.i(TAG, "APK signature verified against installed app - proceeding to install") + // Install the APK handler.post { installApk(apkFile) @@ -179,6 +197,56 @@ class UpdateChecker(private val context: Context) { } } + // True only if the downloaded APK is this same package and shares a signing + // certificate with the installed app. Fail-closed on any error. + private fun verifyApkSignature(apkFile: File): Boolean { + return try { + val pm = context.packageManager + val flags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) + PackageManager.GET_SIGNING_CERTIFICATES else @Suppress("DEPRECATION") PackageManager.GET_SIGNATURES + val downloaded = pm.getPackageArchiveInfo(apkFile.absolutePath, flags) + if (downloaded == null) { + Log.e(TAG, "Could not parse downloaded APK") + return false + } + if (downloaded.packageName != context.packageName) { + Log.e(TAG, "APK package mismatch: ${downloaded.packageName} != ${context.packageName}") + return false + } + val installed = pm.getPackageInfo(context.packageName, flags) + val downloadedSigs = signingCertHashes(downloaded) + val installedSigs = signingCertHashes(installed) + if (downloadedSigs.isEmpty() || installedSigs.isEmpty()) { + Log.e(TAG, "Missing signing certificates (downloaded=${downloadedSigs.size}, installed=${installedSigs.size})") + return false + } + // Share at least one current signing certificate. + val match = downloadedSigs.any { it in installedSigs } + if (!match) Log.e(TAG, "APK signing certificate does not match installed app") + match + } catch (e: Exception) { + Log.e(TAG, "Signature verification error: ${e.message}", e) + false + } + } + + private fun signingCertHashes(info: PackageInfo): Set { + val sigs: Array? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + info.signingInfo?.apkContentsSigners + } else { + @Suppress("DEPRECATION") info.signatures + } + return sigs?.mapNotNull { sha256(it.toByteArray()) }?.toSet() ?: emptySet() + } + + private fun sha256(bytes: ByteArray): String? { + return try { + MessageDigest.getInstance("SHA-256").digest(bytes).joinToString("") { "%02x".format(it) } + } catch (e: Exception) { + null + } + } + private fun getAppVersion(): String { return try { context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.0.0" diff --git a/android/app/src/main/res/layout/activity_provisioning.xml b/android/app/src/main/res/layout/activity_provisioning.xml index 9726069..cbb5b4d 100644 --- a/android/app/src/main/res/layout/activity_provisioning.xml +++ b/android/app/src/main/res/layout/activity_provisioning.xml @@ -1,123 +1,145 @@ - + + android:fillViewport="true" + android:background="#111827"> - - - - - + android:paddingHorizontal="32dp" + android:paddingTop="24dp" + android:paddingBottom="24dp" + android:keepScreenOn="true"> - - - - -