Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
73 changes: 73 additions & 0 deletions android/app/src/main/java/com/openclaw/android/ArtifactSecurity.kt
Original file line number Diff line number Diff line change
@@ -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()
}
46 changes: 38 additions & 8 deletions android/app/src/main/java/com/openclaw/android/JsBridge.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}
}

Expand Down Expand Up @@ -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 {
Expand Down
9 changes: 9 additions & 0 deletions android/app/src/main/java/com/openclaw/android/UrlResolver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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")
}
}
}