桌面应用更新接入协议
后台路径:系统工具 → 更新管理 → 对接文档。包含独立的 Web 热更新与 Tauri 安装壳更新,均支持 stable、beta 渠道。Web ZIP 不是壳安装程序;壳按系统和架构独立发布。
- 管理员上传 Web ZIP,后端验证 ZIP 路径和入口,流式计算 SHA256,将 ZIP 上传现有文件系统,保存永久 HTTPS 公网 URL。壳安装包另见 Tauri 安装壳接入。
- 填写 version、壳版本范围和 notes,生成带数字签名的 latest.json。后端从上传记录读取 packageUrl、sha256、size、entry;前端不能覆盖这些字段。时间由服务器生成,使用 ISO8601 UTC。
- 生成签名清单后是草稿。清单上传文件系统,manifestUrl 是不可变、内容寻址的公网地址。管理员明确发布后,通过数据库事务切换该渠道的当前版本。事务失败或服务中断不应产生半完成切换。
- Web 客户端固定请求
/api/v1/updates/desktop/{channel}/latest.json。成功返回原始 JSON,不包装 success/data;使用 no-store。不要将某一版本的 manifestUrl 当作永远更新的入口。
草稿可删除;已发布版本保留历史,包和签名清单不会自动删除。上传/生成失败可能留下未引用的 OSS 文件,当前版本不会被替换。manifest 私钥只允许超级管理员经 HTTPS 和密码复核下载备份,不出现在普通 JSON 响应中,不存储在浏览器。Tauri 私钥由服务器独立生成并加密保存,不通过 OSS 分发。
接入地址与认证
在线文档:/api/v1/updates/desktop/docs。文档地址与清单地址均走现有 API 反向代理,无需单独配置 /docs/ 路由。以下地址按当前访问域名生成,客户端以更新管理页显示的应用更新地址为准。
- 正式渠道:
/api/v1/updates/desktop/stable/latest.json - 测试渠道:
/api/v1/updates/desktop/beta/latest.json
清单、本说明、OSS 公网 ZIP 和不可变 manifestUrl 无需登录。客户端不携带用户 token、管理员 token 或签名私钥。后台发布和密钥操作需要管理员 Bearer token 及对应授权,不能作为客户端更新接口使用。
最小请求
- 客户端构建时固定渠道、schemaVersion=1,以及可信 keyId → Ed25519 公钥映射。公钥由管理员从“签名密钥”下载后内置;私钥不能进入客户端。
- 通过 HTTPS 请求固定渠道的 latest.json。200 时读取原始 manifest,不能读取 data.version 或 data.packageUrl。先验证字段、渠道、壳版本范围,再按下节规则验签;只有验签和版本检查均通过后才能下载、安装。
- 404 表示该渠道尚未发布版本,继续使用现有版本。超时、502/503/其他 5xx 不得清空当前版本,可稍后退避重试。400 表示渠道或请求错误,应修正配置。
- 签名不合法、未知 keyId、壳不兼容、ZIP 摘要/大小不符、解压或启动失败,均保留旧版本。客户端应保留可回滚副本,验证新入口成功后再切换。
强制更新与壳版本判定
新增 forceUpdate 布尔字段,默认 false,由后台新增版本时的“强制更新”开关设置。字段写入完整 manifest 后参与签名,不能发布后直接修改;调整策略需要生成新版本。客户端必须先对原始清单验签,再读取此标志做交互决策。服务端提供策略,是否禁止跳过更新由桌面客户端实现。
- false:发现更高的兼容 Web 版本时,可以选择稍后更新。
- true:发现更高的 Web 版本时,不能跳过更新。保存当前未完成数据后暂停新增业务操作,保留重试与退出入口;下载或安装失败保留旧文件,不把失败当作更新成功。
- 目标版本与已安装 Web 版本相同或更旧时,不触发强制升级,也不自动降级。超时或收到未验签的清单时,不能依据不可信 forceUpdate 阻断业务。
新生成的清单始终包含 forceUpdate。历史已签名清单缺少该字段时,在验签通过后按 false 解释;不得在验签前补字段,也不得修改、重新签署已发布历史清单。客户端必须实现此字段才能执行强制更新,旧客户端忽略字段不会自动获得该能力。
已验签确认的强制更新目标应持久化,后续网络失败或未验签响应不能把它清除;安装成功并确认版本后再解除更新阻断。
当前协议怎样判断需要升级安装壳
安装壳版本 shellVersion 必须从壳的原生构建信息读取,不能使用热更新 ZIP 的 version。Web 版本只记录最近一次验证并成功启用的清单版本。两个版本独立进行 SemVer 比较,不能通过字符串比较或去掉 -web 后缀推断。
- 对已验签且高于当前 Web 版本的目标,若 shellVersion 小于 minShellVersion,禁止安装此 ZIP,先提示升级安装壳。forceUpdate=true 也不能绕过最低壳版本。
- 若 shellVersion 超出 maxShellVersion 的上界,禁止安装此 ZIP;选择兼容 Web 版本或保持当前版本,不应自动降低安装壳版本。0.9.x 的范围上界为 0.10.0(不含)。
- 壳版本处于兼容范围内,才允许 Web 热更新,并根据 forceUpdate 决定是否可稍后更新。
安装壳更新
安装壳由独立的“安装壳更新”模块管理,按 channel、target、arch 分别发布。minSupportedVersion 是最低允许运行的壳版本;version 是目标壳版本。低于最低支持版本,或低于目标版本且 forceUpdate=true 时,客户端必须完成壳更新。minShellVersion / maxShellVersion 仍只表示 Web ZIP 的壳兼容性。壳更新使用 Tauri updater,不解压覆盖业务数据目录;客户端源码不在此项目,本项目提供发布服务、原生协议及策略,客户端仍须完成接入。
Tauri 安装壳接入
依据 Tauri 官方 updater 协议。只接收 Tauri v2 updater 构建产物:Windows .exe/.msi,macOS .app.tar.gz,Linux .AppImage;不接收普通 Web ZIP、DMG 或 v1Compatible 归档。各系统/架构独立发布;同一个 Windows 发布轨道固定使用同一安装器类型,不要混换 MSI/NSIS。
- 超级管理员在“安装壳更新 → Tauri 密钥”由服务器生成独立的 Ed25519/minisign 密钥。下载 .key.pub,将文件全文配置到桌面客户端 plugins.updater.pubkey;不能使用 PEM,也不能从更新接口动态获取公钥来建立信任。配置 bundle.createUpdaterArtifacts=true;上传 Windows NSIS/MSI、macOS .app.tar.gz 或 Linux .AppImage,不是 Web ZIP。服务器签名不替代 OS 代码签名。
- 后台选择 channel、target、arch,填写与实际安装包一致的原生 version,选择 Tauri 密钥后上传构建产物;无需手动提交 .pub/.sig。服务器计算 SHA256、size,用 Bouncy Castle 生成 Tauri 兼容 minisign 签名,在 trusted comment 内签入文件名、服务器时间、version,再自验包签名及全局签名后保存。上传返回 tauriSignature,可下载为原文件名.sig。修改密钥或版本后必须重新上传签名。创建时填写 minSupportedVersion、forceUpdate、notes,version 必须与签名版本一致。管理员必须核对安装包真实原生版本;服务器不会从所有平台安装器中提取或证明其内置版本。生成 JCS/Ed25519 策略清单后为草稿,不立即发布。
- 发布需 clientTrustUpdated=true,确认目标客户端同时信任 Tauri 安装包公钥和系统 manifest 公钥。首次发布固定轨道的 Tauri 公钥,后续不能直接替换;更换 Tauri 密钥须另行设计原生客户端信任迁移,不能把现有 PEM 当作 Tauri 公钥。manifest 密钥轮换沿用现有 keyId 信任流程,与 Tauri 构建密钥独立。
- 发布通过事务原子切换指针;目标壳版本必须递增,minSupportedVersion 不允许降低,失败保留原发布版本。相同当前 release 重试幂等;已发布记录不可删除。恢复旧功能请发布更高的修复版本,不降低壳版本。
- Tauri endpoints 指向下例原生接口,无需鉴权。无新版本或尚未发布时返回 204;有新版本返回 200,signature 是 Tauri .sig 的内容。额外 manifest 字段是完整的已签名策略,可从 update.rawJson(Rust 为 raw_json)读取。清单独立查询 latest.json 无发布时返回 404。5xx/验签失败不是“没有更新”。
{"bundle":{"createUpdaterArtifacts":true},"plugins":{"updater":{"pubkey":"<Tauri .pub 文件内容,不是 PEM>","endpoints":["https://test.echoaigc.com/api/v1/updates/desktop/shell/stable/{{target}}/{{arch}}/{{current_version}}"],"windows":{"installMode":"passive"}}}}
使用支持版本绑定的 Tauri CLI 构建;支持 requireSignedVersion 的 updater 可开启该保护。安装器签名与 OS 代码签名是不同层次,macOS 签名/公证、Windows 代码签名仍按桌面构建规范执行。此 API 不是替代原生 updater 的安装脚本。
Tauri 服务器密钥与备份
Tauri 私钥使用现有 CryptoService AES-GCM 加密入库,与 manifest PEM 密钥分表、独立生成,不需要新增私钥路径环境变量,也不要求生产服务器安装 Node、Rust 或 Tauri CLI。生成和导出仅 SUPER_ADMIN 且具备 system.updates.keys.manage,经 HTTPS、当前管理员密码复核及失败限流。导出的 .key 是标准 Base64 包装的 minisign 加密私钥,使用独立 12–128 字符备份口令(scrypt N=65536/r=8/p=1);口令不保存。客户端下载的是公钥,绝不能携带私钥。列表及审计不包含私钥或口令。
标准 tauri build 开启 createUpdaterArtifacts=true 时,也需向构建环境提供下载的 .key 及备份口令,分别使用 TAURI_SIGNING_PRIVATE_KEY 和 TAURI_SIGNING_PRIVATE_KEY_PASSWORD,存放在受保护的构建 Secrets。这是构建端配置,业务服务端无需这些环境变量;后台仍自行签署实际上传字节,不依赖构建 .sig。已发布轨道的公钥指纹继续锁定,生成新密钥不会自动替换旧密钥;历史清单保持原样。旧轨道若使用外部生成的密钥,本次不会自动导入或绕过锁定,需要另行安排安全迁移。本模块不提供直接删除、更换已绑定密钥的入口。
ShellSigningKey 字段
| 名称 | 位置 | 类型 | 必填 | 默认 | 说明/约束 |
|---|---|---|---|---|---|
| keyId | response.data | string | 是 | 服务器生成 | 42 字节 minisign 公钥的 SHA256 指纹,64 位小写十六进制。 |
| name | response.data | string | 是 | 无 | 密钥名称,去除首尾空白后 1–100 字符。 |
| publicKey | response.data | string | 是 | 服务器生成 | Tauri .key.pub 全文,不是 PEM。 |
| publishedTracks | response.data | integer | 是 | 0 | 已经锁定该公钥的发布轨道数;无全局启用指针。 |
| createdBy | response.data | integer | 是 | 当前管理员 | 创建人后台账号 ID。 |
| createdAt | response.data | datetime | 是 | 服务器时间 | 密钥生成时间。 |
客户端执行顺序
- 从原生构建信息读取 shellVersion,使用标准 SemVer 比较,不用热更新 version、不按字符串比较。
- 调用 Tauri check;有更新时,在原生层验证 raw_json.manifest 的完整 JCS/Ed25519 签名(只排除 manifest 顶层 signature),使用预置可信公钥。校验 kind=shell、schemaVersion=1、channel、target、arch;比对外层 version/url/signature 分别等于 manifest.version/packageUrl/tauriSignature,拒绝混配和旧清单重放。
- 当前壳低于 minSupportedVersion,或目标更高且 forceUpdate=true:保存当前数据,禁止跳过;仅提供重试/退出。其他更高版本可稍后更新。把已验签强更目标及最低支持版本持久化;网络失败不能解除已知限制。
- 通过 Tauri updater 下载并验签。原生层在 install 前额外核对下载字节 size 和 SHA256;安装、重启成功并从原生读回新版本后,才确认更新完成。不要仅因 downloadAndInstall 返回就删除旧状态或业务数据。失败不清除原安装及业务数据,不标记成功。
- 壳满足要求后再检查 Web ZIP;Web 与壳的版本和 forceUpdate 各自独立。Web 要求更高壳而当前轨道无满足 minShellVersion 的安装包时,不应用该 ZIP,提示发布配置问题,不安装不兼容资源。
客户端必须实现以上原生校验与强更 UI。本仓库不含桌面安装壳源代码,本次不声称已完成客户端自动安装或原生跨平台端到端验收。
ShellManifest 字段
| 名称 | 位置 | 类型 | 必填 | 默认 | 说明/约束 |
|---|---|---|---|---|---|
| schemaVersion | manifest | integer | 是 | 1 | 协议版本。 |
| kind | manifest | string | 是 | shell | 仅安装壳策略,不能与 Web 清单混用。 |
| channel | manifest | string | 是 | 无 | stable / beta。 |
| target | manifest | string | 是 | 上传记录 | windows / darwin / linux。 |
| arch | manifest | string | 是 | 上传记录 | x86_64 / aarch64 / i686 / armv7;darwin 不支持后两者,windows 不支持 armv7。 |
| installerType | manifest | string | 是 | 上传文件类型 | nsis / msi / app / appimage;轨道首次发布后不能切换类型。 |
| version | manifest | string | 是 | .sig 绑定版本 | SemVer,最多 128 字符,不含 build;stable 不接受预发布。 |
| minSupportedVersion | manifest | string | 是 | 无 | 最低支持壳版本,不高于 version;后续发布不允许降低。 |
| forceUpdate | manifest | boolean | 是 | false | 强制更新到该更高目标版本;独立于 Web 的强更标志。 |
| packageUrl | manifest | string | 是 | 文件服务返回 | 永久 HTTPS URL;创建请求不可覆盖。 |
| sha256 | manifest | string | 是 | 服务器计算 | 64 位小写十六进制 SHA256 摘要。 |
| size | manifest | integer | 是 | 服务器计算 | 实际字节数。 |
| tauriSignature | manifest | string | 是 | 服务器签名记录 | Tauri .sig 内容,标准 Base64 编码的 minisign 文本;原生响应外层 signature 使用此值。历史记录不重签。 |
| tauriKeyId | manifest | string | 是 | 服务器计算 | 42 字节 minisign 公钥数据的 SHA256 指纹;不是 manifest keyId。 |
| keyId | manifest | string | 是 | 当前清单密钥 | 系统现有 Ed25519 SPKI 公钥 SHA256 指纹;不是 tauriKeyId。 |
| signature | manifest | string | 是 | 服务器签名 | 完整清单 JCS/Ed25519 签名;不是 Tauri .sig。 |
| releasedAt | manifest | datetime | 是 | 服务器时间 | UTC ISO8601,不随发布变更。 |
| notes | manifest | string[] | 是 | [] | 最多 50 条说明。 |
| notes[] | manifest | string | 是 | 无 | 每条非空、最多 1000 字符,与 Web 清单相同。 |
客户端校验
签名算法:Ed25519(RFC8032)。序列化:JCS(RFC8785)。signature 是标准 Base64 编码的 64 字节 Ed25519 签名。签名覆盖完整 manifest,唯一排除顶层 signature 字段;不是直接签 ZIP,不是 HMAC,也不是将摘要拼接后再次计算普通哈希。
- 使用 HTTPS 获取当前渠道清单。对原始 JSON 拒绝重复键,要求 schemaVersion=1、channel 等于请求渠道,校验字段类型。不要在验签前 trim 或改写字段。
- 删除顶层 signature,使用 RFC8785 canonicalization 得到 UTF8 字节,按 keyId 从客户端内置的可信公钥集合选择 Ed25519 公钥验签,未知 keyId 必须拒绝。未知字段也参与签名,不能选择性忽略。
- 核对壳版本:
shellVersion >= minShellVersion且符合 npm semver 的<=maxShellVersion约束;0.9.x 的上界为 0.10.0(不含)。version 使用 SemVer 比较,不允许自动安装比当前版本旧的版本;存储已接受的最新 releasedAt,拒绝旧清单重放。服务端时间戳参与签名,不等于自动防重放。 - 下载 packageUrl;限制下载大小,核对实际 size、SHA256(小写十六进制)。校验失败必须中止,不执行、不覆盖现有应用。
- 在新目录安全解压,拒绝绝对路径、目录穿越、符号链接和超限解压。成功验证入口后原子切换;失败保留旧版本。仅依靠验签不能代替安全解压。
Node.js 验签示例:canonicalize 使用 RFC8785 实现;pinnedKeys 来自打包进客户端的可信 keyId → 公钥映射,不能来自下载的 manifest 或动态获取的无认证公钥。
import canonicalize from 'canonicalize';
import { verify, createHash } from 'node:crypto';
const { signature, ...payload } = manifest;
const pinnedPublicKeyPem = pinnedKeys[payload.keyId];
if (!pinnedPublicKeyPem) throw new Error('Untrusted signing key');
const valid = verify(null, Buffer.from(canonicalize(payload), 'utf8'),
pinnedPublicKeyPem, Buffer.from(signature, 'base64'));
if (!valid) throw new Error('Invalid manifest signature');
// Before extraction, verify the downloaded bytes and size:
if (zipBytes.length !== payload.size ||
createHash('sha256').update(zipBytes).digest('hex') !== payload.sha256)
throw new Error('Package checksum mismatch');密钥管理与部署
无需 DESKTOP_UPDATE_PRIVATE_KEY_PATH / DESKTOP_UPDATE_PUBLIC_KEY_PATH。超级管理员在“签名密钥”中由服务器生成 Ed25519 密钥;新密钥默认不启用。公钥为 SPKI PEM,私钥备份为 PKCS8 PEM。
- 首次使用:生成密钥 → 下载公钥 → 将 keyId 和公钥内置到客户端 → 确认启用 → 上传 ZIP 并发布。
- 更换密钥:先生成备用密钥,不覆盖旧密钥;先发布同时信任旧、新公钥的客户端,再启用新密钥。客户端按照 keyId 从内置可信公钥集合选择,不能从 manifest 或公开下载接口动态建立信任。服务端保留历史密钥,用历史 keyId 验证原清单;不重新签署、修改或替换已发布清单。
- 启用必须提交 clientTrustUpdated=true 和预期的 expectedActiveKeyId。没有当前密钥时 expectedActiveKeyId=null;另一管理员先完成切换会返回 409,必须刷新后重新确认。
- 仅 SUPER_ADMIN 且有 system.updates.keys.manage 可以生成、启用、下载私钥及查询审计;敏感操作必须 HTTPS,重新输入当前管理员密码。复用后台密码校验和失败限流,密钥操作成功记录操作人、keyId、动作、时间,审计不保存密码、私钥或密文。
- 私钥备份下载是未加密 PEM,必须离线妥善保管,绝不能内置到客户端或上传 OSS。下载响应为附件,Cache-Control:no-store;私钥不出现在普通 JSON 响应、列表、公钥文件或前端持久化状态中。公钥下载要求后台查看授权。
私钥沿用项目 CryptoService 的 AES-GCM 加密存入数据库,当前密钥指针也持久化在数据库,不会随重启自动生成或丢失。请备份 yc_desktop_update_signing_key、yc_desktop_update_keyring 和项目原有 crypto.secret;不能随意更改既有凭证加密主密钥。此加密沿用现有架构,不等于能抵御持有完整数据库备份(包含 crypto.secret)的攻击者。
迁移 V20261009110000、V20261009120000、V20261009130000、V20261009140000 均需执行;140000 新增 Tauri 密钥和审计表,不改写任何历史清单。备份 yc_desktop_shell_signing_key、yc_desktop_shell_key_audit 及原有 crypto.secret。后台接口依赖现有 FILE_SERVICE_*;Web ZIP 与安装壳由浏览器执行 upload-credential → 文件系统 precheck → OSS PUT → upload-complete,业务后台不接收文件 multipart。完成后仅提交永久 packageUrl、fileName、size、md5;后台从同源、当前 systemCode 路径流式下载临时副本,复核大小和摘要,再执行 ZIP 结构校验或 Tauri 签名。可选 DESKTOP_UPDATE_MAX_PACKAGE_BYTES 默认 2147483648(2 GiB),Web 与壳包共用,同时需要保障后台临时磁盘空间和文件系统下载超时。反向代理必须终止 HTTPS、覆盖 X-Forwarded-Proto 并隔离后端端口,不能接受用户伪造的转发头;禁止记录敏感接口请求体或响应体。普通后台账号仍分别授权 system.updates.read、system.updates.write、system.updates.publish,普通账号即使被授予密钥管理功能也不能操作私钥。
SigningKey 字段
| 名称 | 位置 | 类型 | 必填 | 默认 | 说明/约束 |
|---|---|---|---|---|---|
| keyId | response.body | string | 是 | 服务器生成 | 公钥 SPKI DER 的 SHA256,小写 64 位十六进制,唯一标识。 |
| algorithm | response.body | string | 是 | Ed25519 | 当前唯一算法。 |
| publicKeyPem | response.body | string | 是 | 无 | SPKI 公钥,不包含私钥。 |
| active | response.body | boolean | 是 | false | 是否当前签名密钥;生成不自动启用。 |
| createdBy | response.body | integer | 是 | 当前管理员 | 创建人后台账号 ID。 |
| createdAt | response.body | datetime | 是 | 服务器生成 | 生成时间。 |
Manifest 字段
| 名称 | 位置 | 类型 | 必填 | 默认 | 说明/约束 |
|---|---|---|---|---|---|
| schemaVersion | body | integer | 是 | 1 | 协议版本,客户端只接受其支持的值。 |
| keyId | body | string | 是 | 当前密钥 | SPKI DER 的 SHA256 指纹;此字段也参与签名,客户端只使用内置可信 keyId。 |
| channel | body | string | 是 | 无 | stable 或 beta。 |
| version | body | string | 是 | 无 | SemVer,例如 0.9.15-web.1;最多 128 字符,同渠道唯一。 |
| minShellVersion | body | string | 是 | 无 | 最低 SemVer 壳版本(包含)。 |
| maxShellVersion | body | string | 是 | 无 | 最高 SemVer(包含)或 trailing x 通配,如 0.9.x、0.x;不得低于最低版本。 |
| forceUpdate | body | boolean | 新清单是 | false | 是否强制更新,参与签名。仅对更高目标版本生效,不绕过壳兼容性。历史清单缺失时只在验签后按 false 解释。 |
| packageUrl | body | string | 是 | 无 | 永久 HTTPS 公网 ZIP 地址,不含临时签名查询参数。 |
| sha256 | body | string | 是 | 无 | 后端计算的 ZIP 字节摘要,64 个小写十六进制字符。 |
| signature | body | string | 是 | 无 | 完整清单除本字段外 JCS 后的 Ed25519 签名,标准 Base64。 |
| size | body | integer | 是 | 无 | ZIP 实际字节数。 |
| entry | body | string | 是 | index.html | ZIP 内安全相对路径,上传时检查存在且为非空文件。 |
| releasedAt | body | string | 是 | 服务器生成 | 签名清单生成时间,ISO8601;发布不改写此签名字段。 |
| notes | body | string[] | 是 | [] | 最多 50 项,每项 1–1000 字符;数组顺序参与签名。 |
| notes[] | body | string | 是 | 无 | 一条更新说明。 |
示例(signature 是占位值,不可用于实际验签)
错误约定
管理员接口成功使用 {"success":true,"data":...}。公共 latest.json 成功是原始 manifest;错误仍使用项目统一错误对象 {"success":false,"code":"NOT_FOUND","message":"...","data":null}。
- 400:文件系统对象无效、ZIP 无效、超限、入口不存在、版本/壳范围/notes 无效、重复版本、签名失败、只能删除草稿等业务校验错误。
- 401:管理员接口缺少或无效 Bearer token、身份不符。
- 403:管理员缺少所需功能授权,或密钥操作不是有效超级管理员。
- 409:预期当前密钥与实际不一致,需要刷新。
- 429:当前账号或来源密码失败次数超过既有后台限流。
- 404:更新版本不存在或渠道没有当前已发布清单。
- 503:公共当前清单签名校验失败(包括 keyId 未知或历史公钥无效)。
- 500:文件系统、磁盘、数据库或内部生成失败;不得因此替换当前发布指针。客户端收到非 200 必须继续使用现有版本。