From daa96302dd1f1eb8a1d3bc16cd24d1539e5a9175 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Mon, 27 Jul 2026 00:39:26 +0800 Subject: [PATCH] feat(update): secure signed plugin release sets --- docs/IMPLEMENTATION_HANDOFF.md | 58 +++++-- docs/SDK-API参考.md | 9 + docs/插件脚手架.md | 10 ++ package.json | 7 +- packages/common/README.md | 7 + packages/common/metro/index.js | 18 +- .../common/metro/trusted-config-keys.json | 3 - packages/common/package.json | 17 +- packages/common/security/base64.d.ts | 5 + packages/common/security/base64.js | 85 +++++++++ packages/common/security/canonical-json.d.ts | 1 + packages/common/security/canonical-json.js | 46 +++++ .../common/security/trusted-signing-keys.json | 3 + packages/common/src/config.ts | 7 + packages/common/src/crypto.ts | 10 +- packages/common/src/internalSecurity.ts | 68 ++++++++ packages/common/src/remoteConfig.ts | 55 ++++++ packages/common/src/sdk.ts | 72 +------- packages/common/src/secureCache.ts | 18 +- packages/common/tests/fixtures/README.md | 13 ++ ...rn-release-signature-vector.generated.json | 6 + packages/common/tests/lifecycle.test.ts | 1 + .../common/tests/plugin-signature.test.ts | 74 ++++++++ packages/common/tests/remote-config.test.ts | 24 +++ packages/common/tests/sdk.test.ts | 1 + .../common/tests/security-primitives.test.ts | 64 +++++++ packages/update/README.md | 24 +++ .../xuqm/update/XuqmPluginPackageStager.java | 29 +++- .../update/XuqmPluginPackageStagerTest.java | 14 ++ packages/update/scripts/release-config.mjs | 11 +- packages/update/scripts/release-package.mjs | 7 + .../update/scripts/release-package.test.mjs | 23 +++ packages/update/scripts/xuqm-config.mjs | 31 ++++ packages/update/scripts/xuqm-config.test.mjs | 43 ++++- packages/update/scripts/xuqm-rn.mjs | 24 +-- packages/update/scripts/xuqm-rn.test.mjs | 27 +++ packages/update/scripts/xuqm_release.mjs | 8 +- packages/update/src/NativeBundle.ts | 3 + packages/update/src/UpdateSDK.ts | 162 +++++++++++++++--- packages/update/src/index.ts | 2 +- packages/update/src/releaseSet.ts | 19 +- packages/update/src/releaseSetRequest.ts | 28 +++ packages/update/src/updateAvailability.ts | 13 ++ .../update/tests/release-set-request.test.ts | 46 +++++ packages/update/tests/releaseSet.test.ts | 13 +- .../update/tests/update-availability.test.ts | 25 +++ pnpm-lock.yaml | 30 ++-- scripts/sync-server-signature-vector.mjs | 67 ++++++++ tsconfig.json | 1 + 49 files changed, 1135 insertions(+), 197 deletions(-) delete mode 100644 packages/common/metro/trusted-config-keys.json create mode 100644 packages/common/security/base64.d.ts create mode 100644 packages/common/security/base64.js create mode 100644 packages/common/security/canonical-json.d.ts create mode 100644 packages/common/security/canonical-json.js create mode 100644 packages/common/security/trusted-signing-keys.json create mode 100644 packages/common/src/internalSecurity.ts create mode 100644 packages/common/src/remoteConfig.ts create mode 100644 packages/common/tests/fixtures/README.md create mode 100644 packages/common/tests/fixtures/rn-release-signature-vector.generated.json create mode 100644 packages/common/tests/plugin-signature.test.ts create mode 100644 packages/common/tests/remote-config.test.ts create mode 100644 packages/common/tests/security-primitives.test.ts create mode 100644 packages/update/src/releaseSetRequest.ts create mode 100644 packages/update/src/updateAvailability.ts create mode 100644 packages/update/tests/release-set-request.test.ts create mode 100644 packages/update/tests/update-availability.test.ts create mode 100644 scripts/sync-server-signature-vector.mjs diff --git a/docs/IMPLEMENTATION_HANDOFF.md b/docs/IMPLEMENTATION_HANDOFF.md index 2cd455c..9a66fde 100644 --- a/docs/IMPLEMENTATION_HANDOFF.md +++ b/docs/IMPLEMENTATION_HANDOFF.md @@ -1,6 +1,6 @@ # RN SDK 重构实施接管文档 -> 状态更新时间:2026-07-26 +> 状态更新时间:2026-07-27 > 当前实施范围:`@xuqm/rn-common`、`@xuqm/rn-bugcollect`、`@xuqm/rn-update`、`@xuqm/rn-xwebview` > 发布约束:所有 XuqmGroup npm/Maven 制品和服务部署只能通过 `https://jenkins.xuqinmin.com/` 的 Jenkins 完成。 @@ -32,6 +32,37 @@ 补检一次并通过 `onAutomaticCheck` 交给宿主决定 UI。账号变化会取消旧下载授权和 灰度缓存;后台补检失败不影响宿主登录。服务端权威字段 `allowAnonymousUpdateCheck` 缺失时安全默认禁止匿名检查。 +- Update 服务开关统一解析 `features.update`(兼容平铺 `updateEnabled`),缺失安全 + 默认关闭并进入最后成功配置缓存。所有显式检查/下载/安装入口在网络和原生调用前 + 抛出 `UPDATE_DISABLED`;自动启动路径正常跳过。 +- 插件候选已实现 Ed25519 不可变 manifest 验签:common 集中维护 canonical JSON、 + 可信 keyId 与 Hermes 运行时验签,Metro 构建期复用同一规则和密钥集合。候选在计划 + 前与安装前双重验签,Android stager 进一步绑定 buildId、bundleFormat 和 + bundleSha256;响应 ZIP `sha256` 以 `archiveSha256` 纳入同一签名,下载后仍使用 + 同值校验归档。未知 keyId、签名错误和字段篡改测试均已覆盖。 + common 加密、Ed25519 与 secureCache 复用 common/security 唯一纯 JS + Base64/Base64URL codec,不依赖 Hermes 全局 `atob/btoa`;非法字母表、长度、填充和 + 尾随位直接拒绝。canonical + JSON 对函数、symbol、非有限数、非普通对象、数组空位/undefined 确定性失败,避免 + Node、Hermes 与服务端实现各自容错产生不同签名正文。 + Server 的 `update-service/src/test/resources/rn-release-signature-vector.json` 是 + 唯一规范源。RN 测试只读明确标记 GENERATED/DO NOT EDIT 的仓内生成副本,保证独立 + Git/Jenkins checkout 可运行;`scripts/sync-server-signature-vector.mjs +--check|--write` 负责跨仓字节级同步。相邻 Server 缺失时常规单仓 validate 跳过跨仓 + 比较,发布前必须提供 Server checkout 并执行 `--check`。测试会逐字节比较 canonical + UTF-8 正文,并以同一 keyId、原始公钥和 Base64URL 签名完成验签。 +- release-set 检查已强制携带 appVersion、nativeApiLevel、nativeBaselineId;缺少 + baseline 在发请求前失败。发布 Token 只读取 Jenkins 注入的 XUQM_API_TOKEN, + `release.apiToken` 配置会被构建校验拒绝。 +- `xuqm.modules.json` 使用 `packageName` 保存 Android applicationId、使用 + `iosBundleId` 保存 iOS Bundle Identifier;相应字段仅在目标平台 + build/package/release 时必填。唯一 `resolveHostPackageId(config, platform)` 被 + 构建、在线配置验证、ZIP 期望身份和发布上传共同复用。wire 仍使用 + `packageName`,但不会再把 Android 身份写入 iOS ZIP。 +- 插件 ZIP v1 协议限制已与 Server 对齐:归档 50 MiB、单 entry 100 MiB、总解压 + 100 MiB、最多 4096 entries。Android stager 不再保留 5000 上限,4096/4097 和单 + entry 100 MiB 边界已有原生单测;CLI `zipSync` 不生成目录 entry,Server 与 stager + 均明确拒绝目录 entry。 - Debug 的 `XuqmRuntime.start()` 不做远程整包或插件检查;开发页仍可显式调用公开 check/install API 做一次性验证。 - 插件构建清单已从 `xuqm.config.json` 更名为 `xuqm.modules.json`;租户平台签发的 @@ -51,6 +82,11 @@ - 整包和插件发布前调用 `/api/sdk/build/config/validate`。服务不可达、配置过期、 revision 撤销或包名不匹配均阻断新 Release;已安装 App 运行时不查询吊销状态。 - 相同 versionName 的连续 Android Release 自动生成递增 versionCode 和独立 buildId; +- 2026-07-27 本地全量 `pnpm validate` 通过:六个 workspace TypeScript、common 42、 + bugcollect 11、xwebview 7、IM 3、update CLI/Metro 28 与 update domain 18 项测试 + 全部通过;update 发布包内容校验为 51 个文件。common npm dry-run 未包含测试目录及 + 生成签名向量。Server 相邻仓库存在时签名向量逐字节检查通过,模拟 Server checkout + 缺失的单仓 CI 也按契约独立通过。 同一工作区通过 `.xuqm-cache/release-build.json` 保证单调递增,跨工作区 CI 通过 `XUQM_VERSION_CODE` 和 `XUQM_BUILD_ID` 注入全局唯一流水线身份。 - Gradle 内嵌任务把 `buildId` 与 `bugCollectMode` 声明为显式 `@Input` 并传入 embed @@ -62,14 +98,15 @@ - 原生最终恢复入口只删除 SDK 私有 `files/rn-bundles`,并递增 reset generation; common/app 初始化后仅清除 `xuqm_update_*` 状态并确认该 generation,重试不会误清 宿主、BugCollect 或签名 SDK 数据。 -- 已执行全部包类型检查、common 30 项、bugcollect 11 项、update CLI 25 项、update - 领域 13 项、xwebview 7 项测试、update 包内容校验、Prettier 和 `git diff --check`, - 均通过;update 发布包内容为 49 个文件。`pnpm validate` 本次因 Corepack/pnpm - 尝试联网读取 registry 且要求交互确认重建 `node_modules` 而未执行成功,已使用仓内 - 已安装工具逐项执行等价门禁。`native-test` 的 Gradle 在任务配置前因本机 - `libnative-platform.dylib` 无法加载而失败,不能据此声称本轮原生 Java 已编译。 - 尚未执行宿主 Android 构建、设备运行、开发环境接口和真实 Source Map 上传;这些 - 结果不得从静态检查推断。 +- 已执行 `pnpm validate`:全部包类型检查、common 42 项、bugcollect 11 项、update + CLI 28 项、update 领域 18 项、xwebview 7 项和 IM 3 项测试均通过;update 包内容 + 校验通过,发布包为 51 个文件;Prettier 和 `git diff --check` 通过。 + 独立 `native-test` 的系统 Gradle 仍在任务配置前因本机 + `libnative-platform.dylib` 无法加载;改用 App4 Gradle 9.3.1 wrapper 后, + `:xuqm_rn-update:testDebugUnitTest --tests +com.xuqm.update.XuqmPluginPackageStagerTest` 共 25 tasks 构建并测试成功。 + 尚未执行完整宿主 Android 构建、设备运行、开发环境接口和真实 Source Map 上传; + 这些结果不得从当前门禁推断。 - License 离线激活 SDK 已审计:RNSDK 不存在对应包、API、依赖、脚本、文档或空目录; npm 的 `license: UNLICENSED` 元数据和 RN 插件事务 `activation` 不属于离线授权。 - 配置中的 `signingKey` 明确是客户端可提取的请求完整性/缓存派生材料,不是 License、 @@ -156,7 +193,8 @@ ### 3.3 已确认缺口 - RN update 已编码 `XuqmAppUpdateModule`,只桥接 XuqmGroup Android `sdk-update:2.0.0-SNAPSHOT`;Android Snapshot、Bridge 编译和 App4 完整 debug 构建已通过,模拟器安装事务仍待验证。 -- release-set 已统一使用 SHA-256,但签名 manifest 尚待实现。 +- release-set 已统一使用 SHA-256 和 Ed25519 签名 manifest;服务端仍需按同一 + canonical 字段契约签发 `keyId/signature` 并完成联调。 - 原生 state 已成为 Bundle 版本与事务状态唯一真相;App 更新提示缓存仍可使用 AsyncStorage,但不保存 Bundle 版本。 - 客户端已改为 `/api/v1/rn/release-set/check` 单次请求目标入口依赖闭包;租户平台服务端接口尚待同步实现。 - `nativeBaselineId` 已自动计算并进入内嵌 manifest/插件上传:覆盖 Android 原生源码、Gradle 配置、RN 与含原生代码的 npm 依赖,普通业务 JS 不影响指纹;Jenkins/服务端登记校验尚待实现。 diff --git a/docs/SDK-API参考.md b/docs/SDK-API参考.md index 477a72a..8af1016 100644 --- a/docs/SDK-API参考.md +++ b/docs/SDK-API参考.md @@ -67,6 +67,11 @@ await XuqmSDK.logout() ## update 插件检查边界 +Update 服务由平台远程配置 `features.update` 控制,缺失时默认关闭。显式调用 +`checkAppUpdate`、`downloadApk`、`downloadAndInstallApp`、`checkPluginRelease` 或 +`installPluginRelease` 时,关闭状态统一抛出 `UpdateDisabledError`,错误码为 +`UPDATE_DISABLED`,且不会先访问网络或原生安装模块;自动启动检查会正常跳过。 + ```ts const plan = await UpdateSDK.checkPluginRelease('buz1', { signal, @@ -87,4 +92,8 @@ await UpdateSDK.checkAndInstallPlugin('buz1', { - `checkPluginRelease()` 只检查版本和兼容依赖闭包,不下载、不激活;`timeoutMs` 只约束检查请求。 - `installPluginRelease()` 只安装此前确认的计划;本地基线已变化时抛出 `StaleReleaseSetError`。 - `checkAndInstallPlugin()` 用于宿主自动进入插件的场景;`checkTimeoutMs` 只传给检查阶段,下载仍由 `signal` 和进度回调控制。 +- release-set 请求固定包含 `appVersion`、`nativeApiLevel`、`nativeBaselineId`;缺少 + native baseline 时不发请求。服务端返回的插件候选必须具有平台 Ed25519 + `keyId/signature`,客户端在计划前和安装前验证不可变 manifest。 + 候选响应的 ZIP `sha256` 以 `archiveSha256` 纳入签名,下载后继续以同一值校验归档。 - common HTTP 的统一默认请求上限为 30 秒;宿主需要更短的交互等待时显式传上述参数,不得在页面复制 `Promise.race` 或第二套超时器。 diff --git a/docs/插件脚手架.md b/docs/插件脚手架.md index 36bbc76..ff6d07c 100644 --- a/docs/插件脚手架.md +++ b/docs/插件脚手架.md @@ -11,6 +11,16 @@ pnpm xuqm:doctor `init` 只创建缺失的统一配置和必要脚本,不覆盖宿主已有代码。唯一插件构建清单为根目录 `xuqm.modules.json`。它与租户平台签发、宿主不可编辑的 `config.xuqmconfig` 初始化文件职责完全分离。 +构建清单分别使用 `packageName` 保存 Android applicationId、使用 `iosBundleId` +保存 iOS Bundle Identifier。目标平台字段只在该平台 build/package/release 时必填。 +ZIP wire 字段统一名为 `packageName`,但其值必须由 CLI 按平台从上述两个配置字段解析; +发布时再与同一平台身份精确比较。 + +插件 ZIP v1 只有一套协议上限:归档不超过 50 MiB、单个 entry 解压后不超过 +100 MiB、全部 entry 解压后合计不超过 100 MiB、entry 总数不超过 4096。CLI +`zipSync` 只生成文件 entry,因此显式目录 entry 不属于 v1 协议,服务端和 Android +stager 都必须拒绝;禁止在任一端另设 5000 等平行上限。 + 开发启动也使用同一 CLI,不由宿主复制 Node 包装脚本: ```bash diff --git a/package.json b/package.json index 44e550c..262cc28 100644 --- a/package.json +++ b/package.json @@ -18,12 +18,13 @@ "registry": "https://nexus.xuqinmin.com/repository/npm-hosted/" }, "scripts": { - "format": "prettier --write AGENTS.md docs/IMPLEMENTATION_HANDOFF.md package.json packages/common packages/bugcollect packages/update packages/xwebview", - "format:check": "prettier --check AGENTS.md docs/IMPLEMENTATION_HANDOFF.md package.json packages/common packages/bugcollect packages/update packages/xwebview", + "format": "prettier --write AGENTS.md docs/IMPLEMENTATION_HANDOFF.md package.json scripts packages/common packages/bugcollect packages/update packages/xwebview", + "format:check": "prettier --check AGENTS.md docs/IMPLEMENTATION_HANDOFF.md package.json scripts packages/common packages/bugcollect packages/update packages/xwebview", + "signature-vector:check": "node scripts/sync-server-signature-vector.mjs --check", "test": "corepack pnpm --recursive --if-present test", "typecheck": "tsc --noEmit", "typecheck:all": "corepack pnpm --recursive --if-present typecheck", - "validate": "corepack pnpm format:check && corepack pnpm typecheck:all && corepack pnpm test" + "validate": "corepack pnpm signature-vector:check && corepack pnpm format:check && corepack pnpm typecheck:all && corepack pnpm test" }, "peerDependencies": { "@react-native-async-storage/async-storage": ">=1.21.0", diff --git a/packages/common/README.md b/packages/common/README.md index 2de217d..72696d3 100644 --- a/packages/common/README.md +++ b/packages/common/README.md @@ -45,6 +45,10 @@ common-only 项目不要放置 `.xuqmconfig`,也不要使用 `withXuqmConfig() Metro 预加载发起的自动初始化如果因临时网络或 DNS 问题失败,common 会终止该 Promise,避免形成未处理异常。随后第一个真正需要配置的扩展调用共享 `awaitInitialization()` 时,会使用同一份已解析配置重新尝试;配置、appKey 和平台地址不需要由页面或子 SDK 再次传入。持续失败由宿主入口统一记录一次,SDK 不重复打印同一错误。构建期配置损坏、密钥不匹配或 Schema 非法会直接阻断构建,禁止打出运行时才失败的安装包。 +平台远程配置中的 `features.update` 是 Update 服务开关;兼容字段 +`updateEnabled` 只在前者缺失时读取。两者都缺失时安全默认关闭,最后成功配置缓存会 +原样保存开关值。关闭 Update 不影响 common、宿主业务或其它扩展 SDK。 + ## 扩展包的唯一登录入口 应用登录成功后只调用一次: @@ -78,3 +82,6 @@ await XuqmSDK.logout() - `getDeviceInfo`、`getDeviceId`、`useApi`、`usePageApi`、`showToast` 等项目通用能力。 业务代码不得导入 `@xuqm/rn-common/internal`。该子路径仅供官方 `@xuqm` 扩展包接入公共生命周期。 +`internal-security` 同样只供官方扩展使用:配置 V2 和插件不可变 manifest 复用一份 +canonical JSON(键排序、无空白、null/undefined 字段省略)与可信 Ed25519 keyId +集合,不是宿主可替换的通用验签入口。 diff --git a/packages/common/metro/index.js b/packages/common/metro/index.js index f080e0c..9e90d11 100644 --- a/packages/common/metro/index.js +++ b/packages/common/metro/index.js @@ -22,7 +22,9 @@ const CONFIG_DIRECTORY_RELATIVE_PATH = 'src/assets/config' const CONFIG_MAGIC = 'XUQM-CONFIG-V2' const CONFIG_PASSPHRASE = 'xuqm-config-file-v2.2026.internal' const PBKDF2_ITERATIONS = 120_000 -const TRUSTED_CONFIG_KEYS = require('./trusted-config-keys.json') +const TRUSTED_CONFIG_KEYS = require('../security/trusted-signing-keys.json') +const { canonicalJson } = require('../security/canonical-json.js') +const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex') function decodeBase64Url(value) { return Buffer.from(value, 'base64url') @@ -32,18 +34,6 @@ function decodeBase64Url(value) { * 配置文件只在 Node/Metro 构建期解密。移动端运行时直接消费解析后的配置, * 避免 Hermes 在应用启动阶段执行 12 万次 PBKDF2 并阻塞 JS 线程。 */ -function canonicalJson(value) { - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]` - if (value && typeof value === 'object') { - return `{${Object.keys(value) - .filter(key => value[key] !== null && value[key] !== undefined) - .sort() - .map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) - .join(',')}}` - } - return JSON.stringify(value) -} - function assertConfigPayload(config, plaintext) { const allowedFields = new Set([ 'schemaVersion', @@ -107,7 +97,7 @@ function decryptConfigAtBuildTime(content, trustedKeys = TRUSTED_CONFIG_KEYS) { null, Buffer.from(unsignedToken, 'ascii'), { - key: Buffer.from(publicKeyBase64, 'base64'), + key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(publicKeyBase64, 'base64')]), format: 'der', type: 'spki', }, diff --git a/packages/common/metro/trusted-config-keys.json b/packages/common/metro/trusted-config-keys.json deleted file mode 100644 index 1b235da..0000000 --- a/packages/common/metro/trusted-config-keys.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "xuqm-config-dev-2026-07": "MCowBQYDK2VwAyEAFt9MBN8jNwCfalex4wg7kuy2DRAbXkGxZyA/Jaz+6YA=" -} diff --git a/packages/common/package.json b/packages/common/package.json index 9fa6b19..376cdac 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -13,6 +13,7 @@ "./download": "./src/download.ts", "./file-name": "./src/fileName.ts", "./internal": "./src/internal.ts", + "./internal-security": "./src/internalSecurity.ts", "./metro": "./metro/index.js", "./package.json": "./package.json", "./secure-cache": "./src/secureCache.ts", @@ -21,7 +22,8 @@ "metro": "metro/index.js", "files": [ "src", - "metro" + "metro", + "security" ], "private": false, "publishConfig": { @@ -33,6 +35,7 @@ }, "dependencies": { "@noble/ciphers": "2.2.0", + "@noble/curves": "2.2.0", "@noble/hashes": "2.2.0", "@types/semver": "7.7.1", "react-native-blob-util": "0.24.10", @@ -40,18 +43,18 @@ "zod": "4.4.3" }, "peerDependencies": { - "react": ">=18.0.0", - "react-native": ">=0.76.0", "@react-native-async-storage/async-storage": ">=1.21.0", - "axios": ">=1.0.0" + "axios": ">=1.0.0", + "react": ">=18.0.0", + "react-native": ">=0.76.0" }, "devDependencies": { - "typescript": "catalog:", - "@types/react": "^19.2.14", "@react-native-async-storage/async-storage": "^3.1.1", + "@types/react": "^19.2.14", "axios": "^1.18.0", "react": "19.2.7", "react-native": "0.86.0", - "tsx": "^4.23.1" + "tsx": "^4.23.1", + "typescript": "catalog:" } } diff --git a/packages/common/security/base64.d.ts b/packages/common/security/base64.d.ts new file mode 100644 index 0000000..1573fe8 --- /dev/null +++ b/packages/common/security/base64.d.ts @@ -0,0 +1,5 @@ +export function decodeBase64(value: string): Uint8Array +export function encodeBase64( + value: Uint8Array, + options?: { urlSafe?: boolean; padding?: boolean }, +): string diff --git a/packages/common/security/base64.js b/packages/common/security/base64.js new file mode 100644 index 0000000..3bc3770 --- /dev/null +++ b/packages/common/security/base64.js @@ -0,0 +1,85 @@ +'use strict' + +const STANDARD_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' +const URL_SAFE_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' + +/** + * 纯 JS Base64 编码,默认输出带填充的标准 Base64。 + * 协议调用方必须显式声明 URL-safe 与是否保留填充,避免现场字符串替换。 + */ +function encodeBase64(value, options = {}) { + if (!(value instanceof Uint8Array)) throw new TypeError('Base64 value must be Uint8Array') + const alphabet = options.urlSafe ? URL_SAFE_ALPHABET : STANDARD_ALPHABET + const padding = options.padding !== false + let output = '' + for (let offset = 0; offset < value.length; offset += 3) { + const first = value[offset] + const hasSecond = offset + 1 < value.length + const hasThird = offset + 2 < value.length + const second = hasSecond ? value[offset + 1] : 0 + const third = hasThird ? value[offset + 2] : 0 + output += alphabet[first >>> 2] + output += alphabet[((first & 0x03) << 4) | (second >>> 4)] + if (hasSecond) { + output += alphabet[((second & 0x0f) << 2) | (third >>> 6)] + } else if (padding) { + output += '=' + } + if (hasThird) { + output += alphabet[third & 0x3f] + } else if (padding) { + output += '=' + } + } + return output +} + +/** + * 严格解码标准 Base64 或 Base64URL,不依赖浏览器/Hermes 的 atob。 + * 接受有填充或无填充形式,但拒绝混合字母表、非法长度、非法填充和非零尾随位。 + */ +function decodeBase64(value) { + if (typeof value !== 'string') throw new TypeError('Base64 value must be a string') + if (value.length === 0) return new Uint8Array(0) + if (/[^A-Za-z0-9+/_=-]/.test(value)) throw new Error('Base64 contains invalid characters') + if (/[+\/]/.test(value) && /[-_]/.test(value)) { + throw new Error('Base64 cannot mix standard and URL-safe alphabets') + } + + const firstPadding = value.indexOf('=') + const data = firstPadding === -1 ? value : value.slice(0, firstPadding) + const padding = firstPadding === -1 ? '' : value.slice(firstPadding) + if (padding !== '' && padding !== '=' && padding !== '==') { + throw new Error('Base64 padding is invalid') + } + if (data.includes('=')) throw new Error('Base64 padding is invalid') + const remainder = data.length % 4 + if (remainder === 1) throw new Error('Base64 length is invalid') + const expectedPadding = remainder === 0 ? 0 : 4 - remainder + if (padding.length !== 0 && padding.length !== expectedPadding) { + throw new Error('Base64 padding length is invalid') + } + + const normalized = data.replace(/-/g, '+').replace(/_/g, '/') + const outputLength = Math.floor((normalized.length * 6) / 8) + const output = new Uint8Array(outputLength) + let accumulator = 0 + let bitCount = 0 + let outputIndex = 0 + for (const character of normalized) { + const sextet = STANDARD_ALPHABET.indexOf(character) + if (sextet < 0) throw new Error('Base64 contains invalid characters') + accumulator = (accumulator << 6) | sextet + bitCount += 6 + if (bitCount >= 8) { + bitCount -= 8 + output[outputIndex] = (accumulator >>> bitCount) & 0xff + outputIndex += 1 + accumulator &= (1 << bitCount) - 1 + } + } + if (accumulator !== 0) throw new Error('Base64 has non-zero trailing bits') + return output +} + +module.exports = { decodeBase64, encodeBase64 } diff --git a/packages/common/security/canonical-json.d.ts b/packages/common/security/canonical-json.d.ts new file mode 100644 index 0000000..43e92dc --- /dev/null +++ b/packages/common/security/canonical-json.d.ts @@ -0,0 +1 @@ +export function canonicalJson(value: unknown): string diff --git a/packages/common/security/canonical-json.js b/packages/common/security/canonical-json.js new file mode 100644 index 0000000..1c5f47b --- /dev/null +++ b/packages/common/security/canonical-json.js @@ -0,0 +1,46 @@ +'use strict' + +/** + * 安全协议唯一使用的 canonical JSON。对象键按字典序排列,null/undefined 字段省略; + * 非 JSON 协议值、数组空位和不确定的对象形态直接拒绝。 + */ +function canonicalJson(value) { + if (value === null) return 'null' + if (typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value) + if (typeof value === 'number') { + if (!Number.isFinite(value)) throw new TypeError('Canonical JSON requires finite numbers') + return JSON.stringify(value) + } + if (Array.isArray(value)) { + for (let index = 0; index < value.length; index += 1) { + if (!Object.prototype.hasOwnProperty.call(value, index) || value[index] === undefined) { + throw new TypeError('Canonical JSON arrays cannot contain holes or undefined') + } + } + return `[${value.map(canonicalJson).join(',')}]` + } + if (value && typeof value === 'object') { + const prototype = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError('Canonical JSON only supports plain objects') + } + const keys = Reflect.ownKeys(value) + if (keys.some(key => typeof key === 'symbol')) { + throw new TypeError('Canonical JSON does not support symbol keys') + } + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (!descriptor?.enumerable || !('value' in descriptor)) { + throw new TypeError('Canonical JSON only supports enumerable data properties') + } + } + return `{${keys + .filter(key => value[key] !== null && value[key] !== undefined) + .sort() + .map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`) + .join(',')}}` + } + throw new TypeError(`Canonical JSON does not support ${typeof value}`) +} + +module.exports = { canonicalJson } diff --git a/packages/common/security/trusted-signing-keys.json b/packages/common/security/trusted-signing-keys.json new file mode 100644 index 0000000..fcc5fed --- /dev/null +++ b/packages/common/security/trusted-signing-keys.json @@ -0,0 +1,3 @@ +{ + "xuqm-config-dev-2026-07": "Ft9MBN8jNwCfalex4wg7kuy2DRAbXkGxZyA/Jaz+6YA=" +} diff --git a/packages/common/src/config.ts b/packages/common/src/config.ts index f345e76..0de5c5c 100644 --- a/packages/common/src/config.ts +++ b/packages/common/src/config.ts @@ -13,6 +13,8 @@ export interface InternalInitOptions { export interface XuqmConfig { appKey: string + /** 已验签配置中的宿主包名,用于绑定插件签名,禁止由业务运行时改写。 */ + packageName: string /** 租户平台地址:配置、更新、插件发布等 Xuqm 平台接口统一使用该地址。 */ platformUrl: string /** 业务扩展服务地址,由租户平台远程配置返回,不能代替 platformUrl。 */ @@ -26,6 +28,8 @@ export interface XuqmConfig { // 崩溃采集服务(rn-bugcollect 使用) bugCollectApiUrl: string bugCollectEnabled: boolean + /** Update 服务必须由平台明确开启;缺失时安全关闭。 */ + updateEnabled: boolean /** Update 灰度检查是否要求共享登录态;由租户平台动态配置。 */ updateRequiresLogin: boolean configurationSource: 'remote' | 'cache' @@ -83,6 +87,7 @@ export interface XuqmRemoteConfig { pushEnabled?: boolean bugCollectApiUrl?: string bugCollectEnabled?: boolean + updateEnabled?: boolean updateRequiresLogin?: boolean } @@ -95,6 +100,7 @@ export function initConfigFromRemote( const apiUrl = remote.apiUrl ?? remote.imApiUrl ?? '' _config = { appKey: options.appKey, + packageName: options.packageName?.trim() ?? '', platformUrl, apiUrl, imWsUrl: remote.imWsUrl ?? '', @@ -104,6 +110,7 @@ export function initConfigFromRemote( pushEnabled: remote.pushEnabled ?? true, bugCollectApiUrl: remote.bugCollectApiUrl ?? '', bugCollectEnabled: options.bugCollectMode !== 'disabled' && (remote.bugCollectEnabled ?? false), + updateEnabled: remote.updateEnabled ?? false, // 服务端未下发匿名策略时按安全默认禁止匿名更新检查。 updateRequiresLogin: remote.updateRequiresLogin ?? true, configurationSource: options.configSource ?? 'remote', diff --git a/packages/common/src/crypto.ts b/packages/common/src/crypto.ts index ef18cc2..1fb4a9d 100644 --- a/packages/common/src/crypto.ts +++ b/packages/common/src/crypto.ts @@ -1,6 +1,7 @@ import { hmac } from '@noble/hashes/hmac.js' import { sha256 } from '@noble/hashes/sha2.js' import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js' +import { encodeBase64 } from '../security/base64.js' export interface DecryptedConfig { schemaVersion: 2 @@ -19,16 +20,11 @@ export interface DecryptedConfig { /** Encode arbitrary binary data without exceeding Hermes argument-count limits. */ export function bytesToBase64(value: Uint8Array): string { - let binary = '' - const chunkSize = 0x8000 - for (let offset = 0; offset < value.length; offset += chunkSize) { - binary += String.fromCharCode(...value.subarray(offset, offset + chunkSize)) - } - return btoa(binary) + return encodeBase64(value) } function base64UrlEncode(value: Uint8Array): string { - return bytesToBase64(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + return encodeBase64(value, { urlSafe: true, padding: false }) } export function hmacSha256Base64Url(secret: string, data: string): string { diff --git a/packages/common/src/internalSecurity.ts b/packages/common/src/internalSecurity.ts new file mode 100644 index 0000000..08df3d7 --- /dev/null +++ b/packages/common/src/internalSecurity.ts @@ -0,0 +1,68 @@ +import { ed25519 } from '@noble/curves/ed25519.js' +import { utf8ToBytes } from '@noble/hashes/utils.js' +import { decodeBase64 } from '../security/base64.js' +import { canonicalJson } from '../security/canonical-json.js' + +const TRUSTED_SIGNING_KEYS = require('../security/trusted-signing-keys.json') as Readonly< + Record +> + +export interface SignedPluginManifest { + appKey: string + packageName: string + platform: 'ANDROID' | 'IOS' + moduleId: string + type: 'common' | 'app' | 'buz' + version: string + appVersionRange: string + builtAgainstNativeBaselineId: string + buildId: string + bundleFormat: 'hermes-bytecode' | 'javascript' + commonVersionRange?: string + minNativeApiLevel: number + bundleSha256: string + archiveSha256: string +} + +export class PluginManifestSignatureError extends Error { + readonly name = 'PluginManifestSignatureError' + + constructor( + message: string, + public readonly code: 'UNKNOWN_SIGNING_KEY' | 'INVALID_SIGNATURE', + ) { + super(message) + } +} + +/** @internal 插件候选必须在生成计划和下载前通过同一可信密钥集合验签。 */ +export function verifySignedPluginManifest( + manifest: SignedPluginManifest, + keyId: string, + signature: string, + trustedKeys: Readonly> = TRUSTED_SIGNING_KEYS, +): void { + const publicKey = trustedKeys[keyId] + if (!publicKey) { + throw new PluginManifestSignatureError( + `[XuqmSecurity] Untrusted plugin signing key: ${keyId}`, + 'UNKNOWN_SIGNING_KEY', + ) + } + let valid = false + try { + valid = ed25519.verify( + decodeBase64(signature), + utf8ToBytes(canonicalJson(manifest)), + decodeBase64(publicKey), + ) + } catch { + valid = false + } + if (!valid) { + throw new PluginManifestSignatureError( + `[XuqmSecurity] Invalid plugin manifest signature: ${manifest.moduleId}@${manifest.version}`, + 'INVALID_SIGNATURE', + ) + } +} diff --git a/packages/common/src/remoteConfig.ts b/packages/common/src/remoteConfig.ts new file mode 100644 index 0000000..b8b3887 --- /dev/null +++ b/packages/common/src/remoteConfig.ts @@ -0,0 +1,55 @@ +import { z } from 'zod' +import type { XuqmRemoteConfig } from './config' + +const flatRemoteConfigSchema = z.object({ + apiUrl: z.string().optional(), + imApiUrl: z.string().optional(), + imWsUrl: z.string().optional(), + fileServiceUrl: z.string().optional(), + imEnabled: z.boolean().optional(), + pushEnabled: z.boolean().optional(), + bugCollectApiUrl: z.string().optional(), + bugCollectEnabled: z.boolean().optional(), + updateEnabled: z.boolean().optional(), + updateRequiresLogin: z.boolean().optional(), +}) + +const remoteConfigResponseSchema = flatRemoteConfigSchema + .extend({ + allowAnonymousUpdateCheck: z.boolean().optional(), + features: z + .object({ + im: z.boolean().optional(), + push: z.boolean().optional(), + bugCollect: z.boolean().optional(), + update: z.boolean().optional(), + }) + .optional(), + }) + .loose() + +/** 平台远程配置的唯一归一化入口;features 字段优先于历史 flat 字段。 */ +export function normalizeRemoteConfig(value: unknown): XuqmRemoteConfig { + const raw = remoteConfigResponseSchema.parse(value) + const features = raw.features ?? {} + return { + apiUrl: raw.apiUrl, + imApiUrl: raw.imApiUrl, + imWsUrl: raw.imWsUrl, + fileServiceUrl: raw.fileServiceUrl, + imEnabled: features.im ?? raw.imEnabled, + pushEnabled: features.push ?? raw.pushEnabled, + bugCollectApiUrl: raw.bugCollectApiUrl, + bugCollectEnabled: features.bugCollect ?? raw.bugCollectEnabled, + updateEnabled: features.update ?? raw.updateEnabled, + updateRequiresLogin: + raw.updateRequiresLogin ?? + (raw.allowAnonymousUpdateCheck === undefined ? undefined : !raw.allowAnonymousUpdateCheck), + } +} + +/** 旧缓存允许缺少 updateEnabled,最终由 XuqmConfig 按安全默认 false 收敛。 */ +export function parseCachedRemoteConfig(value: unknown): XuqmRemoteConfig | null { + const parsed = flatRemoteConfigSchema.safeParse(value) + return parsed.success ? parsed.data : null +} diff --git a/packages/common/src/sdk.ts b/packages/common/src/sdk.ts index 3ef8771..798a78a 100644 --- a/packages/common/src/sdk.ts +++ b/packages/common/src/sdk.ts @@ -1,5 +1,4 @@ import AsyncStorage from '@react-native-async-storage/async-storage' -import { z } from 'zod' import { _notifyInitialized, _setInitializationSnapshot, @@ -12,6 +11,7 @@ import { isInitialized, type InternalInitOptions, type XuqmLoginOptions, + type XuqmRemoteConfig, type XuqmUserInfo, } from './config' import { DEFAULT_TENANT_PLATFORM_URL } from './constants' @@ -21,6 +21,7 @@ import { _clearAccessToken, _saveAccessToken, configureHttp } from './http' import { retryWithBackoff } from './retry' import { isCacheRecordFresh, openLocalCache, sealLocalCache } from './secureCache' import type { DecryptedConfig } from './crypto' +import { normalizeRemoteConfig, parseCachedRemoteConfig } from './remoteConfig' let _initPromise: Promise | null = null let _resolvedConfigInitPromise: Promise | null = null @@ -33,52 +34,9 @@ let _sessionTransition: Promise = Promise.resolve() let _activeSessionKey: string | null = null const REMOTE_CONFIG_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1_000 const REMOTE_CONFIG_CACHE_PREFIX = '@xuqm_common:remote_config:v1:' -const flatRemoteConfigSchema = z.object({ - apiUrl: z.string().optional(), - imApiUrl: z.string().optional(), - imWsUrl: z.string().optional(), - fileServiceUrl: z.string().optional(), - imEnabled: z.boolean().optional(), - pushEnabled: z.boolean().optional(), - bugCollectApiUrl: z.string().optional(), - bugCollectEnabled: z.boolean().optional(), - updateRequiresLogin: z.boolean().optional(), -}) -const remoteConfigResponseSchema = z - .object({ - apiUrl: z.string().optional(), - imApiUrl: z.string().optional(), - imWsUrl: z.string().optional(), - fileServiceUrl: z.string().optional(), - imEnabled: z.boolean().optional(), - pushEnabled: z.boolean().optional(), - bugCollectApiUrl: z.string().optional(), - bugCollectEnabled: z.boolean().optional(), - updateRequiresLogin: z.boolean().optional(), - allowAnonymousUpdateCheck: z.boolean().optional(), - features: z - .object({ - im: z.boolean().optional(), - push: z.boolean().optional(), - bugCollect: z.boolean().optional(), - }) - .optional(), - }) - .loose() - interface CachedRemoteConfig { cachedAt: number - remote: { - apiUrl?: string - imApiUrl?: string - imWsUrl?: string - fileServiceUrl?: string - imEnabled?: boolean - pushEnabled?: boolean - bugCollectApiUrl?: string - bugCollectEnabled?: boolean - updateRequiresLogin?: boolean - } + remote: XuqmRemoteConfig } function runtimePlatform(): 'ANDROID' | 'IOS' { @@ -112,16 +70,16 @@ async function readCachedRemoteConfig( if (!encrypted) return null const raw = openLocalCache(encrypted, signingKey, initializationKey(options)) const cached = JSON.parse(raw) as CachedRemoteConfig - const parsedRemote = flatRemoteConfigSchema.safeParse(cached?.remote) + const parsedRemote = parseCachedRemoteConfig(cached?.remote) if ( !cached || !isCacheRecordFresh(cached.cachedAt, Date.now(), REMOTE_CONFIG_CACHE_TTL_MS) || - !parsedRemote.success + !parsedRemote ) { await AsyncStorage.removeItem(storageKey(options)) return null } - return { cachedAt: cached.cachedAt, remote: parsedRemote.data } + return { cachedAt: cached.cachedAt, remote: parsedRemote } } catch { await AsyncStorage.removeItem(storageKey(options)).catch(() => undefined) return null @@ -194,23 +152,7 @@ function startInitialization(options: InternalInitOptions, signingKey?: string): }, ) const json = (await res.json()) as Record - const raw = remoteConfigResponseSchema.parse(json.data ?? json) - const features = raw.features ?? {} - remote = { - apiUrl: raw.apiUrl, - imApiUrl: raw.imApiUrl, - imWsUrl: raw.imWsUrl, - fileServiceUrl: raw.fileServiceUrl, - imEnabled: features.im ?? raw.imEnabled, - pushEnabled: features.push ?? raw.pushEnabled, - bugCollectApiUrl: raw.bugCollectApiUrl, - bugCollectEnabled: features.bugCollect ?? raw.bugCollectEnabled, - updateRequiresLogin: - raw.updateRequiresLogin ?? - (raw.allowAnonymousUpdateCheck === undefined - ? undefined - : !raw.allowAnonymousUpdateCheck), - } + remote = normalizeRemoteConfig(json.data ?? json) await writeCachedRemoteConfig(options, remote, signingKey) } catch (error) { const cached = await readCachedRemoteConfig(options, signingKey) diff --git a/packages/common/src/secureCache.ts b/packages/common/src/secureCache.ts index 76310aa..ce417ab 100644 --- a/packages/common/src/secureCache.ts +++ b/packages/common/src/secureCache.ts @@ -1,23 +1,13 @@ import { gcm } from '@noble/ciphers/aes.js' import { sha256 } from '@noble/hashes/sha2.js' import { utf8ToBytes } from '@noble/hashes/utils.js' +import { decodeBase64, encodeBase64 } from '../security/base64.js' const CACHE_FORMAT = 'XUQM-CACHE-V1' const NONCE_BYTES = 12 function bytesToBase64Url(value: Uint8Array): string { - let binary = '' - for (let offset = 0; offset < value.length; offset += 0x8000) { - binary += String.fromCharCode(...value.subarray(offset, offset + 0x8000)) - } - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') -} - -function base64UrlToBytes(value: string): Uint8Array { - const padded = - value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4) - const binary = atob(padded) - return Uint8Array.from(binary, character => character.charCodeAt(0)) + return encodeBase64(value, { urlSafe: true, padding: false }) } function cacheKey(secret: string, context: string): Uint8Array { @@ -57,10 +47,10 @@ export function openLocalCache(payload: string, secret: string, context: string) if (parts.length !== 3 || parts[0] !== CACHE_FORMAT) { throw new Error('[XuqmSDK] LKG cache format is invalid.') } - const nonce = base64UrlToBytes(parts[1]) + const nonce = decodeBase64(parts[1]) if (nonce.length !== NONCE_BYTES) throw new Error('[XuqmSDK] LKG cache nonce is invalid.') const plaintext = gcm(cacheKey(secret, context), nonce, utf8ToBytes(context)).decrypt( - base64UrlToBytes(parts[2]), + decodeBase64(parts[2]), ) return utf8BytesToString(plaintext) } diff --git a/packages/common/tests/fixtures/README.md b/packages/common/tests/fixtures/README.md new file mode 100644 index 0000000..3cb5cc5 --- /dev/null +++ b/packages/common/tests/fixtures/README.md @@ -0,0 +1,13 @@ +# 测试夹具 + +`rn-release-signature-vector.generated.json` 由 Server 仓库的 +`update-service/src/test/resources/rn-release-signature-vector.json` 生成。 + +**GENERATED / DO NOT EDIT**:禁止手工修改。Server 是唯一规范源,更新时执行: + +```bash +node scripts/sync-server-signature-vector.mjs --write +node scripts/sync-server-signature-vector.mjs --check +``` + +该文件只位于测试目录,不进入 `@xuqm/rn-common` 发布包。 diff --git a/packages/common/tests/fixtures/rn-release-signature-vector.generated.json b/packages/common/tests/fixtures/rn-release-signature-vector.generated.json new file mode 100644 index 0000000..48d8a2f --- /dev/null +++ b/packages/common/tests/fixtures/rn-release-signature-vector.generated.json @@ -0,0 +1,6 @@ +{ + "keyId": "xuqm-rn-vector-2026-07", + "publicKeyRawBase64": "jkw/1R5aVg2NAwB7+xw2fR9i8phXi+2jt9l8QzuG8pU=", + "canonicalJson": "{\"appKey\":\"ak_vector\",\"appVersionRange\":\">=8.0.0 <9.0.0\",\"archiveSha256\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"buildId\":\"build-42\",\"builtAgainstNativeBaselineId\":\"android-sha256:abc\",\"bundleFormat\":\"hermes-bytecode\",\"bundleSha256\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"minNativeApiLevel\":2,\"moduleId\":\"common\",\"packageName\":\"com.xuqm.vector\",\"platform\":\"ANDROID\",\"type\":\"common\",\"version\":\"1.2.3\"}", + "signatureBase64Url": "p5GHekcL-wqM9yC7xNGRLMEwNVy7ECwVGwIdWkMJ9G85Pgr4XXca3kBXm3G-hLB0XZa76_tVmBNJhHkUuse6AQ" +} diff --git a/packages/common/tests/lifecycle.test.ts b/packages/common/tests/lifecycle.test.ts index d79c380..8f50f21 100644 --- a/packages/common/tests/lifecycle.test.ts +++ b/packages/common/tests/lifecycle.test.ts @@ -28,6 +28,7 @@ test('all extensions observe the same initialized config exactly once', async () assert.equal(getConfig().apiUrl, 'https://api.example.test') assert.equal(getConfig().platformUrl, 'https://platform.example.test') + assert.equal(getConfig().updateEnabled, false) assert.deepEqual(observed, ['shared-app:https://api.example.test']) unregister() }) diff --git a/packages/common/tests/plugin-signature.test.ts b/packages/common/tests/plugin-signature.test.ts new file mode 100644 index 0000000..0ac2ed8 --- /dev/null +++ b/packages/common/tests/plugin-signature.test.ts @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { decodeBase64 } from '../security/base64.js' +import { + PluginManifestSignatureError, + verifySignedPluginManifest, + type SignedPluginManifest, +} from '../src/internalSecurity' +import { canonicalJson } from '../security/canonical-json.js' + +interface ServerSignatureVector { + keyId: string + publicKeyRawBase64: string + canonicalJson: string + signatureBase64Url: string +} + +const vectorPath = new URL('./fixtures/rn-release-signature-vector.generated.json', import.meta.url) +const vector = JSON.parse(readFileSync(vectorPath, 'utf8')) as ServerSignatureVector +const manifest = JSON.parse(vector.canonicalJson) as SignedPluginManifest +const trustedKeys = { [vector.keyId]: vector.publicKeyRawBase64 } + +test('accepts the Server-owned Ed25519 verification vector byte-for-byte', () => { + assert.deepEqual( + Buffer.from(canonicalJson(manifest), 'utf8'), + Buffer.from(vector.canonicalJson, 'utf8'), + ) + assert.equal(decodeBase64(vector.publicKeyRawBase64).length, 32) + assert.equal(decodeBase64(vector.signatureBase64Url).length, 64) + assert.doesNotThrow(() => + verifySignedPluginManifest(manifest, vector.keyId, vector.signatureBase64Url, trustedKeys), + ) +}) + +test('canonical manifest omits null fields used only by non-common modules', () => { + assert.equal( + canonicalJson({ moduleId: 'common', commonVersionRange: null }), + '{"moduleId":"common"}', + ) +}) + +test('rejects an unknown plugin signing key', () => { + assert.throws( + () => verifySignedPluginManifest(manifest, 'unknown', vector.signatureBase64Url, trustedKeys), + (error: unknown) => + error instanceof PluginManifestSignatureError && error.code === 'UNKNOWN_SIGNING_KEY', + ) +}) + +test('rejects any immutable manifest field tampering', () => { + assert.throws( + () => + verifySignedPluginManifest( + { ...manifest, bundleSha256: 'b'.repeat(64) }, + vector.keyId, + vector.signatureBase64Url, + trustedKeys, + ), + (error: unknown) => + error instanceof PluginManifestSignatureError && error.code === 'INVALID_SIGNATURE', + ) + assert.throws( + () => + verifySignedPluginManifest( + { ...manifest, archiveSha256: 'd'.repeat(64) }, + vector.keyId, + vector.signatureBase64Url, + trustedKeys, + ), + (error: unknown) => + error instanceof PluginManifestSignatureError && error.code === 'INVALID_SIGNATURE', + ) +}) diff --git a/packages/common/tests/remote-config.test.ts b/packages/common/tests/remote-config.test.ts new file mode 100644 index 0000000..347f5c0 --- /dev/null +++ b/packages/common/tests/remote-config.test.ts @@ -0,0 +1,24 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { normalizeRemoteConfig, parseCachedRemoteConfig } from '../src/remoteConfig' + +test('platform update feature is authoritative over the flat compatibility field', () => { + assert.equal( + normalizeRemoteConfig({ features: { update: false }, updateEnabled: true }).updateEnabled, + false, + ) + assert.equal(normalizeRemoteConfig({ updateEnabled: true }).updateEnabled, true) +}) + +test('missing update feature stays absent so XuqmConfig can safely default it off', () => { + assert.equal(normalizeRemoteConfig({}).updateEnabled, undefined) + assert.deepEqual(parseCachedRemoteConfig({ apiUrl: 'https://api.example.test' }), { + apiUrl: 'https://api.example.test', + }) +}) + +test('cached update availability is preserved exactly', () => { + assert.equal(parseCachedRemoteConfig({ updateEnabled: true })?.updateEnabled, true) + assert.equal(parseCachedRemoteConfig({ updateEnabled: false })?.updateEnabled, false) +}) diff --git a/packages/common/tests/sdk.test.ts b/packages/common/tests/sdk.test.ts index c2a4e63..d14da6f 100644 --- a/packages/common/tests/sdk.test.ts +++ b/packages/common/tests/sdk.test.ts @@ -56,6 +56,7 @@ test('awaitInitialization retries a transient config request without leaking fai await assert.doesNotReject(() => awaitInitialization()) assert.equal(requestCount, 2) assert.deepEqual(getInitializationSnapshot(), { state: 'ready', source: 'remote' }) + assert.equal(getConfig().updateEnabled, false) assert.equal(getConfig().updateRequiresLogin, true) const result = await apiRequest<{ available: boolean }>('/api/v1/rn/release-set/check', { diff --git a/packages/common/tests/security-primitives.test.ts b/packages/common/tests/security-primitives.test.ts new file mode 100644 index 0000000..984a8bc --- /dev/null +++ b/packages/common/tests/security-primitives.test.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { decodeBase64, encodeBase64 } from '../security/base64.js' +import { canonicalJson } from '../security/canonical-json.js' + +test('strict Base64 decoder supports padded, unpadded and URL-safe inputs without atob', () => { + assert.deepEqual([...decodeBase64('TQ==')], [77]) + assert.deepEqual([...decodeBase64('TWE=')], [77, 97]) + assert.deepEqual([...decodeBase64('TWFu')], [77, 97, 110]) + assert.deepEqual([...decodeBase64('AQIDBA==')], [1, 2, 3, 4]) + assert.deepEqual([...decodeBase64('AQIDBA')], [1, 2, 3, 4]) + assert.deepEqual([...decodeBase64('-_8=')], [251, 255]) +}) + +test('strict Base64 decoder rejects invalid alphabets, lengths, padding and trailing bits', () => { + for (const value of [ + 'A', + 'A+_-', + 'AA=A', + 'AA===', + 'AAA==', + 'TQ=', + 'TWE==', + 'TQ===', + 'TQ==AA', + 'TR==', + 'TWF=', + 'AA!A', + ]) { + assert.throws(() => decodeBase64(value), /Base64/) + } +}) + +test('pure JS Base64 encoder emits canonical standard and URL-safe forms', () => { + assert.equal(encodeBase64(new Uint8Array()), '') + assert.equal(encodeBase64(Uint8Array.of(77)), 'TQ==') + assert.equal(encodeBase64(Uint8Array.of(77, 97)), 'TWE=') + assert.equal(encodeBase64(Uint8Array.of(77, 97, 110)), 'TWFu') + assert.equal(encodeBase64(Uint8Array.of(251, 255), { urlSafe: true }), '-_8=') + assert.equal(encodeBase64(Uint8Array.of(251, 255), { urlSafe: true, padding: false }), '-_8') + assert.throws(() => encodeBase64([1, 2, 3] as unknown as Uint8Array), /Uint8Array/) +}) + +test('Base64 codec round-trips chunk boundaries and matches the Node reference', () => { + for (const length of [0, 1, 2, 3, 4, 31, 32, 33, 255, 256, 257]) { + const input = Uint8Array.from({ length }, (_, index) => (index * 197 + 31) & 0xff) + const standard = encodeBase64(input) + const urlSafe = encodeBase64(input, { urlSafe: true, padding: false }) + assert.equal(standard, Buffer.from(input).toString('base64')) + assert.equal(urlSafe, Buffer.from(input).toString('base64url')) + assert.deepEqual(decodeBase64(standard), input) + assert.deepEqual(decodeBase64(urlSafe), input) + } +}) + +test('canonical JSON rejects values that have no cross-runtime protocol representation', () => { + assert.throws(() => canonicalJson(Number.NaN), /finite numbers/) + assert.throws(() => canonicalJson(Number.POSITIVE_INFINITY), /finite numbers/) + assert.throws(() => canonicalJson(() => undefined), /does not support function/) + assert.throws(() => canonicalJson(Symbol('value')), /does not support symbol/) + assert.throws(() => canonicalJson([undefined]), /cannot contain holes or undefined/) + assert.throws(() => canonicalJson(new Array(1)), /cannot contain holes or undefined/) + assert.throws(() => canonicalJson(new Date(0)), /only supports plain objects/) +}) diff --git a/packages/update/README.md b/packages/update/README.md index 9e96aac..fcaa231 100644 --- a/packages/update/README.md +++ b/packages/update/README.md @@ -18,6 +18,11 @@ pnpm run xuqm:doctor - 完整 App 版本来自宿主 `package.json.version`,医网信新包从 `8.0.0` 开始。 - 插件默认版本来自 `xuqm.modules.json.pluginVersion`,从 `1.0.0` 开始;仅独立发布的模块覆盖 `moduleVersion`。 +`xuqm.modules.json` 分别使用 Android `packageName` 和 iOS `iosBundleId` 保存平台宿主 +身份,只在目标平台 build/package/release 时要求相应字段。ZIP wire 字段仍统一为 +`packageName`;CLI 通过唯一平台解析器写入对应身份,发布前从 ZIP 内重新读取并与同一 +解析结果精确比较,缺失或不一致时在联网前拒绝发布。 + Debug 启动只注册并校验插件,不访问远程更新服务。开发页如需验证平台能力,显式调用 `UpdateSDK.developer.checkStartupOnce()` 或 `UpdateSDK.developer.checkAndInstallPluginOnce(moduleId)`;该状态不会跨重启保留。 @@ -51,6 +56,7 @@ module.exports = withXuqmModuleConfig(getDefaultConfig(__dirname)) 发布使用的 `XUQM_API_TOKEN` 只能由 Jenkins Credentials 注入环境变量,不属于 `xuqm.modules.json` 或 `config.xuqmconfig`。CLI 不读取项目文件中的 Token,避免可用凭据 进入源码、插件包或构建日志。 +配置校验会直接拒绝 `release.apiToken`;发布凭据只能来自 `XUQM_API_TOKEN`。 Android 宿主只应用一次 SDK 脚本: @@ -149,6 +155,24 @@ const plan = await UpdateSDK.checkAndInstallPlugin('prescription', { SDK 会验证所有已安装模块在目标 common 下仍兼容,并拒绝 `builtAgainstNativeBaselineId` 与当前完整包不一致的候选,然后整组下载、校验、staging、激活、确认或回滚。Bundle 版本与事务状态只保存在原生 state;宿主不得另建 AsyncStorage 版本表,也不得逐层传递版本或路径状态。 +所有公开检查、下载和安装 API 都要求平台明确开启 `features.update`。关闭时在任何网络 +或原生操作前抛出 `UpdateDisabledError`(`code: UPDATE_DISABLED`);登录、启动等自动 +后台路径会吞掉该状态并正常返回,不影响宿主使用。 + +release-set 检查请求必须携带当前 `appVersion`、`nativeApiLevel` 和 +`nativeBaselineId`;原生基线缺失时客户端在请求前阻断。服务端候选必须提供 +`keyId/signature`,签名覆盖以下不可变 canonical manifest: + +`appKey, packageName, platform, moduleId, type, version, appVersionRange, builtAgainstNativeBaselineId, buildId, bundleFormat, minNativeApiLevel, bundleSha256, archiveSha256` + +app/buz 另包含 `commonVersionRange`;common 模块不构造该字段。canonical JSON 与 +Config V2 一致:UTF-8、键按字典序、无空白、null/undefined 字段省略。 + +客户端在生成计划前及安装前各验签一次,未知 keyId、签名错误、宿主身份不匹配或任一 +字段被篡改都会拒绝。响应 `sha256` 以 `archiveSha256` 进入签名清单,下载后使用同值 +校验 ZIP;Android 解包层还会把已签名的 +`buildId/bundleFormat/bundleSha256` 与包内 manifest 逐项比较。 + 需要先展示插件更新提示的项目,分别调用纯检查和安装 API: ```ts diff --git a/packages/update/android/src/main/java/com/xuqm/update/XuqmPluginPackageStager.java b/packages/update/android/src/main/java/com/xuqm/update/XuqmPluginPackageStager.java index 07b66a6..ecec95a 100644 --- a/packages/update/android/src/main/java/com/xuqm/update/XuqmPluginPackageStager.java +++ b/packages/update/android/src/main/java/com/xuqm/update/XuqmPluginPackageStager.java @@ -19,8 +19,9 @@ import java.util.zip.ZipInputStream; final class XuqmPluginPackageStager { private static final int MAX_ARCHIVE_BYTES = 50 * 1024 * 1024; + private static final long MAX_ENTRY_UNCOMPRESSED_BYTES = 100L * 1024L * 1024L; private static final long MAX_UNCOMPRESSED_BYTES = 100L * 1024L * 1024L; - private static final int MAX_ARCHIVE_ENTRIES = 5000; + private static final int MAX_ARCHIVE_ENTRIES = 4096; private XuqmPluginPackageStager() {} @@ -92,10 +93,12 @@ final class XuqmPluginPackageStager { ZipEntry entry; byte[] buffer = new byte[8192]; while ((entry = input.getNextEntry()) != null) { - if (entry.isDirectory()) continue; entryCount += 1; - if (entryCount > MAX_ARCHIVE_ENTRIES) { - throw new IOException("Plugin package has too many files"); + validateArchiveEntryCount(entryCount); + // CLI 的 zipSync 只生成文件 entry;显式目录 entry 不属于 v1 协议, + // 与服务端保持一致拒绝,避免目录绕过 entry 数量和路径检查。 + if (entry.isDirectory()) { + throw new IOException("Plugin package directory entries are not allowed"); } String name = entry.getName(); validateArchiveEntry(moduleId, name); @@ -108,8 +111,11 @@ final class XuqmPluginPackageStager { throw new IOException("Unable to create package directory " + parent); } try (FileOutputStream output = new FileOutputStream(destination)) { + long entryUncompressedBytes = 0L; int count; while ((count = input.read(buffer)) != -1) { + entryUncompressedBytes += count; + validateEntryUncompressedSize(entryUncompressedBytes); uncompressedBytes += count; if (uncompressedBytes > MAX_UNCOMPRESSED_BYTES) { throw new IOException("Plugin package exceeds the uncompressed size limit"); @@ -122,6 +128,18 @@ final class XuqmPluginPackageStager { return archiveEntries; } + static void validateArchiveEntryCount(int entryCount) throws IOException { + if (entryCount > MAX_ARCHIVE_ENTRIES) { + throw new IOException("Plugin package has too many entries"); + } + } + + static void validateEntryUncompressedSize(long entryBytes) throws IOException { + if (entryBytes > MAX_ENTRY_UNCOMPRESSED_BYTES) { + throw new IOException("Plugin package entry exceeds the uncompressed size limit"); + } + } + private static JSONObject readAndValidateManifest( String moduleId, File packageDirectory, @@ -199,6 +217,9 @@ final class XuqmPluginPackageStager { requireEqual(candidate, manifest, "moduleId"); requireEqual(candidate, manifest, "type"); requireEqual(candidate, manifest, "version"); + requireEqual(candidate, manifest, "buildId"); + requireEqual(candidate, manifest, "bundleFormat"); + requireEqual(candidate, manifest, "bundleSha256"); requireEqual(candidate, manifest, "appVersionRange"); requireOptionalEqual(candidate, manifest, "builtAgainstNativeBaselineId"); requireOptionalEqual(candidate, manifest, "commonVersionRange"); diff --git a/packages/update/android/src/test/java/com/xuqm/update/XuqmPluginPackageStagerTest.java b/packages/update/android/src/test/java/com/xuqm/update/XuqmPluginPackageStagerTest.java index 222f066..9d53c39 100644 --- a/packages/update/android/src/test/java/com/xuqm/update/XuqmPluginPackageStagerTest.java +++ b/packages/update/android/src/test/java/com/xuqm/update/XuqmPluginPackageStagerTest.java @@ -18,4 +18,18 @@ public final class XuqmPluginPackageStagerTest { IllegalArgumentException.class, () -> XuqmPluginPackageStager.validateAssetPath("assets/free-form-name.png")); } + + @Test + public void enforcesTheV1ArchiveEntryBoundaries() throws Exception { + XuqmPluginPackageStager.validateArchiveEntryCount(4096); + assertThrows( + java.io.IOException.class, + () -> XuqmPluginPackageStager.validateArchiveEntryCount(4097)); + + XuqmPluginPackageStager.validateEntryUncompressedSize(100L * 1024L * 1024L); + assertThrows( + java.io.IOException.class, + () -> XuqmPluginPackageStager.validateEntryUncompressedSize( + 100L * 1024L * 1024L + 1L)); + } } diff --git a/packages/update/scripts/release-config.mjs b/packages/update/scripts/release-config.mjs index e9d2613..700a6d2 100644 --- a/packages/update/scripts/release-config.mjs +++ b/packages/update/scripts/release-config.mjs @@ -5,7 +5,7 @@ import path from 'node:path' /** * 整包与插件发布共用的配置验证门禁。只阻断新产物;已安装 App 不在线查询吊销状态。 */ -export async function validateReleaseConfig({ projectRoot, packageName, platform }) { +export async function validateReleaseConfig({ projectRoot, packageName }) { const hostRequire = createRequire(path.join(projectRoot, 'package.json')) const commonMetro = hostRequire('@xuqm/rn-common/metro') const configFile = commonMetro.findConfigFile(projectRoot) @@ -15,16 +15,15 @@ export async function validateReleaseConfig({ projectRoot, packageName, platform const content = readFileSync(configFile, 'utf8').trim() const resolved = commonMetro.decryptConfigAtBuildTime(content) - const targetPackageName = - packageName ?? - (platform === 'ios' ? (resolved.iosBundleId ?? resolved.packageName) : resolved.packageName) - if (!targetPackageName) throw new Error('A package identity is required for Release validation') + if (typeof packageName !== 'string' || packageName.trim() === '') { + throw new Error('A package identity is required for Release validation') + } const response = await fetch( `${String(resolved.serverUrl).replace(/\/$/, '')}/api/sdk/build/config/validate`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ content, packageName: targetPackageName }), + body: JSON.stringify({ content, packageName }), }, ) if (!response.ok) { diff --git a/packages/update/scripts/release-package.mjs b/packages/update/scripts/release-package.mjs index d21f657..71bf1f0 100644 --- a/packages/update/scripts/release-package.mjs +++ b/packages/update/scripts/release-package.mjs @@ -26,6 +26,7 @@ export function readReleasePackageIdentity(packageFile, expected) { throw new Error('plugin package manifest is invalid JSON') } + const packageName = requiredString(manifest, 'packageName') const moduleId = requiredString(manifest, 'moduleId') const platform = requiredString(manifest, 'platform') const moduleVersion = requiredString(manifest, 'version') @@ -41,6 +42,11 @@ export function readReleasePackageIdentity(packageFile, expected) { if (buildId === 'development') { throw new Error('plugin package was built without a release buildId; rebuild before publishing') } + if (expected.packageName !== packageName) { + throw new Error( + `plugin package packageName mismatch: expected ${expected.packageName}, got ${packageName}`, + ) + } if (expected.moduleId !== moduleId) { throw new Error( `plugin package moduleId mismatch: expected ${expected.moduleId}, got ${moduleId}`, @@ -83,6 +89,7 @@ export function readReleasePackageIdentity(packageFile, expected) { return { manifest, + packageName, moduleId, platform, moduleVersion, diff --git a/packages/update/scripts/release-package.test.mjs b/packages/update/scripts/release-package.test.mjs index 02a5372..77a668c 100644 --- a/packages/update/scripts/release-package.test.mjs +++ b/packages/update/scripts/release-package.test.mjs @@ -11,6 +11,7 @@ function createPackage(root, overrides = {}, bundle = 'bundle') { const bundleBytes = strToU8(bundle) const manifest = { schemaVersion: 1, + packageName: 'com.example.host', moduleId: 'app', platform: 'android', version: '1.0.1', @@ -37,6 +38,7 @@ function createPackage(root, overrides = {}, bundle = 'bundle') { } const expected = { + packageName: 'com.example.host', moduleId: 'app', platform: 'android', moduleVersion: '1.0.1', @@ -47,6 +49,7 @@ test('release identity is read from and verified against the packaged Bundle', ( const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-package-')) try { const identity = readReleasePackageIdentity(createPackage(root), expected) + assert.equal(identity.packageName, 'com.example.host') assert.equal(identity.buildId, 'build-20260726') assert.equal(identity.bundleFormat, 'hermes-bytecode') assert.equal(identity.nativeBaselineId, 'native-1') @@ -58,6 +61,26 @@ test('release identity is read from and verified against the packaged Bundle', ( } }) +test('publishing requires and exactly matches the packaged packageName', () => { + const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-package-name-')) + try { + assert.throws( + () => readReleasePackageIdentity(createPackage(root, { packageName: undefined }), expected), + /manifest packageName is missing/, + ) + assert.throws( + () => + readReleasePackageIdentity( + createPackage(root, { packageName: 'com.example.other' }), + expected, + ), + /packageName mismatch: expected com\.example\.host, got com\.example\.other/, + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + test('publishing rejects development identity and mismatched packaged Bundle hash', () => { const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-package-invalid-')) try { diff --git a/packages/update/scripts/xuqm-config.mjs b/packages/update/scripts/xuqm-config.mjs index 5b7061a..aec1e62 100644 --- a/packages/update/scripts/xuqm-config.mjs +++ b/packages/update/scripts/xuqm-config.mjs @@ -27,6 +27,18 @@ export function readConfig(root = process.cwd()) { export function validateConfig(config, root = process.cwd()) { const errors = [] if (!config.appId) errors.push('appId is required') + if ( + config.packageName !== undefined && + (typeof config.packageName !== 'string' || config.packageName.trim() === '') + ) { + errors.push('packageName must be a non-empty string') + } + if ( + config.iosBundleId !== undefined && + (typeof config.iosBundleId !== 'string' || config.iosBundleId.trim() === '') + ) { + errors.push('iosBundleId must be a non-empty string') + } if (!config.mainModuleName) errors.push('mainModuleName is required') if (typeof config.pluginVersion !== 'string' || !semver.valid(config.pluginVersion)) { errors.push('pluginVersion must be SemVer') @@ -34,6 +46,9 @@ export function validateConfig(config, root = process.cwd()) { if (typeof config.appVersionRange !== 'string' || !semver.validRange(config.appVersionRange)) { errors.push('appVersionRange must be a valid SemVer range') } + if (config.release && Object.prototype.hasOwnProperty.call(config.release, 'apiToken')) { + errors.push('release.apiToken is forbidden; Jenkins must inject XUQM_API_TOKEN') + } if (!Array.isArray(config.modules) || config.modules.length === 0) { errors.push('modules must contain at least one module') return errors @@ -103,6 +118,22 @@ export function validateConfig(config, root = process.cwd()) { return errors } +/** + * wire 协议统一使用 packageName,但值必须是目标平台自己的宿主身份。所有构建、校验 + * 与发布入口只能通过这里解析,避免 Android applicationId 泄漏到 iOS 插件。 + */ +export function resolveHostPackageId(config, platform) { + if (!SUPPORTED_PLATFORMS.has(platform)) { + throw new Error(`unsupported platform: ${platform}`) + } + const key = platform === 'ios' ? 'iosBundleId' : 'packageName' + const value = config?.[key] + if (typeof value !== 'string' || value.trim() === '') { + throw new Error(`${key} is required for ${platform} build and release`) + } + return value.trim() +} + export function readPackageVersion(root = process.cwd()) { const packageFile = path.join(root, 'package.json') if (!existsSync(packageFile)) throw new Error('package.json is missing') diff --git a/packages/update/scripts/xuqm-config.test.mjs b/packages/update/scripts/xuqm-config.test.mjs index 1c451f3..62279a4 100644 --- a/packages/update/scripts/xuqm-config.test.mjs +++ b/packages/update/scripts/xuqm-config.test.mjs @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import test from 'node:test' -import { resolveVersionedModules, validateConfig } from './xuqm-config.mjs' +import { resolveHostPackageId, resolveVersionedModules, validateConfig } from './xuqm-config.mjs' function fixture() { const root = mkdtempSync(path.join(tmpdir(), 'xuqm-config-')) @@ -19,6 +19,7 @@ test('plugins start at the configured plugin version independently from the app try { const config = { appId: 'example', + packageName: 'com.example.host', mainModuleName: 'Example', pluginVersion: '1.0.0', appVersionRange: '>=7.2.14 <8.0.0', @@ -70,6 +71,7 @@ test('only common can own existing shared source roots', () => { mkdirSync(path.join(root, 'src', 'common'), { recursive: true }) const base = { appId: 'example', + packageName: 'com.example.host', mainModuleName: 'Example', pluginVersion: '1.0.0', appVersionRange: '>=1.0.0 <2.0.0', @@ -113,3 +115,42 @@ test('only common can own existing shared source roots', () => { rmSync(root, { recursive: true, force: true }) } }) + +test('release token cannot be stored in xuqm.modules.json', () => { + const root = fixture() + try { + const errors = validateConfig( + { + appId: 'example', + packageName: 'com.example.host', + mainModuleName: 'Example', + pluginVersion: '1.0.0', + appVersionRange: '>=1.0.0 <2.0.0', + metroConfig: './metro.config.js', + release: { apiToken: 'must-not-enter-source' }, + modules: [{ id: 'common', type: 'common', entry: './common.ts' }], + }, + root, + ) + assert.match(errors.join('\n'), /release\.apiToken is forbidden.*XUQM_API_TOKEN/) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + +test('host package identity resolves exactly once for each target platform', () => { + const config = { + packageName: 'com.example.android', + iosBundleId: 'com.example.ios', + } + assert.equal(resolveHostPackageId(config, 'android'), 'com.example.android') + assert.equal(resolveHostPackageId(config, 'ios'), 'com.example.ios') + assert.throws( + () => resolveHostPackageId({ packageName: 'com.example.android' }, 'ios'), + /iosBundleId is required for ios build and release/, + ) + assert.throws( + () => resolveHostPackageId({ iosBundleId: 'com.example.ios' }, 'android'), + /packageName is required for android build and release/, + ) +}) diff --git a/packages/update/scripts/xuqm-rn.mjs b/packages/update/scripts/xuqm-rn.mjs index e08b0c3..9f7e28d 100755 --- a/packages/update/scripts/xuqm-rn.mjs +++ b/packages/update/scripts/xuqm-rn.mjs @@ -24,6 +24,7 @@ import { compatibleAppRange, parseModuleOptions, readConfig, + resolveHostPackageId, resolveVersionedModules, selectModules, validateConfig, @@ -47,23 +48,10 @@ function writeJson(file, value) { writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`) } -function androidApplicationId() { - const gradleFile = ['android/app/build.gradle', 'android/app/build.gradle.kts'] - .map(file => path.resolve(root, file)) - .find(existsSync) - if (!gradleFile) throw new Error('Android app Gradle file is missing') - const match = readFileSync(gradleFile, 'utf8').match( - /\bapplicationId\s*(?:=|\s)\s*["']([^"']+)["']/, - ) - if (!match?.[1]) throw new Error('Android applicationId could not be resolved') - return match[1] -} - -async function validateReleaseConfigOnline(platform) { +async function validateReleaseConfigOnline(config, platform) { await validateReleaseConfig({ projectRoot: root, - packageName: platform === 'android' ? androidApplicationId() : undefined, - platform, + packageName: resolveHostPackageId(config, platform), }) } @@ -164,6 +152,7 @@ function init() { writeJson(configFile, { schemaVersion: 3, appId: pkg.name ?? 'react-native-app', + packageName: pkg.name ?? 'react-native-app', mainModuleName: pkg.name ?? 'ReactNativeApp', pluginVersion: '1.0.0', appVersionRange: compatibleAppRange(pkg.version ?? '1.0.0'), @@ -334,6 +323,7 @@ function buildEntryFile(module, platform) { function build(platform, optionArgs = []) { assertPlatform(platform) const config = doctor() + resolveHostPackageId(config, platform) const versioned = resolveVersionedModules(config, root, process.env.XUQM_APP_VERSION) const versionedConfig = { ...config, modules: versioned.modules } const modules = parseSelectedModules(versionedConfig, optionArgs) @@ -436,6 +426,7 @@ function createModulePackage(config, platform, module, appVersion, nativeBaselin }) const manifest = { schemaVersion: 1, + packageName: resolveHostPackageId(config, platform), moduleId: module.id, type: module.type, platform, @@ -636,7 +627,8 @@ async function packageApp(platform, optionArgs) { fail(error instanceof Error ? error.message : String(error)) } const { apk, bugCollectMode } = packageOptions - await validateReleaseConfigOnline(platform) + const config = doctor() + await validateReleaseConfigOnline(config, platform) const buildIdentity = createAndroidReleaseIdentity({ projectRoot: root }) const androidDirectory = path.join(root, 'android') const wrapper = path.join( diff --git a/packages/update/scripts/xuqm-rn.test.mjs b/packages/update/scripts/xuqm-rn.test.mjs index 86a3959..6cebef3 100644 --- a/packages/update/scripts/xuqm-rn.test.mjs +++ b/packages/update/scripts/xuqm-rn.test.mjs @@ -52,6 +52,8 @@ test('embed creates a complete versioned manifest from a host fixture', () => { JSON.stringify({ schemaVersion: 3, appId: 'fixture.app', + packageName: 'com.example.fixture', + iosBundleId: 'com.example.fixture.ios', mainModuleName: 'Fixture', pluginVersion: '1.0.0', appVersionRange: '>=7.2.14 <8.0.0', @@ -119,6 +121,7 @@ test('Android Hermes hosts compile plugin bundles to bytecode', () => { JSON.stringify({ schemaVersion: 3, appId: 'fixture.app', + packageName: 'com.example.fixture', mainModuleName: 'Fixture', pluginVersion: '1.0.0', appVersionRange: '>=1.0.0 <2.0.0', @@ -269,6 +272,7 @@ test('embed rejects assets owned by more than one module', () => { JSON.stringify({ schemaVersion: 3, appId: 'fixture.app', + packageName: 'com.example.fixture', mainModuleName: 'Fixture', pluginVersion: '1.0.0', appVersionRange: '>=1.0.0 <2.0.0', @@ -312,6 +316,8 @@ test('build invokes the host-local React Native CLI with a consistent color envi JSON.stringify({ schemaVersion: 3, appId: 'fixture.app', + packageName: 'com.example.fixture', + iosBundleId: 'com.example.fixture.ios', mainModuleName: 'Fixture', pluginVersion: '1.0.0', appVersionRange: '>=1.0.0 <2.0.0', @@ -356,6 +362,25 @@ test('build invokes the host-local React Native CLI with a consistent color envi assert.equal(child.args[0], 'bundle') assert.equal(child.forceColor, '0') assert.equal(child.noColor, null) + + const androidPackage = unzipSync( + new Uint8Array( + readFileSync(path.join(root, 'bundle/android/common/common.android.xuqm.zip')), + ), + ) + const androidManifest = JSON.parse(strFromU8(androidPackage['rn-manifest.json'])) + assert.equal(androidManifest.packageName, 'com.example.fixture') + + execFileSync(process.execPath, [cli, 'build', 'ios'], { + cwd: root, + stdio: 'pipe', + }) + const iosPackage = unzipSync( + new Uint8Array(readFileSync(path.join(root, 'bundle/ios/common/common.ios.xuqm.zip'))), + ) + const iosManifest = JSON.parse(strFromU8(iosPackage['rn-manifest.json'])) + assert.equal(iosManifest.platform, 'ios') + assert.equal(iosManifest.packageName, 'com.example.fixture.ios') } finally { rmSync(root, { recursive: true, force: true }) } @@ -384,6 +409,7 @@ test('building one buz first rebuilds startup and common module maps', () => { JSON.stringify({ schemaVersion: 3, appId: 'fixture.app', + packageName: 'com.example.fixture', mainModuleName: 'Fixture', pluginVersion: '1.0.0', appVersionRange: '>=1.0.0 <2.0.0', @@ -467,6 +493,7 @@ test('building one buz first rebuilds startup and common module maps', () => { ) const packageManifest = JSON.parse(strFromU8(commonPackage['rn-manifest.json'])) assert.equal(packageManifest.schemaVersion, 1) + assert.equal(packageManifest.packageName, 'com.example.fixture') assert.equal(packageManifest.moduleId, 'common') assert.equal(packageManifest.bundleFormat, 'javascript') assert.deepEqual(packageManifest.assets, []) diff --git a/packages/update/scripts/xuqm_release.mjs b/packages/update/scripts/xuqm_release.mjs index 04329ed..4e0b6f7 100644 --- a/packages/update/scripts/xuqm_release.mjs +++ b/packages/update/scripts/xuqm_release.mjs @@ -10,6 +10,7 @@ import { bundleOutputDirectory, parseModuleOptions, readConfig, + resolveHostPackageId, resolveVersionedModules, selectModules, } from './xuqm-config.mjs' @@ -53,6 +54,7 @@ if (!['platform', 'disabled'].includes(bugCollectMode)) { } const config = readConfig(root) +const hostPackageId = resolveHostPackageId(config, platform) const versioned = resolveVersionedModules(config, root, process.env.XUQM_APP_VERSION) const selectedModules = selectModules({ ...config, modules: versioned.modules }, moduleIds) if (moduleIds.length > 0 && selectedModules.some(module => module.type === 'startup')) { @@ -82,8 +84,7 @@ function apiHeaders() { async function validateReleaseConfigOnline() { await validateReleaseConfig({ projectRoot: root, - packageName: config.packageName, - platform, + packageName: hostPackageId, }) } @@ -94,6 +95,7 @@ function releasePackage(module) { ) if (!existsSync(file)) throw new Error(`plugin package is missing: ${file}`) const identity = readReleasePackageIdentity(file, { + packageName: hostPackageId, moduleId: module.id, platform, moduleVersion: module.moduleVersion, @@ -116,7 +118,7 @@ async function uploadBundle(module, release, note) { form.append('commonVersionRange', identity.commonVersionRange ?? '') form.append('minNativeApiLevel', String(identity.minNativeApiLevel)) form.append('note', note) - if (config.packageName) form.append('packageName', config.packageName) + form.append('packageName', identity.packageName) form.append('bundle', new Blob([readFileSync(file)]), `${module.id}.${platform}.xuqm.zip`) const response = await fetch(`${serverUrl}/api/v1/rn/upload`, { method: 'POST', diff --git a/packages/update/src/NativeBundle.ts b/packages/update/src/NativeBundle.ts index 5de62be..02198e9 100644 --- a/packages/update/src/NativeBundle.ts +++ b/packages/update/src/NativeBundle.ts @@ -27,6 +27,9 @@ export interface NativeReleaseModule { /** Base64-encoded .xuqm.zip bytes; never decode Hermes bytecode as text. */ archiveBase64: string sha256: string + buildId: string + bundleFormat: string + bundleSha256: string appVersionRange: string builtAgainstNativeBaselineId?: string commonVersionRange?: string diff --git a/packages/update/src/UpdateSDK.ts b/packages/update/src/UpdateSDK.ts index d739155..68f1d6d 100644 --- a/packages/update/src/UpdateSDK.ts +++ b/packages/update/src/UpdateSDK.ts @@ -11,6 +11,10 @@ import { sha256Hex, type DownloadProgress, } from '@xuqm/rn-common' +import { + verifySignedPluginManifest, + type SignedPluginManifest, +} from '@xuqm/rn-common/internal-security' import { NativeBundle, type NativeReleaseModule } from './NativeBundle' import { NativeAppUpdate } from './NativeAppUpdate' import { getAppVersionCode, getAppVersionName, _devSetAppVersion } from './NativeVersion' @@ -22,6 +26,8 @@ import { type ReleaseSetPlan, } from './releaseSet' import { shouldSkipUpdateForLogin } from './updateLoginPolicy' +import { assertUpdateEnabled, UpdateDisabledError } from './updateAvailability' +import { createReleaseSetCheckBody } from './releaseSetRequest' export interface PluginRegistration { moduleId: string @@ -68,16 +74,23 @@ interface RawAppUpdateInfo extends Omit { } interface RawPluginReleaseCandidate { + appKey: string + packageName: string + platform: 'ANDROID' | 'IOS' moduleId: string type: PluginModuleType - version?: string - latestVersion?: string + version: string downloadUrl: string sha256: string appVersionRange: string - builtAgainstNativeBaselineId?: string - commonVersionRange?: string - minNativeApiLevel?: number + builtAgainstNativeBaselineId: string + buildId: string + bundleFormat: 'hermes-bytecode' | 'javascript' + bundleSha256: string + commonVersionRange?: string | null + minNativeApiLevel: number + keyId: string + signature: string note?: string forceUpdate?: boolean changeLog?: string @@ -114,6 +127,18 @@ function isLoginRequiredWithoutSession(): boolean { return shouldSkipUpdateForLogin(getConfig().updateRequiresLogin, getUserId()) } +function requireUpdateEnabled(): void { + assertUpdateEnabled(getConfig().updateEnabled) +} + +function requireAppVersion(operation: string): string { + const appVersion = getAppVersionName()?.trim() + if (!appVersion) { + throw new Error(`[UpdateSDK] Full-app version is required for plugin ${operation}.`) + } + return appVersion +} + async function clearSessionUpdateState(): Promise { const previousCacheKey = sessionCacheKey(UPDATE_APP_CACHE_KEY) activeSessionAbort.abort() @@ -226,15 +251,21 @@ async function installedModule(registration: PluginRegistration): Promise { await awaitInitialization() + requireUpdateEnabled() if (isLoginRequiredWithoutSession()) { return { needsUpdate: false, requiresLogin: true, skippedReason: 'LOGIN_REQUIRED' } } @@ -378,6 +449,8 @@ export const UpdateSDK = { updateInfo: AppUpdateInfo, options: { signal?: AbortSignal; onProgress?: (progress: UpdateDownloadProgress) => void } = {}, ): Promise { + await awaitInitialization() + requireUpdateEnabled() if (!updateInfo.downloadUrl) throw new Error('[UpdateSDK] App update has no downloadUrl') const bytes = await downloadBytes(updateInfo.downloadUrl, { ...options, @@ -395,6 +468,8 @@ export const UpdateSDK = { onProgress?: (progress: UpdateDownloadProgress) => void } = {}, ): Promise { + await awaitInitialization() + requireUpdateEnabled() if (!updateInfo.downloadUrl || !updateInfo.versionCode) { throw new Error('[UpdateSDK] App update requires downloadUrl and versionCode') } @@ -414,6 +489,7 @@ export const UpdateSDK = { options: { signal?: AbortSignal; timeoutMs?: number } = {}, ): Promise { await awaitInitialization() + requireUpdateEnabled() if (isLoginRequiredWithoutSession()) return null const target = requireRegistration(targetModuleId) if (target.type === 'common') { @@ -431,19 +507,29 @@ export const UpdateSDK = { throw new Error('[UpdateSDK] Exactly one common module must be registered') } const config = getConfig() + if (!config.packageName) { + throw new Error('[UpdateSDK] Signed host packageName is required for plugin update checks.') + } + const platform = Platform.OS === 'android' ? 'ANDROID' : 'IOS' const userId = getUserId()?.trim() + const nativeApiLevel = await NativeBundle.getNativeApiLevel() + const nativeBaselineId = registeredNativeBaselineId(registrations) ?? '' + const appVersion = requireAppVersion('update checks') const raw = await apiRequest('/api/v1/rn/release-set/check', { method: 'POST', skipAuth: true, signal: currentSessionSignal(options.signal), timeoutMs: options.timeoutMs, - body: { + body: createReleaseSetCheckBody({ appKey: config.appKey, targetModuleId, - platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS', + platform, userId: userId || undefined, + appVersion, + nativeApiLevel, + nativeBaselineId, installedModules: installed, - }, + }), }) const allowed = new Set([common.moduleId, targetModuleId]) const candidates = (raw.modules ?? raw.candidates ?? []).map(candidate => { @@ -453,20 +539,28 @@ export const UpdateSDK = { ) } const registration = requireRegistration(candidate.moduleId) - return asCandidate({ + const normalized = asCandidate({ ...candidate, - type: registration.type, downloadUrl: normalizeDownloadUrl(candidate.downloadUrl) ?? candidate.downloadUrl, - commonVersionRange: candidate.commonVersionRange ?? registration.commonVersionRange, - minNativeApiLevel: candidate.minNativeApiLevel ?? registration.minNativeApiLevel, }) + if (normalized.type !== registration.type) { + throw new Error( + `[UpdateSDK] Signed plugin type does not match registration: ${candidate.moduleId}`, + ) + } + verifyCandidate(normalized, { + appKey: config.appKey, + packageName: config.packageName, + platform, + }) + return normalized }) const plan = planReleaseSet({ installed, candidates, - nativeApiLevel: await NativeBundle.getNativeApiLevel(), - nativeBaselineId: registeredNativeBaselineId(registrations), - appVersion: getAppVersionName() ?? '0.0.0', + nativeApiLevel, + nativeBaselineId, + appVersion, releaseId: raw.releaseId, }) if (plan && (await readQuarantinedReleases())[targetModuleId] === plan.releaseId) { @@ -483,7 +577,21 @@ export const UpdateSDK = { onProgress?: (moduleId: string, progress: UpdateDownloadProgress) => void } = {}, ): Promise { + await awaitInitialization() + requireUpdateEnabled() const authorizedGeneration = sessionGeneration + const config = getConfig() + if (!config.packageName) { + throw new Error('[UpdateSDK] Signed host packageName is required for plugin installation.') + } + const platform = Platform.OS === 'android' ? 'ANDROID' : 'IOS' + for (const candidate of plan.modules) { + verifyCandidate(candidate, { + appKey: config.appKey, + packageName: config.packageName, + platform, + }) + } const registrations = UpdateSDK.getRegisteredPlugins() const installed = await Promise.all( registrations.map(registration => installedModule(registration)), @@ -498,7 +606,7 @@ export const UpdateSDK = { candidates: plan.modules, nativeApiLevel: await NativeBundle.getNativeApiLevel(), nativeBaselineId: registeredNativeBaselineId(registrations), - appVersion: getAppVersionName() ?? '0.0.0', + appVersion: requireAppVersion('installation'), releaseId: plan.releaseId, }) if (!verifiedPlan) return null @@ -519,6 +627,9 @@ export const UpdateSDK = { version: module.version, archiveBase64: bytesToBase64(archive), sha256: module.sha256, + buildId: module.buildId, + bundleFormat: module.bundleFormat, + bundleSha256: module.bundleSha256, commonVersionRange: module.commonVersionRange, appVersionRange: module.appVersionRange!, builtAgainstNativeBaselineId: module.builtAgainstNativeBaselineId, @@ -632,7 +743,14 @@ export const UpdateSDK = { /** @internal 共享登录态变化时取消旧灰度授权,并为新用户自动补检一次。 */ async _handleSessionChange(loggedIn: boolean): Promise { await clearSessionUpdateState() - if (!loggedIn || !getConfig().updateRequiresLogin || getConfig().debug) return + if ( + !loggedIn || + !getConfig().updateEnabled || + !getConfig().updateRequiresLogin || + getConfig().debug + ) { + return + } if (automaticLoginCheckGeneration === sessionGeneration) return automaticLoginCheckGeneration = sessionGeneration try { @@ -643,3 +761,5 @@ export const UpdateSDK = { } }, } + +export { UpdateDisabledError } diff --git a/packages/update/src/index.ts b/packages/update/src/index.ts index 1aa0580..938ffb8 100644 --- a/packages/update/src/index.ts +++ b/packages/update/src/index.ts @@ -10,7 +10,7 @@ _registerUserInfoHandler('rn-update', info => UpdateSDK._handleSessionChange(Boo _registerInitializationHandler('rn-update-reset', () => UpdateSDK._reconcileNativeReset()) _registerBundleIdentityProvider('rn-update', resolveCurrentBundleIdentity) -export { StaleReleaseSetError, UpdateSDK } from './UpdateSDK' +export { StaleReleaseSetError, UpdateDisabledError, UpdateSDK } from './UpdateSDK' export type { PluginRegistration, AppUpdateInfo, diff --git a/packages/update/src/releaseSet.ts b/packages/update/src/releaseSet.ts index 5cc1e86..a88b9f6 100644 --- a/packages/update/src/releaseSet.ts +++ b/packages/update/src/releaseSet.ts @@ -14,7 +14,16 @@ export interface InstalledPluginModule { } export interface PluginReleaseCandidate extends InstalledPluginModule { + appKey: string + packageName: string + platform: 'ANDROID' | 'IOS' + buildId: string + bundleFormat: 'hermes-bytecode' | 'javascript' + bundleSha256: string + keyId: string + signature: string downloadUrl: string + /** 下载归档文件的 SHA-256;归档内 bundle 另由已签名 bundleSha256 约束。 */ sha256: string forceUpdate?: boolean changeLog?: string @@ -129,7 +138,15 @@ export function planReleaseSet(options: PlanReleaseSetOptions): ReleaseSetPlan | for (const module of installed.values()) assertModule(module) for (const candidate of candidates.values()) { assertModule(candidate) - if (!candidate.downloadUrl.trim() || !/^[a-f0-9]{64}$/i.test(candidate.sha256)) { + if ( + !candidate.downloadUrl.trim() || + !/^[a-f0-9]{64}$/i.test(candidate.sha256) || + !/^[a-f0-9]{64}$/i.test(candidate.bundleSha256) || + !candidate.buildId.trim() || + !candidate.bundleFormat.trim() || + !candidate.keyId.trim() || + !candidate.signature.trim() + ) { throw new ReleaseSetCompatibilityError( `${candidate.moduleId} must provide a download URL and SHA-256`, candidate.moduleId, diff --git a/packages/update/src/releaseSetRequest.ts b/packages/update/src/releaseSetRequest.ts new file mode 100644 index 0000000..5efee58 --- /dev/null +++ b/packages/update/src/releaseSetRequest.ts @@ -0,0 +1,28 @@ +import type { InstalledPluginModule } from './releaseSet' + +export interface ReleaseSetCheckBody { + appKey: string + targetModuleId: string + platform: 'ANDROID' | 'IOS' + userId?: string + appVersion: string + nativeApiLevel: number + nativeBaselineId: string + installedModules: InstalledPluginModule[] +} + +/** 服务端兼容性判断所需的终态请求契约,客户端收到候选后仍执行二次校验。 */ +export function createReleaseSetCheckBody(input: ReleaseSetCheckBody): ReleaseSetCheckBody { + const appVersion = input.appVersion.trim() + if (!appVersion) { + throw new Error('[UpdateSDK] Full-app version is required before checking a release set.') + } + if (!Number.isInteger(input.nativeApiLevel) || input.nativeApiLevel < 1) { + throw new Error('[UpdateSDK] Native API level must be positive before checking a release set.') + } + const nativeBaselineId = input.nativeBaselineId.trim() + if (!nativeBaselineId) { + throw new Error('[UpdateSDK] Native baseline is required before checking a plugin release set.') + } + return { ...input, appVersion, nativeBaselineId } +} diff --git a/packages/update/src/updateAvailability.ts b/packages/update/src/updateAvailability.ts new file mode 100644 index 0000000..9d7ce46 --- /dev/null +++ b/packages/update/src/updateAvailability.ts @@ -0,0 +1,13 @@ +export class UpdateDisabledError extends Error { + readonly name = 'UpdateDisabledError' + readonly code = 'UPDATE_DISABLED' + + constructor() { + super('[UpdateSDK] Update service is disabled by the platform.') + } +} + +/** 所有检查、下载和安装入口在触碰网络或原生安装状态前复用这一门禁。 */ +export function assertUpdateEnabled(enabled: boolean): void { + if (!enabled) throw new UpdateDisabledError() +} diff --git a/packages/update/tests/release-set-request.test.ts b/packages/update/tests/release-set-request.test.ts new file mode 100644 index 0000000..f3d8df3 --- /dev/null +++ b/packages/update/tests/release-set-request.test.ts @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { createReleaseSetCheckBody } from '../src/releaseSetRequest' + +const base = { + appKey: 'app', + targetModuleId: 'orders', + platform: 'ANDROID' as const, + appVersion: '8.0.0', + nativeApiLevel: 2, + nativeBaselineId: 'native-sha256', + installedModules: [ + { + moduleId: 'app', + type: 'app' as const, + version: '1.0.0', + appVersionRange: '>=8.0.0 <9.0.0', + }, + ], +} + +test('release-set check sends all server compatibility inputs', () => { + assert.deepEqual(createReleaseSetCheckBody({ ...base, userId: 'user-1' }), { + ...base, + userId: 'user-1', + }) +}) + +test('release-set check blocks locally when native baseline is unavailable', () => { + assert.throws( + () => createReleaseSetCheckBody({ ...base, nativeBaselineId: ' ' }), + /Native baseline is required/, + ) +}) + +test('release-set check blocks locally when app or native API identity is unavailable', () => { + assert.throws( + () => createReleaseSetCheckBody({ ...base, appVersion: ' ' }), + /Full-app version is required/, + ) + assert.throws( + () => createReleaseSetCheckBody({ ...base, nativeApiLevel: 0 }), + /Native API level must be positive/, + ) +}) diff --git a/packages/update/tests/releaseSet.test.ts b/packages/update/tests/releaseSet.test.ts index f7aec08..b6f483f 100644 --- a/packages/update/tests/releaseSet.test.ts +++ b/packages/update/tests/releaseSet.test.ts @@ -32,13 +32,22 @@ const installed: InstalledPluginModule[] = [ ] function candidate( - module: Omit, + module: Pick & + Partial>, ): PluginReleaseCandidate { return { + appKey: 'app-key', + packageName: 'com.example.app', + platform: 'ANDROID', appVersionRange: '>=8.0.0 <9.0.0', - ...module, + buildId: 'build-1', + bundleFormat: 'hermes-bytecode', + bundleSha256: 'b'.repeat(64), + keyId: 'test-key', + signature: 'test-signature', downloadUrl: `https://updates.example/${module.moduleId}.bundle`, sha256: 'a'.repeat(64), + ...module, } } diff --git a/packages/update/tests/update-availability.test.ts b/packages/update/tests/update-availability.test.ts new file mode 100644 index 0000000..cce7e52 --- /dev/null +++ b/packages/update/tests/update-availability.test.ts @@ -0,0 +1,25 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { assertUpdateEnabled, UpdateDisabledError } from '../src/updateAvailability' + +test('disabled update rejects before any network operation', () => { + let networkCalls = 0 + const operation = () => { + assertUpdateEnabled(false) + networkCalls += 1 + } + assert.throws(operation, (error: unknown) => { + assert.equal(error instanceof UpdateDisabledError, true) + assert.equal((error as UpdateDisabledError).code, 'UPDATE_DISABLED') + return true + }) + assert.equal(networkCalls, 0) +}) + +test('explicitly enabled update allows the operation to continue', () => { + let networkCalls = 0 + assertUpdateEnabled(true) + networkCalls += 1 + assert.equal(networkCalls, 1) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8441d02..4a4f893 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -66,6 +66,9 @@ importers: '@noble/ciphers': specifier: 2.2.0 version: 2.2.0 + '@noble/curves': + specifier: 2.2.0 + version: 2.2.0 '@noble/hashes': specifier: 2.2.0 version: 2.2.0 @@ -431,6 +434,10 @@ packages: resolution: {integrity: sha512-Z6pjIZ/8IJcCGzb2S/0Px5J81yij85xASuk1teLNeg75bfT07MV3a/O2Mtn1I2se43k3lkVEcFaR10N4cgQcZA==} engines: {node: '>= 20.19.0'} + '@noble/curves@2.2.0': + resolution: {integrity: sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==} + engines: {node: '>= 20.19.0'} + '@noble/hashes@2.2.0': resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==} engines: {node: '>= 20.19.0'} @@ -671,14 +678,6 @@ packages: cpu: [x64] os: [win32] - '@xuqm/rn-common@0.6.0-alpha.9': - resolution: {integrity: sha512-NvSYr92sRV8xKVCTrO7sPFXypUYfxMozvHWaNlIk+4bnvGlhWGGUvmvjm2QxF0rBzTO8b0HzhXhb9S/CIx629Q==} - peerDependencies: - '@react-native-async-storage/async-storage': '>=1.21.0' - axios: '>=1.0.0' - react: '>=18.0.0' - react-native: '>=0.76.0' - abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -1862,6 +1861,10 @@ snapshots: '@noble/ciphers@2.2.0': {} + '@noble/curves@2.2.0': + dependencies: + '@noble/hashes': 2.2.0 + '@noble/hashes@2.2.0': {} '@nozbe/lokijs@1.5.12-wmelon6': {} @@ -2055,17 +2058,6 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true - '@xuqm/rn-common@0.6.0-alpha.9(@react-native-async-storage/async-storage@3.1.1(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(axios@1.18.1)(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)': - dependencies: - '@noble/ciphers': 2.2.0 - '@noble/hashes': 2.2.0 - '@react-native-async-storage/async-storage': 3.1.1(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) - axios: 1.18.1 - react: 19.2.7 - react-native: 0.86.0(@types/react@19.2.17)(react@19.2.7) - react-native-blob-util: 0.24.10(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) - zod: 4.4.3 - abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 diff --git a/scripts/sync-server-signature-vector.mjs b/scripts/sync-server-signature-vector.mjs new file mode 100644 index 0000000..d8a1241 --- /dev/null +++ b/scripts/sync-server-signature-vector.mjs @@ -0,0 +1,67 @@ +#!/usr/bin/env node + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const serverRoot = path.resolve( + process.env.XUQM_SERVER_ROOT || path.join(root, '..', 'XuqmGroup-Server'), +) +const source = path.join( + serverRoot, + 'update-service/src/test/resources/rn-release-signature-vector.json', +) +const target = path.join( + root, + 'packages/common/tests/fixtures/rn-release-signature-vector.generated.json', +) +const mode = process.argv[2] + +if ((mode !== '--check' && mode !== '--write') || process.argv.length !== 3) { + throw new Error('Usage: node scripts/sync-server-signature-vector.mjs --check|--write') +} + +function parseVector(bytes, label) { + let vector + try { + vector = JSON.parse(bytes.toString('utf8')) + } catch { + throw new Error(`${label} is not valid UTF-8 JSON`) + } + for (const field of ['keyId', 'publicKeyRawBase64', 'canonicalJson', 'signatureBase64Url']) { + if (typeof vector[field] !== 'string' || !vector[field]) { + throw new Error(`${label} is missing ${field}`) + } + } +} + +if (!existsSync(source)) { + if (mode === '--write') { + throw new Error(`Server signature vector does not exist: ${source}`) + } + console.log(`signature vector check skipped; Server checkout is unavailable: ${source}`) + process.exit(0) +} + +const sourceBytes = readFileSync(source) +parseVector(sourceBytes, 'Server signature vector') + +if (mode === '--write') { + mkdirSync(path.dirname(target), { recursive: true }) + writeFileSync(target, sourceBytes) + console.log(`generated RN signature vector from Server: ${target}`) + process.exit(0) +} + +if (!existsSync(target)) { + throw new Error(`Generated RN signature vector is missing; run with --write: ${target}`) +} +const targetBytes = readFileSync(target) +parseVector(targetBytes, 'Generated RN signature vector') +if (!sourceBytes.equals(targetBytes)) { + throw new Error( + 'Generated RN signature vector differs from Server; run sync-server-signature-vector.mjs --write', + ) +} +console.log('RN signature vector matches Server byte-for-byte') diff --git a/tsconfig.json b/tsconfig.json index 2890999..310bdf4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,7 @@ "@xuqm/rn-common/download": ["./packages/common/src/download.ts"], "@xuqm/rn-common/file-name": ["./packages/common/src/fileName.ts"], "@xuqm/rn-common/internal": ["./packages/common/src/internal.ts"], + "@xuqm/rn-common/internal-security": ["./packages/common/src/internalSecurity.ts"], "@xuqm/rn-common/secure-cache": ["./packages/common/src/secureCache.ts"], "@xuqm/rn-common/version": ["./packages/common/src/version.ts"], "@xuqm/rn-im": ["./packages/im/src"],