2 Commits

Author SHA1 Message Date
LukeMackin
74619a2713 fix(android): 安全修复 — SHA256原生层计算+TOCTOU+域名白名单
- PackageInstallerModule: DigestInputStream边写边算SHA256, 原生层校验消除TOCTOU
- updater: remove expo-crypto, add download domain whitelist, 错误消息去敏感路径
- package.json: 移除expo-crypto依赖(不再需要JS侧SHA256)
2026-07-19 01:51:40 +08:00
LukeMackin
3e7c98a099 fix(android): 审查修复 — Kotlin编译错误+SHA256校验+权限检查
- PackageInstallerModule: fsync作用域修复, PendingIntent改用getLaunchIntentForPackage, 权限预检
- updater: expo-crypto SHA256校验, getLocalBuildTag优先级修正, parseVersion null安全
- ManifestBuild: 增加type字段(js/apk)替代size启发式
- app.json: fallbackToCacheTimeout 0→3000
- shared: catch静默→console.warn
2026-07-19 01:48:00 +08:00
6 changed files with 61 additions and 42 deletions

View File

@@ -30,7 +30,7 @@
"updates": { "updates": {
"enabled": true, "enabled": true,
"checkAutomatically": "ON_LOAD", "checkAutomatically": "ON_LOAD",
"fallbackToCacheTimeout": 0, "fallbackToCacheTimeout": 3000,
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/manifest/expo-manifest.json" "url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/manifest/expo-manifest.json"
}, },
"extra": { "extra": {

View File

@@ -2,6 +2,7 @@
"version": "0.1.0", "version": "0.1.0",
"builds": { "builds": {
"android": { "android": {
"type": "apk",
"tag": "v0.1.0-dav-android-1", "tag": "v0.1.0-dav-android-1",
"url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-1/gca-0.1.0.apk", "url": "https://git.childish-ghost.com/LukeMackin/Yuzu-GCA/releases/download/v0.1.0-dav-android-1/gca-0.1.0.apk",
"sha256": "PLACEHOLDER", "sha256": "PLACEHOLDER",

View File

@@ -4,24 +4,31 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import expo.modules.kotlin.modules.Module import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition import expo.modules.kotlin.modules.ModuleDefinition
import java.io.File import java.io.File
import java.io.FileInputStream import java.io.FileInputStream
import java.security.DigestInputStream
import java.security.MessageDigest
class PackageInstallerModule : Module() { class PackageInstallerModule : Module() {
override fun definition() = ModuleDefinition { override fun definition() = ModuleDefinition {
Name("PackageInstallerModule") Name("PackageInstallerModule")
AsyncFunction("installApk") { filePath: String -> AsyncFunction("installApk") { filePath: String, expectedSha256: String ->
installApk(filePath) installApk(filePath, expectedSha256)
} }
} }
private fun installApk(filePath: String) { private fun installApk(filePath: String, expectedSha256: String) {
val context: Context = appContext.reactContext ?: return val context: Context = appContext.reactContext ?: return
val apkFile = File(filePath) val apkFile = File(filePath)
if (!apkFile.exists()) throw Exception("APK file not found: $filePath") if (!apkFile.exists()) throw Exception("安装包不存在")
if (!context.packageManager.canRequestPackageInstalls()) {
throw SecurityException("REQUEST_INSTALL_PACKAGES 权限未授予")
}
val packageInstaller = context.packageManager.packageInstaller val packageInstaller = context.packageManager.packageInstaller
val sessionParams = PackageInstaller.SessionParams( val sessionParams = PackageInstaller.SessionParams(
@@ -32,18 +39,25 @@ class PackageInstallerModule : Module() {
val session = packageInstaller.openSession(sessionId) val session = packageInstaller.openSession(sessionId)
try { try {
// 边写入边计算 SHA256原子化避免 TOCTOU
val sha256 = MessageDigest.getInstance("SHA-256")
FileInputStream(apkFile).use { input -> FileInputStream(apkFile).use { input ->
val digestStream = DigestInputStream(input, sha256)
session.openWrite("package", 0, apkFile.length()).use { output -> session.openWrite("package", 0, apkFile.length()).use { output ->
input.copyTo(output) digestStream.copyTo(output)
}
session.fsync(output) session.fsync(output)
} }
}
// 安装完成后发送通知,用户点击即重启 val actualSha256 = sha256.digest().joinToString("") { "%02x".format(it) }
val intent = Intent(context, context.javaClass) if (actualSha256 != expectedSha256) {
intent.action = Intent.ACTION_MAIN throw SecurityException("SHA256校验失败")
}
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
?: throw Exception("无法获取启动Intent")
val pendingIntent = PendingIntent.getActivity( val pendingIntent = PendingIntent.getActivity(
context, 0, intent, context, 0, launchIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
) )

View File

@@ -14,7 +14,6 @@
"@yuzu-gca/shared": "workspace:*", "@yuzu-gca/shared": "workspace:*",
"expo": "~52.0.0", "expo": "~52.0.0",
"expo-constants": "~17.0.0", "expo-constants": "~17.0.0",
"expo-device": "~7.0.0",
"expo-file-system": "~18.0.0", "expo-file-system": "~18.0.0",
"expo-status-bar": "~2.0.0", "expo-status-bar": "~2.0.0",
"expo-updates": "~26.0.0", "expo-updates": "~26.0.0",

View File

@@ -1,6 +1,5 @@
import { parseVersion, checkUpdate, semverGt } from '@yuzu-gca/shared'; import { parseVersion, checkUpdate } from '@yuzu-gca/shared';
import Constants from 'expo-constants'; import Constants from 'expo-constants';
import * as Device from 'expo-device';
import * as FileSystem from 'expo-file-system'; import * as FileSystem from 'expo-file-system';
import { NativeModules, Platform } from 'react-native'; import { NativeModules, Platform } from 'react-native';
@@ -8,49 +7,63 @@ import { NativeModules, Platform } from 'react-native';
// 1. 版本号读取 // 1. 版本号读取
// ============================================================ // ============================================================
/** 从 app.json + expo-updates 获取本地版本 */
export function getLocalVersion(): string { export function getLocalVersion(): string {
return Constants.expoConfig?.version ?? '0.1.0'; return Constants.expoConfig?.version ?? '0.1.0';
} }
export function getLocalBuildTag(): string { export function getLocalBuildTag(): string {
const v = getLocalVersion(); const v = getLocalVersion();
return `v${v}-dav-android-${Constants.expoConfig?.ios?.buildNumber ?? Constants.expoConfig?.android?.versionCode ?? 1}`; const versionCode = Constants.expoConfig?.android?.versionCode ?? 1;
return `v${v}-dav-android-${versionCode}`;
} }
// ============================================================ // ============================================================
// 2. JS Bundle OTAexpo-updates 自动处理) // 2. JS Bundle OTAexpo-updates 自动处理)
// ============================================================ // ============================================================
/** 手动触发 OTA 检查expo-updates 已自动启动检查,此方法用于设置页手动刷新) */
export async function checkJsBundleUpdate(): Promise<{ export async function checkJsBundleUpdate(): Promise<{
hasUpdate: boolean; hasUpdate: boolean;
remoteVersion: string; remoteVersion: string;
}> { }> {
const localVersion = getLocalVersion(); const localVersion = getLocalVersion();
const localTag = getLocalBuildTag(); const localTag = getLocalBuildTag();
const local = parseVersion(localTag);
if (!local) return { hasUpdate: false, remoteVersion: localVersion };
const result = await checkUpdate('android', parseVersion(localTag)!); const result = await checkUpdate('android', local);
if (!result.shouldUpdate || !result.remoteVersion) { if (!result.shouldUpdate || !result.remoteVersion) {
return { hasUpdate: false, remoteVersion: localVersion }; return { hasUpdate: false, remoteVersion: localVersion };
} }
// expo-updates 会在检测到新 manifest 时自动下载 bundle return { hasUpdate: true, remoteVersion: result.remoteVersion.raw };
// 这里只返回状态;实际下载由 expo-updates 后台完成
return {
hasUpdate: semverGt(result.remoteVersion.raw.replace(/^v/, '').split('-')[0], localVersion),
remoteVersion: result.remoteVersion.raw,
};
} }
// ============================================================ // ============================================================
// 3. APK 静默安装(大更新) // 3. APK 静默安装(大更新)
// ============================================================ // ============================================================
const ALLOWED_DOWNLOAD_HOSTS = [
'git.childish-ghost.com',
'github.com',
];
const { PackageInstallerModule } = NativeModules; const { PackageInstallerModule } = NativeModules;
/** 后台下载 APK 并通过 PackageInstaller 静默安装 */ 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, sha256: string): Promise<void> { export async function downloadAndInstallApk(apkUrl: string, sha256: string): Promise<void> {
validateUrl(apkUrl);
const localPath = `${FileSystem.cacheDirectory ?? ''}gca-update.apk`; const localPath = `${FileSystem.cacheDirectory ?? ''}gca-update.apk`;
const download = FileSystem.createDownloadResumable( const download = FileSystem.createDownloadResumable(
@@ -66,19 +79,17 @@ export async function downloadAndInstallApk(apkUrl: string, sha256: string): Pro
const result = await download.downloadAsync(); const result = await download.downloadAsync();
if (!result?.uri) throw new Error('APK 下载失败'); if (!result?.uri) throw new Error('APK 下载失败');
console.log(`[OTA] 下载完成: ${result.uri}`);
// 调用原生 PackageInstaller 安装
if (Platform.OS === 'android' && PackageInstallerModule) { if (Platform.OS === 'android' && PackageInstallerModule) {
await PackageInstallerModule.installApk(result.uri); // SHA256 校验在原生层完成(避免 TOCTOU + 密码学误用)
console.log('[OTA] APK 已提交安装,用户下次重启生效'); await PackageInstallerModule.installApk(result.uri, sha256);
console.log('[OTA] SHA256校验通过APK已提交安装');
} else { } else {
throw new Error('PackageInstaller 原生模块不可用'); throw new Error('PackageInstaller 原生模块不可用');
} }
} }
// ============================================================ // ============================================================
// 4. 启动时一键检查JS OTA + APK 更新) // 4. 启动时一键检查
// ============================================================ // ============================================================
export async function onAppStartCheckUpdate(): Promise<{ export async function onAppStartCheckUpdate(): Promise<{
@@ -94,18 +105,10 @@ export async function onAppStartCheckUpdate(): Promise<{
return { jsUpdate: false, apkUpdate: false }; return { jsUpdate: false, apkUpdate: false };
} }
// JS OTA: expo-updates 自动处理,无需手动干预 if (result.build.type === 'apk') {
const jsUpdate = semverGt(
result.build.tag.replace(/^v/, '').split('-')[0],
localTag.replace(/^v/, '').split('-')[0],
);
// APK 大更新:需要下载 + PackageInstaller
if (result.build.size > 5_000_000) {
// >5MB = APK 安装包
await downloadAndInstallApk(result.build.url, result.build.sha256); await downloadAndInstallApk(result.build.url, result.build.sha256);
return { jsUpdate: false, apkUpdate: true }; return { jsUpdate: false, apkUpdate: true };
} }
return { jsUpdate, apkUpdate: false }; return { jsUpdate: true, apkUpdate: false };
} }

View File

@@ -18,6 +18,7 @@ export interface ManifestBuild {
url: string; url: string;
sha256: string; sha256: string;
size: number; size: number;
type: 'js' | 'apk';
minVersion?: string; minVersion?: string;
} }
@@ -67,7 +68,8 @@ export async function checkUpdate(
build, build,
remoteVersion, remoteVersion,
}; };
} catch { } catch (e) {
console.warn('[GCA] checkUpdate 失败:', e);
return { shouldUpdate: false, build: null, remoteVersion: null }; return { shouldUpdate: false, build: null, remoteVersion: null };
} }
} }