欢の更新站INTEGRATION GUIDE返回管理后台 →
UPDATE-STATION / V1

让其他程序,安全接入更新站。

无需后台登录即可阅读。接入使用独立客户端密钥,不使用管理员账号、Cookie 或 CSRF Token。

POST /api/v1/challengePOST /api/v1/updatesPOST /api/v1/download

程序接入指南

1. 接入前准备

本更新站只提供私有更新服务,没有公开下载链接。其他程序需要通过应用层双向 RSA 签名认证,管理员账号密码不能代替客户端认证。

  • API 基地址:当前站点的最终 HTTPS 源地址,例如 https://updates.example.com。不要包含尾斜杠、路径或查询参数;程序只支持部署在域名根路径。本机开发可以使用 http://127.0.0.1:8088。
  • programId:后台“程序与版本”里创建的程序标识,例如 com.example.desktop。长度 2–64,使用小写字母、数字、点、下划线或横线,以小写字母或数字开头。
  • clientId:后台“客户端授权”登记公钥后分配的 32 位小写十六进制 ID,不是程序标识或公钥指纹。
  • 客户端私钥:可在后台“添加客户端”弹窗中一键生成 RSA 3072 位密钥对,然后下载私钥;也可在客户端本地生成并导入公钥。私钥只在管理员浏览器中生成、显示和导出,不提交或保存在更新站。不同授权身份使用不同密钥,同一身份允许多台机器共享。
  • 服务端公钥:从后台“认证与密钥”导出,通过可信渠道送到客户端并核对指纹。不要依赖本页面或未经认证的 API 动态获取并首次信任公钥。
  • 在后台为该客户端勾选允许访问的程序,启用客户端和程序,上传版本并设为当前发布版本。
  • 保持两端系统时钟准确。默认挑战有效期 90 秒,客户端请求时间偏差允许 60 秒;实际值由服务端配置决定。

2. 最小完整调用流程

text
客户端生成 32 字节随机数
  → POST /api/v1/challenge
  → 验证服务端签名、指纹、上下文与挑战有效期
  → 用客户端私钥签名 POST /api/v1/updates
  → 验证更新响应,读取 release 和 updateAvailable
  → 自行判断是否允许更新或降级
  → 重新获取并验证一个新挑战
  → 用客户端私钥签名 POST /api/v1/download
  → 暂存文件,验证签名清单、文件大小、SHA-256
  → 全部成功后保存最终文件

挑战是一次性的,检查更新和下载不能共用;每次重试都要重新获取挑战。下载并不解压、执行或安装程序,安装器的安全校验由接入方负责。

3. 三个客户端接口

  • POST /api/v1/challenge:JSON 包含 clientId 和本次随机 nonce;无须客户端签名,但必须验证服务端返回的签名挑战。
  • POST /api/v1/updates:JSON 包含 programId 和 currentVersion(未知当前版本可传空字符串);请求带四个认证头。响应为签名 JSON 信封,验证后才能读取更新信息。
  • POST /api/v1/download:JSON 包含 releaseId;用新的挑战签名。成功响应为文件流,签名清单位于 X-Update-Envelope 响应头。

所有接口使用 Content-Type: application/json。正文先序列化一次为 UTF-8 字节,用这同一份字节计算 SHA-256、签名和发送;不要签名后再改变空格、字段顺序或中文编码方式。完整请求与响应示例、逐字段定义及签名原文见后面的“双向签名更新协议 v1”。

4. 在弹窗生成密钥并导出接入配置

  1. 后台“客户端授权” → “添加客户端”,填写名称并点击“一键生成密钥对”。
  2. 浏览器生成匹配的公私钥,不是从公钥反推出私钥。公钥格式为 SPKI PEM(BEGIN PUBLIC KEY),私钥格式为 PKCS#8 PEM(BEGIN PRIVATE KEY);算法为 RSA 3072 位 + SHA-256 + PKCS#1 v1.5。
  3. 下载或复制私钥,并勾选“我已保存私钥”;选择该身份可访问的程序后提交。提交接口只有名称、公钥和授权列表,没有私钥。
  4. 创建成功后点击“下载完整接入配置 JSON”。配置包含 protocol / algorithm / origin / clientId / clientPublicKey / clientPrivateKey / clientPublicKeyFingerprint / serverPublicKey / serverPublicKeyFingerprint / programs / clientName。
  5. 将 JSON 通过可信渠道交给客户端,或按字段映射到你的程序配置。同一份 JSON 可以分发给多台机器。服务器只保留公钥;关闭弹窗后无法从更新站找回私钥或再次导出含私钥的配置。

使用导出的配置直接检查或下载更新:

sh
php client/update.php --config=client-客户端ID.json --program=com.example.desktop --current-version=1.0.0 --check-only
php client/update.php --config=client-客户端ID.json --program=com.example.desktop --current-version=1.0.0 --out=downloads

programs 是导出时的授权快照,最终访问权限仍由服务器实时判断。不要把 JSON 放到公开下载目录;服务端公钥信任来自管理员提供的配置,而不是运行时从未认证 API 自动信任。

5. 使用项目内置 PHP 客户端

安装包中的 client/UpdateClient.php 已实现完整认证、响应验签、下载摘要校验及临时文件处理。client/update.php 是命令行入口。它们依赖同一项目中的 src/core.php,不是两个独立的单文件脚本;复制到其他项目时保留 client/ 与 src/core.php 的目录关系。需要 PHP 的 curl 和 openssl 扩展;纯客户端调用不需要连接本站数据库。

在客户端机器生成密钥,只把 client-public.pem 导入后台:

sh
php bin/keygen.php client/keys

只检查更新,替换域名、客户端 ID、程序 ID 和公钥路径:

sh
php client/update.php --url=https://updates.example.com --id=替换为32位客户端ID --private-key=client/keys/client-private.pem --server-key=server-public.pem --program=com.example.desktop --current-version=1.0.0 --check-only

下载已验证的更新包,默认拒绝降级,不会解压或运行:

sh
php client/update.php --url=https://updates.example.com --id=替换为32位客户端ID --private-key=client/keys/client-private.pem --server-key=server-public.pem --program=com.example.desktop --current-version=1.0.0 --out=downloads

在现有 PHP 程序中调用:

php
<?php
require __DIR__ . '/client/UpdateClient.php';

$client = new \UpdateStation\UpdateClient(
    'https://updates.example.com',
    '替换为后台分配的32位客户端ID',
    file_get_contents(__DIR__ . '/client/keys/client-private.pem'),
    file_get_contents(__DIR__ . '/server-public.pem')
);
$currentVersion = '1.0.0';
$update = $client->check('com.example.desktop', $currentVersion);
if ($update['updateAvailable'] && $update['release']) {
    $release = $update['release'];
    if (version_compare($release['version'], $currentVersion, '<')) {
        throw new RuntimeException('拒绝自动降级');
    }
    $file = $client->download($release, __DIR__ . '/downloads');
    // 至此文件已完成验签、大小与 SHA-256 校验;自行决定后续安装流程。
}

check() 和 download() 内部会各自获取新挑战,不要自行复用挑战。调用方需要捕获异常并记录失败;服务端验签失败、授权失效或网络中断时不能继续使用未验证的元数据或临时文件。

6. 在其他语言中实现

  • 请求签名:SHA-256 + RSA PKCS#1 v1.5,不使用 RSA-PSS。
  • Base64 编码:base64url,使用 - 与 _,去掉末尾 =;解码时可补齐填充。
  • 客户端签名原文:按后面的协议拼接 8 行,以单个 LF 连接,末尾不加 LF。时间戳使用十进制 Unix 秒字符串,正文摘要使用小写十六进制。
  • 服务端验签原文:UPDATE-STATION-RESPONSE-V1、一个 LF、信封中的 payload 原样字符串;不是解码后的 JSON。
  • 服务端验签通过后再解码 payload,并校验 protocol / kind / status / issuedAt / context。context 中 HTTP 方法、路径、客户端 ID、挑战 ID 和正文摘要必须与本次请求一致。
  • 挑战响应的 context.clientId 与 context.challengeId 通常为空字符串(正常请求不带认证头);实际客户端 ID 和客户端随机数绑定在 data.clientId / data.clientNonce。更新与下载的 context 使用认证头中的 ID。
  • 下载清单先将 X-Update-Envelope 做一次 base64url 解码得到信封 JSON,再按同样规则验签。下载全过程暂存为临时文件,禁止跳过清单或文件摘要校验。
  • 新上传的 release.filename 为版本号加压缩格式后缀,例如 4.0.1.zip、4.0.1.tar.gz,不会沿用管理员所选原文件名。历史版本可能仍使用原名;实际以验签后的元数据为准。
  • 版本不是严格的语义化版本;服务端的 updateAvailable 只表示与当前发布版本不同。自行制定升级、降级及版本比较策略。

7. 多台机器共享同一授权身份

可以共享:每台机器使用相同的 clientId、客户端私钥及预置服务端公钥。协议不绑定单台机器或固定 IP;这是一份“程序/分发渠道授权身份”,不是每台机器的设备许可证。

  • 每台机器自行生成客户端随机数并获取新挑战,挑战对象不得通过共享文件、缓存或其他机器复用。可以并发请求,只要各自的挑战没有过期或被消耗。
  • 修改授权或停用该身份会影响所有共用机器。客户端列表统计的是授权身份数量;最近认证时间是这些机器中最近一次认证,不是设备数量。
  • 默认同一 clientId 每分钟最多请求 30 个挑战,所有共用机器合计;同一来源 IP 每分钟最多 90 个挑战和 180 次签名认证。一次检查更新需要一个挑战;若要下载,另外需要一个挑战。
  • 分发数量较大时,客户端错峰检查并加入随机延迟,遇到 429 尊重 Retry-After。默认限额不变,但可以在 config/local.php 中按实际负载配置以下可选整数(范围 1–100000);老配置未添加时仍使用原默认值。
php
'challenge_client_per_minute' => 300, // 同一授权身份所有机器的挑战总额
'challenge_ip_per_minute' => 300,     // 同一来源 IP 的挑战总额
'verify_ip_per_minute' => 600,        // 同一来源 IP 的签名请求总额

共享模式仍没有公网直链:普通浏览器或未签名请求不能下载。持有分发私钥的人能够以该共享身份访问其获授权的程序,这是该模式的边界。

8. 常见接入问题

  • 401:检查登记公钥是否对应本机私钥、客户端是否启用、系统时间、挑战是否过期或已消耗、签名正文是否与实际发送字节一致。
  • 403:检查客户端是否获得该程序授权,以及程序是否启用。
  • release=null:没有启用的当前发布版本,需要后台上传并发布。
  • 签名失败:确认使用 PKCS#1 v1.5、base64url、LF 换行、无末尾换行,且响应签名针对 payload 原样字符串。
  • 下载失败:检查响应头是否被代理删除、文件大小和摘要是否一致;丢弃临时文件后用新挑战重试。
  • 429:限流,遵守 Retry-After,避免高频轮询与无间隔重试。
  • 503 或非 JSON 响应:站点或代理不可用,安全失败。不得因错误响应没有签名而改为信任未认证内容。

9. 文档读取地址

  • /docs:本页,浏览器可直接阅读,无需登录。
  • /docs/integration.md:本接入指南原文,适合其他程序或 AI 工具读取。
  • /docs/protocol.md:完整协议原文,包含签名格式、请求响应 JSON、下载校验和错误规则。

这些地址只公开随程序打包的文档,不提供私钥、后台凭据、真实客户端列表或公开程序包链接。生产 API 建议由桌面程序、服务端或更新代理调用;当前未提供浏览器跨域 CORS 接入。

双向签名更新协议 v1

这是应用层双向签名认证,不是 TLS 客户端证书(mTLS)。TLS 负责加密与域名认证;应用层协议负责登记身份、程序授权、防重放和内容签名。

密钥与基础编码

  • 两端各自拥有不同的 RSA 密钥对;默认 3072 位,服务端接受 3072–8192 位客户端 RSA 公钥。
  • 服务端只登记客户端公钥。客户端密钥可在管理员浏览器弹窗生成并导出,也可在客户端生成;客户端私钥不提交或保存在更新站。双方提前固定信任对方公钥。服务端公钥不得通过未认证的更新连接“首次自动信任”。
  • 同一个 clientId 与私钥允许多台机器共享,不绑定固定设备或 IP。每次请求仍独立获取新挑战;共用身份的机器共享授权、停用状态和按身份限流额度。
  • 算法是 SHA-256 + RSASSA-PKCS1-v1_5,不是 RSA-PSS,也不是用私钥加密文件。
  • 公钥 PEM 格式;指纹为规范化 SubjectPublicKeyInfo DER 字节的 SHA-256,64 位小写十六进制。
  • base64url 使用 - 和 _,不带 = 填充;散列使用小写十六进制;时间为 UTC Unix 秒整数。
  • JSON 的签名依据原始 UTF-8 字节,不得解析后重新序列化再验签。
  • API 路径区分大小写,不允许查询参数、尾斜杠或重定向。

A. 获取服务端签名的一次性挑战

http
POST /api/v1/challenge
Content-Type: application/json

{"clientId":"32位客户端ID","nonce":"客户端生成的64位随机十六进制"}

客户端 nonce 必须来自 CSPRNG,32 字节;每次请求重新生成。

响应统一签名信封:

json
{
  "algorithm": "RSA-SHA256",
  "keyId": "服务端公钥指纹",
  "payload": "base64url编码的原始JSON负载",
  "signature": "base64url编码的RSA签名"
}

服务端签名原文如下(UTF-8,无末尾换行):

text
UPDATE-STATION-RESPONSE-V1\n<payload原样字符串>

负载解码后:

json
{
  "protocol": "update-station/v1",
  "kind": "challenge",
  "status": 200,
  "issuedAt": 1700000000,
  "context": {
    "method": "POST",
    "path": "/api/v1/challenge",
    "clientId": "",
    "challengeId": "",
    "bodySha256": "本次请求原始JSON字节的SHA-256"
  },
  "data": {
    "id": "服务端24随机字节转48位十六进制",
    "nonce": "服务端32随机字节转64位十六进制",
    "clientId": "32位客户端ID",
    "clientNonce": "原样回传客户端随机数",
    "expiresAt": 1700000090
  }
}

客户端依次检查:信封算法、公钥指纹、服务端签名、协议版本、HTTP 状态绑定、时间、请求 context,及 data.clientId / clientNonce / expiresAt。任一失败立即停止,不能继续使用私钥发起后续请求。 挑战接口无需客户端签名,但只为已启用的登记身份创建挑战,并受限流保护。

B. 客户端签署检查更新或下载请求

每个请求用一个新挑战,不得复用检查更新时的挑战进行下载。

text
UPDATE-STATION-REQUEST-V1
POST
/api/v1/updates
<clientId>
<challengeId>
<服务端挑战nonce>
<时间戳十进制字符串>
<请求原始body字节SHA-256>

以上各行用单个 LF (\n) 连接,末尾没有 LF。采用客户端私钥签名后放在请求头:

http
X-Client-Id: <clientId>
X-Challenge-Id: <challengeId>
X-Timestamp: <Unix秒>
X-Signature: <base64url签名>
Content-Type: application/json

服务端校验:身份启用、挑战归属、未过期未使用、时间戳窗口、RSA 签名。成功后通过数据库条件更新原子消耗挑战:

sql
UPDATE challenges SET consumed_at = ?
WHERE id = ? AND consumed_at IS NULL AND expires_at > ?;

受影响行数必须为 1,因此多个 PHP 进程或多台共享数据库的应用节点不会重复接受同一挑战。有效签名之后即消耗挑战,包括后续程序授权失败的请求。 错误签名不会消耗挑战。修改/停用客户端会删除其尚存挑战;每次实际访问还会检查当前客户端状态与程序授权。

检查更新

http
POST /api/v1/updates

{"programId":"com.example.desktop","currentVersion":"1.0.0"}

签名负载 kind=updates,context.clientId / challengeId 为本次请求头值;data 包括:

json
{
  "program": {"id":"com.example.desktop","name":"桌面助手"},
  "updateAvailable": true,
  "release": {
    "id":"32位版本ID",
    "programId":"com.example.desktop",
    "version":"1.1.0",
    "filename":"desktop.zip",
    "sha256":"文件摘要",
    "size":12345,
    "notes":"更新说明",
    "createdAt":1700000000,
    "enabled":true
  }
}

无可用当前版本时 release=null。updateAvailable 表示与管理员当前发布版本不同,不代表版本号必然递增。客户端应单独执行降级策略。

下载

获得新挑战,对以下 body 与 /api/v1/download 路径重新签名:

http
POST /api/v1/download

{"releaseId":"32位版本ID"}

成功返回 application/octet-stream、Content-Length,以及:

http
X-Update-Envelope: base64url(签名信封JSON)
Cache-Control: no-store

先将响应头做一次 base64url 解码得到信封,再按通用规则验证信封;解码其 payload 得到 kind=download 的负载。data 是该版本的 id / programId / version / filename / sha256 / size / createdAt / enabled,不包含 notes,以控制头大小。

客户端必须检查:

  1. 服务端 RSA 签名、预置 keyId、协议、状态、响应类型。
  2. 完整 context 与刚发出的请求一致,尤其 challengeId 和原始 body 摘要。
  3. 下载清单与此前已验证的更新元数据一致。
  4. 数据流字节数等于 size,SHA-256 等于 sha256。
  5. 校验完成后才发布最终文件。失败删除 .part,不可运行、解压或安装。

大文件响应签名在传输开始前生成,时间检查应基于请求开始时间而不是仅以下载结束时间判断;参考客户端保留了这个窗口,同时通过一次性挑战绑定本次请求。

不支持 GET、HEAD、Range、断点续传或公开链接。每次重新下载都重新认证。下载过程中连接中断也会消耗挑战。

错误与重试

已初始化服务端的客户端 API 错误也使用签名信封,kind=error、status 与 HTTP 状态一致,data.message 提供错误信息。

  • 400/415/422:请求结构或字段不正确。
  • 401:身份、签名、时效或挑战不通过。
  • 403:程序停用或未授权。
  • 404:版本不存在、下架或接口不存在。
  • 405:不允许的请求方法。
  • 409:管理操作遇到唯一约束冲突。
  • 413:超出应用上传限制。
  • 429:限流;尊重 Retry-After。
  • 503:包或站点不可用。

PHP / Nginx / 配置启动失败可能无法返回有效签名。客户端必须安全失败,不能因为是错误响应而绕过验签去信任其内容或下载地址。 网络重试应获取全新挑战,不能盲目重发已签名的旧请求。

实现提示

  • OpenSSL:openssl_sign(..., OPENSSL_ALGO_SHA256) / openssl_verify。
  • Node:crypto.sign('RSA-SHA256', bytes, privateKey);默认 PKCS#1 v1.5。
  • .NET:RSA.SignData(bytes, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1)。
  • 不能将 PKCS#1 私钥、公钥指纹、programId 或 clientId 当作相同概念;客户端身份由登记公钥证明。
  • 横向部署时数据库、服务端签名密钥、包存储必须一致;负载均衡不能缓存签名 API,也不能直接代理私有包目录。