diff --git a/packages/client-android/modules/package-installer/android/src/main/java/dev/yuzu/gca/PackageInstallerModule.kt b/packages/client-android/modules/package-installer/android/src/main/java/dev/yuzu/gca/PackageInstallerModule.kt index 6b139f5..6027c68 100644 --- a/packages/client-android/modules/package-installer/android/src/main/java/dev/yuzu/gca/PackageInstallerModule.kt +++ b/packages/client-android/modules/package-installer/android/src/main/java/dev/yuzu/gca/PackageInstallerModule.kt @@ -9,24 +9,25 @@ import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.ModuleDefinition import java.io.File import java.io.FileInputStream +import java.security.DigestInputStream +import java.security.MessageDigest class PackageInstallerModule : Module() { override fun definition() = ModuleDefinition { Name("PackageInstallerModule") - AsyncFunction("installApk") { filePath: String -> - installApk(filePath) + AsyncFunction("installApk") { filePath: String, expectedSha256: String -> + installApk(filePath, expectedSha256) } } - private fun installApk(filePath: String) { + private fun installApk(filePath: String, expectedSha256: String) { val context: Context = appContext.reactContext ?: return val apkFile = File(filePath) - if (!apkFile.exists()) throw Exception("APK file not found: $filePath") + if (!apkFile.exists()) throw Exception("安装包不存在") - // 检查 REQUEST_INSTALL_PACKAGES 权限 if (!context.packageManager.canRequestPackageInstalls()) { - throw SecurityException("REQUEST_INSTALL_PACKAGES 权限未授予,请在系统设置中允许") + throw SecurityException("REQUEST_INSTALL_PACKAGES 权限未授予") } val packageInstaller = context.packageManager.packageInstaller @@ -38,14 +39,21 @@ class PackageInstallerModule : Module() { val session = packageInstaller.openSession(sessionId) try { + // 边写入边计算 SHA256(原子化,避免 TOCTOU) + val sha256 = MessageDigest.getInstance("SHA-256") FileInputStream(apkFile).use { input -> + val digestStream = DigestInputStream(input, sha256) session.openWrite("package", 0, apkFile.length()).use { output -> - input.copyTo(output) + digestStream.copyTo(output) session.fsync(output) } } - // 安装完成后发送通知,用户点击即重启 + val actualSha256 = sha256.digest().joinToString("") { "%02x".format(it) } + if (actualSha256 != expectedSha256) { + throw SecurityException("SHA256校验失败") + } + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: throw Exception("无法获取启动Intent") val pendingIntent = PendingIntent.getActivity( diff --git a/packages/client-android/package.json b/packages/client-android/package.json index 76ef310..b47ce0f 100644 --- a/packages/client-android/package.json +++ b/packages/client-android/package.json @@ -14,7 +14,6 @@ "@yuzu-gca/shared": "workspace:*", "expo": "~52.0.0", "expo-constants": "~17.0.0", - "expo-crypto": "~14.0.0", "expo-file-system": "~18.0.0", "expo-status-bar": "~2.0.0", "expo-updates": "~26.0.0", diff --git a/packages/client-android/src/updater/index.ts b/packages/client-android/src/updater/index.ts index e9800c1..5f6360c 100644 --- a/packages/client-android/src/updater/index.ts +++ b/packages/client-android/src/updater/index.ts @@ -1,6 +1,5 @@ import { parseVersion, checkUpdate } from '@yuzu-gca/shared'; import Constants from 'expo-constants'; -import * as Crypto from 'expo-crypto'; import * as FileSystem from 'expo-file-system'; import { NativeModules, Platform } from 'react-native'; @@ -36,28 +35,35 @@ export async function checkJsBundleUpdate(): Promise<{ return { hasUpdate: false, remoteVersion: localVersion }; } - return { - hasUpdate: true, - remoteVersion: result.remoteVersion.raw, - }; + return { hasUpdate: true, remoteVersion: result.remoteVersion.raw }; } // ============================================================ // 3. APK 静默安装(大更新) // ============================================================ +const ALLOWED_DOWNLOAD_HOSTS = [ + 'git.childish-ghost.com', + 'github.com', +]; + const { PackageInstallerModule } = NativeModules; -/** 计算文件 SHA256 */ -async function sha256File(fileUri: string): Promise { - const base64 = await Crypto.digestStringAsync( - Crypto.CryptoDigestAlgorithm.SHA256, - await FileSystem.readAsStringAsync(fileUri, { encoding: FileSystem.EncodingType.Base64 }), - ); - return base64; +function validateUrl(url: string): void { + try { + const host = new URL(url).hostname; + if (!ALLOWED_DOWNLOAD_HOSTS.some(h => host === h || host.endsWith('.' + h))) { + throw new Error(`不允许的下载域名: ${host}`); + } + } catch (e) { + if (e instanceof TypeError) throw new Error('下载URL格式无效'); + throw e; + } } -export async function downloadAndInstallApk(apkUrl: string, expectedSha256: string): Promise { +export async function downloadAndInstallApk(apkUrl: string, sha256: string): Promise { + validateUrl(apkUrl); + const localPath = `${FileSystem.cacheDirectory ?? ''}gca-update.apk`; const download = FileSystem.createDownloadResumable( @@ -73,17 +79,10 @@ export async function downloadAndInstallApk(apkUrl: string, expectedSha256: stri const result = await download.downloadAsync(); if (!result?.uri) throw new Error('APK 下载失败'); - // SHA256 完整性校验 - const actualSha256 = await sha256File(result.uri); - if (actualSha256 !== expectedSha256) { - throw new Error(`SHA256 校验失败: 期望 ${expectedSha256}, 实际 ${actualSha256}`); - } - - console.log(`[OTA] 下载完成, SHA256 校验通过: ${result.uri}`); - if (Platform.OS === 'android' && PackageInstallerModule) { - await PackageInstallerModule.installApk(result.uri); - console.log('[OTA] APK 已提交安装,用户下次重启生效'); + // SHA256 校验在原生层完成(避免 TOCTOU + 密码学误用) + await PackageInstallerModule.installApk(result.uri, sha256); + console.log('[OTA] SHA256校验通过,APK已提交安装'); } else { throw new Error('PackageInstaller 原生模块不可用'); } @@ -111,6 +110,5 @@ export async function onAppStartCheckUpdate(): Promise<{ return { jsUpdate: false, apkUpdate: true }; } - // type === 'js': expo-updates 自动处理 return { jsUpdate: true, apkUpdate: false }; }