diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 4abb867d..7f0e3126 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -36,6 +36,16 @@ android { "WWW_URL", "\"https://github.com/AidanPark/openclaw-android-app/releases/download/v1.0.0/www.zip\"", ) + buildConfigField( + "String", + "WWW_VERSION", + "null", + ) + buildConfigField( + "String", + "WWW_SHA256", + "null", + ) buildConfigField( "String", "CONFIG_URL", diff --git a/android/app/src/main/java/com/openclaw/android/ArtifactSecurity.kt b/android/app/src/main/java/com/openclaw/android/ArtifactSecurity.kt new file mode 100644 index 00000000..73ea5912 --- /dev/null +++ b/android/app/src/main/java/com/openclaw/android/ArtifactSecurity.kt @@ -0,0 +1,73 @@ +package com.openclaw.android + +import java.io.File +import java.security.MessageDigest +import java.util.Locale + +internal object ArtifactSecurity { + private val sha256Pattern = Regex("^[A-Fa-f0-9]{64}$") + + fun requireVersion( + version: String?, + component: String, + ): String { + val normalized = version?.trim() + require(!normalized.isNullOrEmpty()) { "$component version is required" } + return normalized + } + + fun requireSha256( + sha256: String?, + component: String, + ): String { + val normalized = sha256?.trim() + require(!normalized.isNullOrEmpty()) { "$component SHA-256 is required" } + require(sha256Pattern.matches(normalized)) { + "$component SHA-256 must be a 64-character hex digest" + } + return normalized.lowercase(Locale.US) + } + + fun sha256Hex(file: File): String { + val digest = MessageDigest.getInstance("SHA-256") + file.inputStream().use { input -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + while (true) { + val read = input.read(buffer) + if (read == END_OF_STREAM) break + digest.update(buffer, 0, read) + } + } + return digest.digest().toHexString() + } + + fun resolveInsideDirectory( + rootDir: File, + entryName: String, + ): File { + require(entryName.isNotBlank()) { "Zip entry name is empty" } + val root = rootDir.canonicalFile + val target = File(root, entryName).canonicalFile + require(target.path.startsWith(root.path + File.separator)) { + "Unsafe zip entry: $entryName" + } + return target + } + + private fun ByteArray.toHexString(): String { + val hex = CharArray(size * HEX_CHARS_PER_BYTE) + forEachIndexed { index, byte -> + val value = byte.toInt() and BYTE_MASK + hex[index * HEX_CHARS_PER_BYTE] = HEX_CHARS[value ushr NIBBLE_BITS] + hex[index * HEX_CHARS_PER_BYTE + 1] = HEX_CHARS[value and NIBBLE_MASK] + } + return String(hex) + } + + private const val END_OF_STREAM = -1 + private const val BYTE_MASK = 0xff + private const val NIBBLE_MASK = 0x0f + private const val NIBBLE_BITS = 4 + private const val HEX_CHARS_PER_BYTE = 2 + private val HEX_CHARS = "0123456789abcdef".toCharArray() +} diff --git a/android/app/src/main/java/com/openclaw/android/JsBridge.kt b/android/app/src/main/java/com/openclaw/android/JsBridge.kt index 9c97d6cb..4ff0f9df 100644 --- a/android/app/src/main/java/com/openclaw/android/JsBridge.kt +++ b/android/app/src/main/java/com/openclaw/android/JsBridge.kt @@ -569,30 +569,60 @@ class JsBridge( private suspend fun updateWww() { try { - val url = UrlResolver(activity).getWwwUrl() + val component = UrlResolver(activity).getWwwComponent() + val version = ArtifactSecurity.requireVersion(component.version, "www") + val expectedSha256 = ArtifactSecurity.requireSha256(component.sha256, "www") val stagingWww = java.io.File(activity.cacheDir, "www-staging") stagingWww.deleteRecursively() stagingWww.mkdirs() emitProgress("www", PROGRESS_DOWNLOAD, "Downloading...") - val zipFile = java.io.File(activity.cacheDir, "www.zip") - java.net.URL(url).openStream().use { input -> - zipFile.outputStream().use { output -> input.copyTo(output) } - } + val zipFile = downloadVerifiedWww(component.url, version, expectedSha256) emitProgress("www", PROGRESS_EXTRACT, "Extracting...") - extractZipToDir(zipFile, stagingWww) - zipFile.delete() + try { + extractZipToDir(zipFile, stagingWww) + } finally { + zipFile.delete() + } emitProgress("www", PROGRESS_APPLY, "Applying...") val wwwDir = bootstrapManager.wwwDir wwwDir.deleteRecursively() wwwDir.parentFile?.mkdirs() stagingWww.renameTo(wwwDir) + activity.getSharedPreferences("openclaw", 0).edit().putString("www_version", version).apply() activity.runOnUiThread { activity.reloadWebView() } } catch (e: Exception) { emitProgress("www", PROGRESS_START, "Update failed: ${e.message}") + throw e + } + } + + private fun downloadVerifiedWww( + url: String, + version: String, + expectedSha256: String, + ): java.io.File { + val zipFile = java.io.File(activity.cacheDir, "www.zip") + zipFile.delete() + var verified = false + + try { + java.net.URL(url).openStream().use { input -> + zipFile.outputStream().use { output -> input.copyTo(output) } + } + val actualSha256 = ArtifactSecurity.sha256Hex(zipFile) + if (actualSha256 != expectedSha256) { + throw SecurityException("www SHA-256 mismatch for version $version") + } + verified = true + return zipFile + } finally { + if (!verified) { + zipFile.delete() + } } } @@ -625,7 +655,7 @@ class JsBridge( entry: java.util.zip.ZipEntry, targetDir: java.io.File, ) { - val destFile = java.io.File(targetDir, entry.name) + val destFile = ArtifactSecurity.resolveInsideDirectory(targetDir, entry.name) if (entry.isDirectory) { destFile.mkdirs() } else { diff --git a/android/app/src/main/java/com/openclaw/android/UrlResolver.kt b/android/app/src/main/java/com/openclaw/android/UrlResolver.kt index d30078a5..c3ed59d8 100644 --- a/android/app/src/main/java/com/openclaw/android/UrlResolver.kt +++ b/android/app/src/main/java/com/openclaw/android/UrlResolver.kt @@ -36,6 +36,15 @@ class UrlResolver( return config?.www?.url ?: BuildConfig.WWW_URL } + suspend fun getWwwComponent(): ComponentConfig { + val config = loadConfig() + return config?.www ?: ComponentConfig( + url = BuildConfig.WWW_URL, + version = BuildConfig.WWW_VERSION, + sha256 = BuildConfig.WWW_SHA256, + ) + } + private suspend fun loadConfig(): RemoteConfig? { // 1. Local cache if (configFile.exists()) { diff --git a/android/app/src/test/java/com/openclaw/android/ArtifactSecurityTest.kt b/android/app/src/test/java/com/openclaw/android/ArtifactSecurityTest.kt new file mode 100644 index 00000000..a825e37d --- /dev/null +++ b/android/app/src/test/java/com/openclaw/android/ArtifactSecurityTest.kt @@ -0,0 +1,83 @@ +package com.openclaw.android + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +class ArtifactSecurityTest { + @TempDir + lateinit var tempDir: File + + @Test + fun `requireSha256 normalizes valid digest`() { + val digest = "EA2AEBA8819E517DB711F8C32369E89E7C52CEE73E07930FF91185E1AB93F4F3" + + assertEquals( + "ea2aeba8819e517db711f8c32369e89e7c52cee73e07930ff91185e1ab93f4f3", + ArtifactSecurity.requireSha256(digest, "www"), + ) + } + + @Test + fun `requireSha256 rejects missing or malformed digest`() { + assertThrows(IllegalArgumentException::class.java) { + ArtifactSecurity.requireSha256(null, "www") + } + assertThrows(IllegalArgumentException::class.java) { + ArtifactSecurity.requireSha256("not-a-sha256", "www") + } + } + + @Test + fun `sha256Hex hashes file contents`() { + val file = File(tempDir, "www.zip") + file.writeText("www") + + assertEquals( + "7c2ecd07f155648431e0f94b89247d713c5786e1e73e953f2fe7eca39534cd6d", + ArtifactSecurity.sha256Hex(file), + ) + } + + @Test + fun `requireVersion rejects missing version`() { + assertThrows(IllegalArgumentException::class.java) { + ArtifactSecurity.requireVersion(null, "www") + } + assertThrows(IllegalArgumentException::class.java) { + ArtifactSecurity.requireVersion(" ", "www") + } + } + + @Test + fun `resolveInsideDirectory accepts nested entry`() { + val target = ArtifactSecurity.resolveInsideDirectory(tempDir, "assets/app.js") + + assertTrue(target.path.startsWith(tempDir.canonicalPath + File.separator)) + assertEquals("app.js", target.name) + } + + @Test + fun `resolveInsideDirectory rejects traversal entry`() { + assertThrows(IllegalArgumentException::class.java) { + ArtifactSecurity.resolveInsideDirectory(tempDir, "../escaped-proof.txt") + } + } + + @Test + fun `resolveInsideDirectory rejects empty entry`() { + assertThrows(IllegalArgumentException::class.java) { + ArtifactSecurity.resolveInsideDirectory(tempDir, "") + } + } + + @Test + fun `resolveInsideDirectory rejects absolute entry`() { + assertThrows(IllegalArgumentException::class.java) { + ArtifactSecurity.resolveInsideDirectory(tempDir, "/tmp/escaped-proof.txt") + } + } +}