# 双向签名更新协议 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 ``` 负载解码后: ```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 <服务端挑战nonce> <时间戳十进制字符串> <请求原始body字节SHA-256> ``` 以上各行用单个 LF (`\n`) 连接,**末尾没有 LF**。采用客户端私钥签名后放在请求头: ```http X-Client-Id: X-Challenge-Id: X-Timestamp: X-Signature: 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,也不能直接代理私有包目录。