Wujies 无界平台
打包与上架

密钥与签名

本文是 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
-Keygen 的默认落点与上面不是同一个

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 字符
已有密钥时 keygen 直接拒绝,不会覆盖

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 失败:

命令用法说明
keygenkeygen <输出目录>生成 private.pem / public.pem;已存在则拒绝
signsign <包目录> <私钥private.pem>在包目录里生成 signature.txt;目录为空报「包目录为空,没有可签名的内容」
verifyverify <包目录或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>。解包时后者覆盖前者,而签名只算得出一份
上面那批"被排除的条目"只作用在 zip 一侧

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」;缺签名内容报「签名文件格式不正确(缺少签名内容)」。

后两行是给人看的,且命令行 verify 不校验格式版本

# 开头,解析时被跳过,作用是在只拿到签名文件时能核对覆盖范围。没有 keyId 字段 —— 当前是单公钥模型。

另外:命令行 verify 用的 PackageSignerMain.parseSignature 不校验 app-market-signature/ 的版本号(它只认算法与 base64),版本号校验在服务端的 PackageSecurityInspector 里。

两条设计要点

要点为什么
路径参与签名否则把 A 文件改名成 B 可以逃过校验。路径是摘要输入的一部分,改名、移动、增删文件都会让签名失效
只签内容,不签 zip 元数据zip 条目还有时间戳、压缩级别、外部属性。算进去的话,重新打一次包签名就废了(哪怕内容一字未改)。所以「按目录签名」与「按 zip 签名」得到同一份数据 —— 这也是打包脚本能先给暂存目录签名、再打 zip 的原因

为什么不把签名塞进 app.json

因为 JSON 的键序在不同语言/库之间不保证一致。把签名放进被签名的 JSON 里,就得先做一套"规范化 JSON"(排序键、固定分隔符、统一转义),跨语言极易出错;独立文件完全没有这个问题。

清单里的 assets.signature 字段没有代码在读

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 返回。

最容易犯的错:公钥没配就切 enforce

此时所有包都会被拒(包括你自己签的),因为服务端没法验证任何签名。报错里会说明是「未配置 public-key」而不是「签名无效」—— 别被绕进去。


签名就绪度自检

GET /app/console/signature-readiness      # 权限 app:console:list

SignatureReadinessService.check() 把 app_version 里已发布(status = 1,见 AppMarketConstants.VERSION_STATUS_RELEASED)的版本逐个下载验签,直接回答「现在能不能切 enforce」。它属于上架端(控制台就是目录的所有者,直接用 Mapper 是正常的),所以用户版下不可用:/app/console/** 被形态闸门挡住,而它不在例外清单里(MarketEdition.java:62-69)。

返回字段含义
mode / publicKeyConfigured当前模式 / 是否配了公钥
readytrue 才建议切 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),不要做成定时任务 —— 那只会每天白拉一遍对象存储。


换钥要同时做三件事

  1. 重新生成密钥对(旧私钥丢了就只能重来,没有找回的办法)。先备份并移走旧钥,keygen 拒绝覆盖。
  2. 把新的 public.pem 整段贴进 app-market.security.public-key —— package.ps1 -SyncPublicKey 可以代劳(写成 public-key: | 块,并吞掉旧的 PEM 行)。
  3. 重建宿主并重启:mvn -pl wujies-admin -am -B install -DskipTests。
第 3 步最常被漏

公钥是启动期读进内存的(@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,而不是比对清单里的版本号。


相关阅读

目录