mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-21 13:42:38 -06:00
Merge pull request #32 from screentinker/fix/android-device-fixes
fix(android): OTA APK signature verification (Critical) + pairing-code visibility
This commit is contained in:
commit
171b69233c
|
|
@ -17,7 +17,7 @@
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:name=".RemoteDisplayApp"
|
android:name=".RemoteDisplayApp"
|
||||||
android:allowBackup="true"
|
android:allowBackup="false"
|
||||||
android:icon="@android:drawable/ic_media_play"
|
android:icon="@android:drawable/ic_media_play"
|
||||||
android:label="RemoteDisplay"
|
android:label="RemoteDisplay"
|
||||||
android:largeHeap="true"
|
android:largeHeap="true"
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ class ProvisioningActivity : AppCompatActivity() {
|
||||||
private lateinit var statusText: TextView
|
private lateinit var statusText: TextView
|
||||||
private lateinit var progressBar: ProgressBar
|
private lateinit var progressBar: ProgressBar
|
||||||
private lateinit var pairingSection: View
|
private lateinit var pairingSection: View
|
||||||
|
private lateinit var serverSection: View
|
||||||
|
|
||||||
private val connection = object : ServiceConnection {
|
private val connection = object : ServiceConnection {
|
||||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||||
|
|
@ -73,6 +74,7 @@ class ProvisioningActivity : AppCompatActivity() {
|
||||||
statusText = findViewById(R.id.statusText)
|
statusText = findViewById(R.id.statusText)
|
||||||
progressBar = findViewById(R.id.progressBar)
|
progressBar = findViewById(R.id.progressBar)
|
||||||
pairingSection = findViewById(R.id.pairingSection)
|
pairingSection = findViewById(R.id.pairingSection)
|
||||||
|
serverSection = findViewById(R.id.serverSection)
|
||||||
|
|
||||||
// Pre-fill if previously entered
|
// Pre-fill if previously entered
|
||||||
if (config.serverUrl.isNotEmpty()) {
|
if (config.serverUrl.isNotEmpty()) {
|
||||||
|
|
@ -135,9 +137,15 @@ class ProvisioningActivity : AppCompatActivity() {
|
||||||
wsService?.onRegistered = { deviceId ->
|
wsService?.onRegistered = { deviceId ->
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
progressBar.visibility = View.GONE
|
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
|
pairingSection.visibility = View.VISIBLE
|
||||||
pairingCodeText.text = wsService?.getPairingCode() ?: "------"
|
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
|
connectBtn.isEnabled = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import android.content.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.IntentFilter
|
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.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
|
|
@ -17,6 +20,7 @@ import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.security.MessageDigest
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class UpdateChecker(private val context: Context) {
|
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)")
|
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
|
// Install the APK
|
||||||
handler.post {
|
handler.post {
|
||||||
installApk(apkFile)
|
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<String> {
|
||||||
|
val sigs: Array<Signature>? = 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 {
|
private fun getAppVersion(): String {
|
||||||
return try {
|
return try {
|
||||||
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.0.0"
|
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.0.0"
|
||||||
|
|
|
||||||
|
|
@ -1,123 +1,145 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<LinearLayout
|
<!-- Scrolls so content is always reachable on short screens; the pairing code
|
||||||
|
auto-sizes to fit any screen width (phones, TVs, HD sticks). -->
|
||||||
|
<ScrollView
|
||||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:gravity="center"
|
android:fillViewport="true"
|
||||||
android:orientation="vertical"
|
android:background="#111827">
|
||||||
android:background="#111827"
|
|
||||||
android:padding="48dp"
|
|
||||||
android:keepScreenOn="true">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="RemoteDisplay"
|
|
||||||
android:textColor="#3B82F6"
|
|
||||||
android:textSize="36sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:layout_marginBottom="8dp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Digital Signage Player"
|
|
||||||
android:textColor="#94A3B8"
|
|
||||||
android:textSize="16sp"
|
|
||||||
android:layout_marginBottom="48dp" />
|
|
||||||
|
|
||||||
<!-- Server URL Section -->
|
|
||||||
<LinearLayout
|
<LinearLayout
|
||||||
android:layout_width="400dp"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center_horizontal|top"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:layout_marginBottom="24dp">
|
android:paddingHorizontal="32dp"
|
||||||
|
android:paddingTop="24dp"
|
||||||
|
android:paddingBottom="24dp"
|
||||||
|
android:keepScreenOn="true">
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Server URL"
|
android:text="RemoteDisplay"
|
||||||
android:textColor="#94A3B8"
|
|
||||||
android:textSize="14sp"
|
|
||||||
android:layout_marginBottom="8dp" />
|
|
||||||
|
|
||||||
<EditText
|
|
||||||
android:id="@+id/serverUrlInput"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
android:background="@drawable/input_background"
|
|
||||||
android:hint="http://192.168.1.100:3000"
|
|
||||||
android:textColorHint="#64748B"
|
|
||||||
android:textColor="#F1F5F9"
|
|
||||||
android:textSize="16sp"
|
|
||||||
android:padding="12dp"
|
|
||||||
android:inputType="textUri"
|
|
||||||
android:singleLine="true"
|
|
||||||
android:importantForAutofill="no" />
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
android:id="@+id/connectBtn"
|
|
||||||
android:layout_width="400dp"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
android:text="Connect"
|
|
||||||
android:textColor="#FFFFFF"
|
|
||||||
android:textSize="16sp"
|
|
||||||
android:textStyle="bold"
|
|
||||||
android:background="@drawable/button_primary"
|
|
||||||
android:layout_marginBottom="32dp" />
|
|
||||||
|
|
||||||
<ProgressBar
|
|
||||||
android:id="@+id/progressBar"
|
|
||||||
android:layout_width="48dp"
|
|
||||||
android:layout_height="48dp"
|
|
||||||
android:indeterminate="true"
|
|
||||||
android:visibility="gone" />
|
|
||||||
|
|
||||||
<!-- Pairing Section (shown after connection) -->
|
|
||||||
<LinearLayout
|
|
||||||
android:id="@+id/pairingSection"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:orientation="vertical"
|
|
||||||
android:gravity="center"
|
|
||||||
android:visibility="gone">
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="Pairing Code"
|
|
||||||
android:textColor="#94A3B8"
|
|
||||||
android:textSize="16sp"
|
|
||||||
android:layout_marginBottom="12dp" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/pairingCodeText"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:text="------"
|
|
||||||
android:textColor="#3B82F6"
|
android:textColor="#3B82F6"
|
||||||
android:textSize="64sp"
|
android:textSize="22sp"
|
||||||
android:textStyle="bold"
|
android:textStyle="bold"
|
||||||
android:fontFamily="monospace"
|
android:layout_marginBottom="4dp" />
|
||||||
android:letterSpacing="0.3" />
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Enter this code in the dashboard to pair this display"
|
android:text="Digital Signage Player"
|
||||||
android:textColor="#64748B"
|
android:textColor="#94A3B8"
|
||||||
android:textSize="14sp"
|
android:textSize="14sp"
|
||||||
android:layout_marginTop="16dp"
|
android:layout_marginBottom="20dp" />
|
||||||
android:gravity="center" />
|
|
||||||
|
<!-- Server URL Section (hidden once paired so the code has room) -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/serverSection"
|
||||||
|
android:layout_width="400dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:layout_marginBottom="24dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Server URL"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:layout_marginBottom="8dp" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/serverUrlInput"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:background="@drawable/input_background"
|
||||||
|
android:hint="http://192.168.1.100:3000"
|
||||||
|
android:textColorHint="#64748B"
|
||||||
|
android:textColor="#F1F5F9"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:inputType="textUri"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:importantForAutofill="no" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/connectBtn"
|
||||||
|
android:layout_width="400dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:text="Connect"
|
||||||
|
android:textColor="#FFFFFF"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:background="@drawable/button_primary"
|
||||||
|
android:layout_marginBottom="32dp" />
|
||||||
|
|
||||||
|
<ProgressBar
|
||||||
|
android:id="@+id/progressBar"
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:indeterminate="true"
|
||||||
|
android:visibility="gone" />
|
||||||
|
|
||||||
|
<!-- Pairing Section (shown after connection) -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/pairingSection"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:gravity="center"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Pairing Code"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<!-- FIXED-height box: autosize fits the text inside the bounded box and
|
||||||
|
gravity center vertically centers it, so the digits are never
|
||||||
|
clipped (the earlier wrap_content height clipped the glyph bottoms).
|
||||||
|
24-64sp fills the width on phones/TVs/sticks. -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/pairingCodeText"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="96dp"
|
||||||
|
android:text="------"
|
||||||
|
android:textColor="#3B82F6"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:letterSpacing="0.3"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:gravity="center"
|
||||||
|
app:autoSizeTextType="uniform"
|
||||||
|
app:autoSizeMinTextSize="24sp"
|
||||||
|
app:autoSizeMaxTextSize="64sp"
|
||||||
|
app:autoSizeStepGranularity="2sp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Enter this code in the dashboard to pair this display"
|
||||||
|
android:textColor="#64748B"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:layout_marginTop="16dp"
|
||||||
|
android:gravity="center" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/statusText"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="#94A3B8"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:gravity="center"
|
||||||
|
android:layout_marginTop="16dp" />
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
</ScrollView>
|
||||||
<TextView
|
|
||||||
android:id="@+id/statusText"
|
|
||||||
android:layout_width="wrap_content"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textColor="#94A3B8"
|
|
||||||
android:textSize="14sp"
|
|
||||||
android:layout_marginTop="16dp" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue