程序接入指南
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. 最小完整调用流程
客户端生成 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. 在弹窗生成密钥并导出接入配置
- 后台“客户端授权” → “添加客户端”,填写名称并点击“一键生成密钥对”。
- 浏览器生成匹配的公私钥,不是从公钥反推出私钥。公钥格式为 SPKI PEM(
BEGIN PUBLIC KEY),私钥格式为 PKCS#8 PEM(BEGIN PRIVATE KEY);算法为 RSA 3072 位 + SHA-256 + PKCS#1 v1.5。 - 下载或复制私钥,并勾选“我已保存私钥”;选择该身份可访问的程序后提交。提交接口只有名称、公钥和授权列表,没有私钥。
- 创建成功后点击“下载完整接入配置 JSON”。配置包含
protocol / algorithm / origin / clientId / clientPublicKey / clientPrivateKey / clientPublicKeyFingerprint / serverPublicKey / serverPublicKeyFingerprint / programs / clientName。 - 将 JSON 通过可信渠道交给客户端,或按字段映射到你的程序配置。同一份 JSON 可以分发给多台机器。服务器只保留公钥;关闭弹窗后无法从更新站找回私钥或再次导出含私钥的配置。
使用导出的配置直接检查或下载更新:
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=downloadsprograms 是导出时的授权快照,最终访问权限仍由服务器实时判断。不要把 JSON 放到公开下载目录;服务端公钥信任来自管理员提供的配置,而不是运行时从未认证 API 自动信任。
5. 使用项目内置 PHP 客户端
安装包中的 client/UpdateClient.php 已实现完整认证、响应验签、下载摘要校验及临时文件处理。client/update.php 是命令行入口。它们依赖同一项目中的 src/core.php,不是两个独立的单文件脚本;复制到其他项目时保留 client/ 与 src/core.php 的目录关系。需要 PHP 的 curl 和 openssl 扩展;纯客户端调用不需要连接本站数据库。
在客户端机器生成密钥,只把 client-public.pem 导入后台:
php bin/keygen.php client/keys只检查更新,替换域名、客户端 ID、程序 ID 和公钥路径:
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下载已验证的更新包,默认拒绝降级,不会解压或运行:
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
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);老配置未添加时仍使用原默认值。
'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. 获取服务端签名的一次性挑战
POST /api/v1/challenge
Content-Type: application/json
{"clientId":"32位客户端ID","nonce":"客户端生成的64位随机十六进制"}客户端 nonce 必须来自 CSPRNG,32 字节;每次请求重新生成。
响应统一签名信封:
{
"algorithm": "RSA-SHA256",
"keyId": "服务端公钥指纹",
"payload": "base64url编码的原始JSON负载",
"signature": "base64url编码的RSA签名"
}服务端签名原文如下(UTF-8,无末尾换行):
UPDATE-STATION-RESPONSE-V1\n<payload原样字符串>负载解码后:
{
"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. 客户端签署检查更新或下载请求
每个请求用一个新挑战,不得复用检查更新时的挑战进行下载。
UPDATE-STATION-REQUEST-V1
POST
/api/v1/updates
<clientId>
<challengeId>
<服务端挑战nonce>
<时间戳十进制字符串>
<请求原始body字节SHA-256>以上各行用单个 LF (\n) 连接,末尾没有 LF。采用客户端私钥签名后放在请求头:
X-Client-Id: <clientId>
X-Challenge-Id: <challengeId>
X-Timestamp: <Unix秒>
X-Signature: <base64url签名>
Content-Type: application/json服务端校验:身份启用、挑战归属、未过期未使用、时间戳窗口、RSA 签名。成功后通过数据库条件更新原子消耗挑战:
UPDATE challenges SET consumed_at = ?
WHERE id = ? AND consumed_at IS NULL AND expires_at > ?;受影响行数必须为 1,因此多个 PHP 进程或多台共享数据库的应用节点不会重复接受同一挑战。有效签名之后即消耗挑战,包括后续程序授权失败的请求。 错误签名不会消耗挑战。修改/停用客户端会删除其尚存挑战;每次实际访问还会检查当前客户端状态与程序授权。
检查更新
POST /api/v1/updates
{"programId":"com.example.desktop","currentVersion":"1.0.0"}签名负载 kind=updates,context.clientId / challengeId 为本次请求头值;data 包括:
{
"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 路径重新签名:
POST /api/v1/download
{"releaseId":"32位版本ID"}成功返回 application/octet-stream、Content-Length,以及:
X-Update-Envelope: base64url(签名信封JSON)
Cache-Control: no-store先将响应头做一次 base64url 解码得到信封,再按通用规则验证信封;解码其 payload 得到 kind=download 的负载。data 是该版本的 id / programId / version / filename / sha256 / size / createdAt / enabled,不包含 notes,以控制头大小。
客户端必须检查:
- 服务端 RSA 签名、预置 keyId、协议、状态、响应类型。
- 完整 context 与刚发出的请求一致,尤其 challengeId 和原始 body 摘要。
- 下载清单与此前已验证的更新元数据一致。
- 数据流字节数等于
size,SHA-256 等于sha256。 - 校验完成后才发布最终文件。失败删除
.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,也不能直接代理私有包目录。