密钥与签名
本文是 1.x 的归档文档(冻结于 1.x)。该分支只接收勘误修正, 内容基本不再变动;文中的源码路径、配置项与版本号均以 1.x 为准,不建议用于新项目。
新项目请查看 5.x 最新文档。已在 1.x 上运行的项目,本文仍是可靠依据。
这一页解决三件事:密钥放在哪、怎么生成与签名、换钥要同时做哪三件事。
签名回答的唯一问题是:这个包是不是我签发的、上传之后有没有被改过。 它不判断代码好坏。为什么必须有签名(以及白名单管的是另一件事)见 包安全与签名;off / warn / enforce 三种模式和「现在能不能切 enforce」见 安全校验模式。
四样东西,别混
| 东西 | 位置 / 名字 | 谁读它 |
|---|---|---|
| 私钥 | wujies/wujies-plugins/keys/private.pem | 只有打包时的 PackageSignerMain sign 读它 |
| 公钥 | 与私钥同目录的 public.pem | 打包后的自查(verify);以及和宿主配置比对 |
| 宿主配置项 | app-market.security.public-key(wujies-platform/wujies-admin/src/main/resources/application.yml) | PackageSecurityInspector(上传/下载校验)、SignatureReadinessService(自检) |
| 包内签名文件 | 包根 signature.txt | 校验侧 PackageSecurityInspector.parseSignatureFile |
一句话记住:私钥只签名、公钥只验签;-SigningKey 只是私钥路径,签名是打包时算出来的产物(包根 signature.txt),不是某个"打开签名"的开关。
密钥放在哪
主用位置是插件仓库的 keys/,也就是 wujies/wujies-plugins/keys/:private.pem + public.pem。该目录在 wujies/wujies-plugins/.gitignore 第 10 行(keys/)被忽略,所以它只存在于发布方本机。目录结构见 仓库结构。
不带 -SigningKey 时,package.ps1 的 Find-SigningKey 按下面的顺序找第一个存在的私钥:
| 顺序 | 位置 |
|---|---|
| 1 | 环境变量 WUJIES_SIGNING_KEY |
| 2 | <插件仓库>/keys/private.pem(即上表主用位置) |
| 3 | <平台仓库>/plugins/keys/private.pem(拆仓库前的历史位置) |
| 4 | <平台仓库>/keys/private.pem |
package.ps1 在 -Keygen 分支里写死的默认路径是 <平台仓库>\plugins\keys\private.pem(package.ps1:697),而本仓库实际在用的私钥在插件仓库 keys/ 下。
所以要么显式指定落点,要么记住这一条:
# 落在插件仓库(推荐,与自动发现的第 2 顺位一致)
powershell -NoProfile -ExecutionPolicy Bypass -File package.ps1 -Keygen -SigningKey keys\private.pem(-Keygen 要求路径以 private.pem 结尾,否则直接报错 —— keygen 固定产出 private.pem / public.pem。)
生成密钥(keygen)
# 在插件仓库根 wujies/wujies-plugins/ 执行;目录不存在会自动建
powershell -NoProfile -ExecutionPolicy Bypass -File package.ps1 -Keygen -SigningKey keys\private.pem底层就是 PackageSignerMain keygen 这一条纯 JDK 命令(脚本只是把 keygen + 公钥比对 + 提示串起来):
# 在平台仓库根 wujies-platform/ 执行
java -cp wujies-modules\wujies-app-market\target\classes `
cn.wujies.appmarket.security.PackageSignerMain keygen wujies\wujies-plugins\keys产出与格式(PackageSignerMain.keygen / writePem):
| 项 | 值 |
|---|---|
| 算法与长度 | RSA 2048(KeyPairGenerator.getInstance("RSA").initialize(2048)) |
| 私钥 | PEM,标签 -----BEGIN PRIVATE KEY-----(PKCS#8) |
| 公钥 | PEM,标签 -----BEGIN PUBLIC KEY-----(X.509 SubjectPublicKeyInfo) |
| 文件名 | 写死 private.pem / public.pem,每行 64 字符 |
private.pem 或 public.pem 任一存在就报错:
失败: 密钥已存在,拒绝覆盖: <目录>(要轮换请先自行备份并移走旧密钥)覆盖密钥等于让所有已签发的包失效,所以必须是显式动作。(package.ps1 在这一步之前还会先查一次 private.pem 并报「目录里已经有 private.pem…请先手工备份并移走它」。)
签名工具随应用市场模块一起编译。target/classes 里没有那个 class 时报:
找不到签名工具类: <平台仓库>\wujies-modules\wujies-app-market\target\classes\cn\wujies\appmarket\security\PackageSignerMain.class
签名工具随应用市场模块一起编译,请先构建平台:
mvn -pl wujies-admin -am -B install -DskipTests生成成功后,keygen 会直接打印一段可以粘贴进 yml 的片段(mode 是提示文字,按你的实际模式填):
app-market:
security:
mode: enforce # 确认所有包都能通过后,从 warn 切到 enforce
public-key: |
-----BEGIN PUBLIC KEY-----
<你的公钥内容>
-----END PUBLIC KEY-----配置项位置与生效时机见 配置参考:public-key 是启动期读入的 @Value(PackageSecurityInspector:100),改完必须重建宿主 + 重启。
给包签名(sign)
日常只需要一个参数。不传 -SigningKey 时脚本会自动发现本机私钥(上表 4 个候选位置),发现不了才警告:
# 在插件仓库根执行;以 wujies-hello 为例
powershell -NoProfile -ExecutionPolicy Bypass -File package.ps1 `
-Plugin wujies-hello -SigningKey keys\private.pem| 参数 | 作用 |
|---|---|
-SigningKey <私钥路径> | 私钥。传了就在打 zip 之前给暂存目录签名,产出包根 signature.txt |
-SigningPublicKey <公钥路径> | 自查用公钥,默认取 -SigningKey 同目录下的 public.pem;同目录没有就跳过自查(只提示) |
-SyncPublicKey | 把公钥写进宿主 application.yml 的 public-key: 块,并提醒必须重建宿主 + 重启 |
-NoSign | 显式不签名。与 -SigningKey 互斥(同时传直接报错) |
-Keygen | 只生成密钥,不构建不打包 |
两个自动行为要知道(都在 package.ps1):
- 传了不存在的路径、且以
private.pem结尾 → 自动 keygen 到该目录再接着签名(package.ps1:1058-1070)。这是"第一次用"的最短路径; - 未传
-SigningKey且本机没有私钥 → 不会自动生成(新钥和宿主配的公钥对不上,生成了也只会得到一堆被拒的包),而是Write-Warning说清「宿主app-market.security.mode=enforce时这个包上传/部署会被拒绝」。注意脚本在这一栏里给出的路径提示仍写着历史位置<平台仓库>\plugins\keys\private.pem(package.ps1:1117),实际自动发现以"密钥放在哪"那节的表为准。
签名成功时会看到三行(前两行来自 PackageSignerMain.sign,第三行来自脚本的自查)。下面这段是真实的一次 gallery-1.0.5 打包输出:
使用私钥签名: …\keys\private.pem
已签名: …\.plugin-dist\gallery\1.0.5\gallery-1.0.5\signature.txt
覆盖 5 个文件(不含 signature.txt)
签名自查通过自查用的是同一份规范化实现(PackageDigest)+ verify,所以它能提前发现「签出来的东西后端不认」,而不是等安装时才炸。
签名后脚本还会顺手比对宿主的 public-key:一致打 ✓ 公钥与宿主 …application.yml:NNN 一致,不一致或为空就警告并说清改法。比对用的是 PackageKeys 同一套规范化(去掉 PEM 标记 + 所有空白),刻意不自己写一份"公钥相等"的判定。
三个子命令
工具是 cn.wujies.appmarket.security.PackageSignerMain,退出码 0 成功 / 1 失败:
| 命令 | 用法 | 说明 |
|---|---|---|
keygen | keygen <输出目录> | 生成 private.pem / public.pem;已存在则拒绝 |
sign | sign <包目录> <私钥private.pem> | 在包目录里生成 signature.txt;目录为空报「包目录为空,没有可签名的内容」 |
verify | verify <包目录或zip> <公钥public.pem> | 校验签名;通过打印「签名有效: …」,不通过打印「签名无效(内容与签名不匹配): …」 |
verify 目录与 zip 都支持(目录走 fileDigestsOfDir,zip 走 fileDigestsOfZip),这正是下面「只签内容、不签 zip 元数据」换来的性质。
$classes = "wujies-modules\wujies-app-market\target\classes" # 相对平台仓库根
$keys = "keys" # 相对插件仓库根
# 生成
java -cp $classes cn.wujies.appmarket.security.PackageSignerMain keygen $keys
# 签名(package.ps1 会自动调用,一般不用手敲)
java -cp $classes cn.wujies.appmarket.security.PackageSignerMain sign ..\.plugin-dist\hello\1.0.0\hello-1.0.0 $keys\private.pem
# 校验(目录或 zip 都行)
java -cp $classes cn.wujies.appmarket.security.PackageSignerMain verify ..\.plugin-dist\hello\1.0.0\hello-1.0.0.zip $keys\public.pem签名工具只依赖纯 JDK
这是硬约束,不是风格偏好:签名工具是用 -cp wujies-modules/wujies-app-market/target/classes 启动的,这个 classpath 上没有 Spring、没有 wujies-common-*。
| 类 | 职责 | 纯 JDK? |
|---|---|---|
security/PackageDigest | 规范化文本(签名/校验共用这一份) | ✅ |
security/PackageSignerMain | 离线命令行 keygen / sign / verify | ✅ |
security/PackageKeys | 密钥解析(两处共用) | ✅ |
security/PackageSecurityInspector | 白名单 + 验签 + 模式判定 | ❌ 带 ServiceException 与 Spring @Value,签名工具不能引用它 |
签名工具原本复用 PackageSecurityInspector.loadPublicKey,签名本身成功了,却卡在之后的自查上:
NoClassDefFoundError: cn/wujies/common/core/exception/ServiceException所以密钥解析被单独抽成 PackageKeys。不要在签名相关类里引入任何平台依赖 —— 报错信息完全指不到"你不该引这个"。
签名算的是什么
规范化由 PackageDigest 实现,签名方与校验方共用同一份。两边各写一遍实现是这类系统最常见的自伤方式 —— 差一个字节就永远验不过。
对包内每个文件(排除 signature.txt 自己),按路径字符串升序依次拼:
<相对路径>\n<内容 SHA-256 十六进制小写>\n即先写路径、一个换行符,再写该文件内容的 SHA-256(64 位小写十六进制)、一个换行符。全部片段连起来的整段 UTF-8 字节就是要签名/校验的数据(下称规范化文本)。
| 项 | 规则 |
|---|---|
| 遍历顺序 | 路径字符串升序。canonicalText 自己再排一次序,不依赖调用方传进来的是有序 Map |
| 路径形式 | 相对包根的路径,正斜杠 /;条目名里的 \ 一律先换成 / |
| 被排除的条目 | signature.txt;目录项;__MACOSX/ 下的(含 …/__MACOSX/…);绝对路径或含 : 的;含 .. 段的;空名 |
| 内容摘要 | 该文件原始字节的 SHA-256,十六进制小写 |
| 单条目上限 | 256MB(PackageDigest.MAX_ENTRY_BYTES,与解包侧 AppPackageDeployService.MAX_ENTRY_BYTES 一致) |
| 重名条目 | 直接拒绝:应用包内存在重名条目: <name>。解包时后者覆盖前者,而签名只算得出一份 |
fileDigestsOfDir(目录侧)只排除 signature.txt 与目录,不做 PackageDigest.normalize。
所以暂存目录里若真有 __MACOSX/ 之类会被 zip 侧跳过的条目,sign(按目录)与 verify(按 zip)就会算不到一起、自查失败。
签名本身:算法 SHA256withRSA,签的是规范化文本的 UTF-8 字节,签名编码是单行 Standard Base64。
signature.txt 的格式
位置在包根。下面这个样本取自历史目录里的 wujies/wujies-plugins/dist-packages/gallery-1.0.5/signature.txt(那是产物默认落点还在仓库内时打的包;现在的默认落点已改到仓库外的 <插件仓库的上一级>\.plugin-dist\<code>\<version>\<code>-<version>\,见打包应用):
app-market-signature/1
SHA256withRSA
<base64 签名>
# 覆盖文件数: 5
# 内容摘要: <规范化文本的 SHA-256 十六进制小写>校验侧的解析规则(PackageSecurityInspector.parseSignatureFile):跳过空行与 # 开头的注释行;app-market-signature/ 前缀的行必须是 app-market-signature/1,否则报「不支持的签名格式」;第一个非注释行是算法,必须恰好是 SHA256withRSA(否则「不支持的签名算法」);第二个非注释行是 base64 签名,解不出报「签名内容不是合法的 base64」;缺签名内容报「签名文件格式不正确(缺少签名内容)」。
# 开头,解析时被跳过,作用是在只拿到签名文件时能核对覆盖范围。没有 keyId 字段 —— 当前是单公钥模型。
另外:命令行 verify 用的 PackageSignerMain.parseSignature 不校验 app-market-signature/ 的版本号(它只认算法与 base64),版本号校验在服务端的 PackageSecurityInspector 里。
两条设计要点
| 要点 | 为什么 |
|---|---|
| 路径参与签名 | 否则把 A 文件改名成 B 可以逃过校验。路径是摘要输入的一部分,改名、移动、增删文件都会让签名失效 |
| 只签内容,不签 zip 元数据 | zip 条目还有时间戳、压缩级别、外部属性。算进去的话,重新打一次包签名就废了(哪怕内容一字未改)。所以「按目录签名」与「按 zip 签名」得到同一份数据 —— 这也是打包脚本能先给暂存目录签名、再打 zip 的原因 |
为什么不把签名塞进 app.json
因为 JSON 的键序在不同语言/库之间不保证一致。把签名放进被签名的 JSON 里,就得先做一套"规范化 JSON"(排序键、固定分隔符、统一转义),跨语言极易出错;独立文件完全没有这个问题。
AppManifest.Assets.signature 留着不删是为了兼容已上架的清单。填了它不等于签了名 —— 校验只看包内有没有 signature.txt。
校验发生在哪两个点
| 校验点 | 调用点 | 作用 |
|---|---|---|
| 上传时 | AppPackageController.parse(POST /app/package/parse,权限 app:console:add)→ inspectOrThrow | 尽早告诉发布方,别等到安装才发现 |
| 下载后 | AppPackageDeployService.deploy → inspectOrThrow | 真正的防线。校验的是即将落位的那份字节,能挡住「上传之后对象存储里的包被替换」。安装/升级复用的就是这段代码 |
下载后的那次校验在校验和之后、解包之前(AppPackageDeployService:188):enforce 下不通过的包不会落位任何文件。
失败时的两句话(inspectOrThrow 抛的 ServiceException,前缀固定是 应用包安全检查未通过(app-market.security.mode=enforce):):
应用包未签名(缺少 signature.txt)。签名是识别「包是否被改过」的唯一手段,开放第三方上架前必须开启强制校验。
应用包签名校验失败: 签名与包内容不匹配。包内容可能被篡改,或不是用对应私钥签发的。
应用包带签名,但服务端未配置 app-market.security.public-key,无法校验。 ← 公钥没配时是这一句部署报告的 security 字段就是同一份结论的一句话版本(AppPackageDeployService:311 的 security.summary()),形如 模式=enforce, 签名=有效, 无问题。上架时同一句话由 AppPackageParseVo.securityNote 返回。
此时所有包都会被拒(包括你自己签的),因为服务端没法验证任何签名。报错里会说明是「未配置 public-key」而不是「签名无效」—— 别被绕进去。
签名就绪度自检
GET /app/console/signature-readiness # 权限 app:console:listSignatureReadinessService.check() 把 app_version 里已发布(status = 1,见 AppMarketConstants.VERSION_STATUS_RELEASED)的版本逐个下载验签,直接回答「现在能不能切 enforce」。它属于上架端(控制台就是目录的所有者,直接用 Mapper 是正常的),所以用户版下不可用:/app/console/** 被形态闸门挡住,而它不在例外清单里(MarketEdition.java:62-69)。
| 返回字段 | 含义 |
|---|---|
mode / publicKeyConfigured | 当前模式 / 是否配了公钥 |
ready | true 才建议切 enforce(等价于 blockers 为空) |
summary | 一句话结论,可直接给人看 |
blockers | 阻止切换的原因(逐条可读) |
counts / checked / truncated | 五类计数、实际检查了几个、是否因超过上限(MAX_CHECK = 100)被截断 |
items | 逐版本明细:versionId / appCode / version / status / reason |
| 分类 | 含义 | 怎么办 |
|---|---|---|
signed | 签名有效 | 切了会被放行 |
unsigned | 包里没有 signature.txt | 就地重签(见下),或重新打包上传成新版本 |
invalid | 有签名但校验不过 | 包被改过,或不是用同一私钥签的 |
unverifiable | 有签名,但服务端没配公钥 | 去配 public-key(不是包的问题) |
failed | 下载失败 / 没有包地址 | 不影响切 enforce,但表示这个版本没被检查到 |
会真实下载所有包(上限 100,超出会明确报 truncated),不要做成定时任务 —— 那只会每天白拉一遍对象存储。
换钥要同时做三件事
- 重新生成密钥对(旧私钥丢了就只能重来,没有找回的办法)。先备份并移走旧钥,
keygen拒绝覆盖。 - 把新的
public.pem整段贴进app-market.security.public-key——package.ps1 -SyncPublicKey可以代劳(写成public-key: |块,并吞掉旧的 PEM 行)。 - 重建宿主并重启:
mvn -pl wujies-admin -am -B install -DskipTests。
公钥是启动期读进内存的(@Value("${app-market.security.public-key:}"))。不重建、不重启,后端还在用旧公钥 —— enforce 模式下会把你刚签的包全部拒掉,而报错看起来像「签名无效」,方向全错。
application.yml 打在宿主 jar 里,所以「重建」是必须的。脚本自己也会把这句话打出来:yml 打在宿主 jar 里:必须重建宿主并重启,否则 enforce 仍按旧公钥校验。
换钥的代价取决于已经签过多少包:旧钥签过的包全部作废,得逐个重新签一遍,最后再调一次 GET /app/console/signature-readiness 确认 ready=true。所以正式对外发布前,私钥就该备份好、尽量别换(密钥纪律见 备份与安全)。
把历史未签名的包「就地重签」
用于把已发布但未签名的版本补齐 —— 版本号与状态都不动,只把包换成「原内容 + signature.txt」。
# 1) 下载已发布的包,先核对 sha256 与 app_version.package_sha256 一致
curl.exe -s -o original.zip <该版本的 package_url>
# 2) 解包 → 只加签名(内容一个字节都不动)→ 自查 → 按同样的方式重打包
Expand-Archive original.zip pkg
java -cp wujies-modules\wujies-app-market\target\classes `
cn.wujies.appmarket.security.PackageSignerMain sign pkg <插件仓库>\keys\private.pem
java -cp wujies-modules\wujies-app-market\target\classes `
cn.wujies.appmarket.security.PackageSignerMain verify pkg <插件仓库>\keys\public.pem # 必须打印"签名有效"
Compress-Archive -Path "pkg\*" -DestinationPath signed.zip -CompressionLevel Optimal
# 3) 走上传接口(服务端落对象存储并回填校验和)
curl.exe -X POST http://<host>/app/package/parse -F "file=@signed.zip" -F "appId=<appId>"
# 4) 用返回的 package_url / package_size / package_md5 / package_sha256 更新**那一行** app_version
# (版本号 version 与状态 status 都不动)注意事项:
| 项 | 说明 |
|---|---|
| 「内容一个字节都不动」的含义 | 解包后只新增 signature.txt,不修改、不删除、不重命名任何已有文件,也不"顺手"格式化 JSON 或改行尾 |
别只改 app.json 里的字符串就重签 | 那会变成一个新内容的包,与库里记录的校验和不再对应 —— 除非你确实打算换包内容 |
上传接口的 appId | 传了会校验「包内清单的 code 与该应用一致」。历史包若清单 code 有笔误,这里会被拒 |
| 别用「重发一个新版本」代替 | 那会让旧版本永远是未签名的,ready 也就永远上不去 |
| 重打包工具 | 用 Compress-Archive(平台对 \ 与 / 都做了归一化);换 7-Zip / jar cf 时要确认分隔符 |
| 该端点的权限 | POST /app/package/parse 要 app:console:add(见 上架与发布) |
不能用「清单版本 == 版本行版本」当断言。 历史上确实存在两者不一致的已发布包(wujies-platform/AGENTS.md §3.5 记了实测:版本行是 0.1.1,包内 app.json 写的是 0.1.0,当初上架时只改了版本行)。
「就地重签」按定义要「内容一个字节都不动」,所以这类断言会把合法的历史包挡在流程外。核对的正确做法是比对包内容与库里记录的 app_version.package_sha256,而不是比对清单里的版本号。