包安全与签名
这一页解决什么问题
应用包(zip)的两个部分都是完全可信代码:backend/*.jar 是任意 Java 代码,跑在宿主 JVM 里;
frontend/** 是跑在宿主同源上下文里的 JS,能拿用户的 token 调任何接口。
所以"能不能装"这件事要由平台回答三个问题,这一页就是这三个问题的答案:
| 问题 | 管这件事的机制 | 落点 |
|---|---|---|
| 包内允许出现哪些文件? | 产物白名单 | PackageSecurityInspector.checkArtifacts |
| 这个包是不是我签发的、上传之后有没有被改过? | 分离式签名(包根 signature.txt) | PackageDigest + PackageSignerMain |
| 检查到什么程度、什么时候拒? | 三档模式 off / warn / enforce | app-market.security.mode |
前两个机制都在 wujies-platform/wujies-modules/wujies-app-market/src/main/java/cn/wujies/appmarket/security/,
本页每条断言都指向那里的类或 wujies/wujies-plugins/package.ps1 —— 看完可以直接去核对。
什么时候必须读:改 app-market/security/** 或打包器的签名流程;要切 app-market.security.mode;
排查"上传 / 部署被安全检查拒绝";要给历史未签名版本补签名。
为什么必须有签名
| 部分 | 它能干什么 |
|---|---|
| 后端 jar | 跑在宿主 JVM 里 —— 能读数据库、读配置、发任何网络请求 |
| 前端 JS | 跑在宿主同源上下文 —— 能拿用户 token 调任何接口 |
所以"装一个来路不明的应用"和"让陌生人在你服务器上执行代码"是同一件事。
静态扫描(关键字黑名单、危险 API 检测)挡不住有意作恶的包,只能提高门槛。
签名是唯一真防线:它不判断代码好坏,只回答「这个包是不是我签发的、有没有被改过」。
答不上来的包就不装。这段推导写在 PackageSecurityInspector 的类注释里。
产物白名单管的是另一件事
白名单挡不住恶意代码,但能挡掉「往前端目录塞个 .jsp 让 Web 容器执行」这类结构性问题
—— 因为前端目录就是宿主的静态资源目录,同源。
规则由 PackageSecurityInspector 的两个常量与 checkArtifacts 决定(ALLOWED_TOP_LEVEL / HOME_LEVEL_FILES / FORBIDDEN_FRONTEND_EXT):
| 位置 | 规则 |
|---|---|
| 包根 | 只允许 app.json 与 signature.txt,其它文件一律报「包根出现不允许的文件」 |
| 顶层目录 | 只允许 backend/、frontend/、migrations/ 三个 |
backend/ | 只允许 .jar;且清单 assets.backend 声明了哪个 jar 时,目录下的其它 jar 会被报成「清单未声明的产物」 |
migrations/ | 只允许 .sql |
frontend/ | 必须命中配置的白名单扩展名,且再减一层硬禁止 |
frontend/ 下无论如何都拒绝的扩展名(写进配置也改不掉):
.jsp .jspx .jspf .php .phtml .war .jar .class
.exe .dll .so .dylib .sh .bat .cmd .ps1 .vbs
.properties .xml .yml .yaml .sql允许的扩展名由 app-market.security.allowed-frontend-extensions 配置,默认值(注解与 application.yml
里是同一串):
.js,.mjs,.cjs,.css,.json,.svg,.png,.jpg,.jpeg,.gif,.webp,.ico,.bmp,
.woff,.woff2,.ttf,.otf,.eot,.map,.txt,.html,.wasmMAX_REPORTED = 6:问题多于 6 条时报告只保留前 6 条,避免把任务日志刷满(checkArtifacts 末尾)。
包内有重名条目时直接计为问题并跳过白名单检查(zipReadable = false)—— 解包时后者覆盖前者,
而签名只算得出一份。
分离式签名(signature.txt)
签名是分离式的:包根一个 signature.txt,覆盖除它自己以外的全部文件 ——
app.json、backend/*.jar、frontend/**、migrations/*.sql(会真实执行的 SQL)都在内。
JSON 的键序在不同语言 / 库之间不保证一致。把签名放进被签名的 JSON 里,就得先做一套"规范化 JSON",
跨语言极易出错;独立文件完全没有这个问题。这段理由写在 PackageDigest.SIGNATURE_FILE 的注释上。
被签名的数据怎么算出来
对包内每个文件(排除 signature.txt 自己),按路径字符串升序依次拼:
<相对路径>\n<内容 SHA-256 十六进制小写>\n拼出来的整段 UTF-8 字节就是要签名的数据,算法是 SHA256withRSA(PackageKeys.ALGORITHM)。
两条要点(PackageDigest 类注释):
- 路径参与签名 —— 否则把 A 文件改名成 B 就能逃过校验;
- 只签名内容、不签名 zip 元数据(时间戳、压缩级别、外部属性)—— 否则重新打一次包签名就废了。 所以「按目录签名」与「按 zip 签名」算出来是同一份数据,这也是打包脚本能先给暂存目录签名、 再打 zip 的原因。
归一化细则(PackageDigest.normalize / canonicalText):
| 项 | 规则 |
|---|---|
| 排序 | 实现里自己 TreeMap 排序,不依赖调用方传进来的是有序 Map |
| 路径分隔符 | 条目名里的 \ 一律先换成 /(Windows 的 Compress-Archive 会写反斜杠) |
| 跳过不参与的条目 | signature.txt、目录项、__MACOSX/ 下的、绝对路径或含 : 的、含 .. 段的、空名 |
| 内容摘要 | 该文件原始字节的 SHA-256,十六进制小写 |
| 单条目上限 | 256MB(MAX_ENTRY_BYTES,与解包侧 AppPackageDeployService 一致) |
| 重名条目 | 直接拒绝:应用包内存在重名条目: <name> |
signature.txt 长什么样
PackageSignerMain.sign 写出的就是下面四行(后两行是注释):
app-market-signature/1
SHA256withRSA
<base64 签名>
# 覆盖文件数: <N>
# 内容摘要: <规范化文本的 SHA-256 十六进制小写>解析侧(PackageSecurityInspector.parseSignatureFile)跳过空行与 # 开头的行;
遇到 app-market-signature/ 前缀的行必须是 app-market-signature/1,否则报「不支持的签名格式」;
第一个非注释行是算法,必须恰好是 SHA256withRSA,否则报「不支持的签名算法」;
第二个非注释行是 base64 签名,解不出来报「签名内容不是合法的 base64」。
后两行只在"手里只有一个签名文件"时用来核对覆盖范围。
签名文件里没有 keyId:服务端只有一把 public-key,所以"换钥"天然是全局作废旧包的操作。
四个类与「纯 JDK」这条硬约束
| 类 | 职责 | 纯 JDK? |
|---|---|---|
PackageDigest | 规范化(签名与校验共用这一份) | 是 |
PackageSignerMain | 离线命令行 keygen / sign / verify | 是 |
PackageKeys | 密钥解析(签名侧与校验侧共用) | 是 |
PackageSecurityInspector | 白名单 + 验签 + 模式判定(inspectOrThrow 在 enforce 下抛异常) | 否 —— 带 ServiceException 与 Spring @Value |
签名工具是用 -cp wujies-modules/wujies-app-market/target/classes 启动的,
这个 classpath 上没有 Spring、没有 wujies-common-*。
这个坑真实发生过:签名工具原本复用 PackageSecurityInspector 里的公钥解析,签名本身成功了,
卡在之后的自查上,报 NoClassDefFoundError: cn/wujies/common/core/exception/ServiceException。
所以密钥解析被单独抽成 PackageKeys(原因写在它的类注释里)。错误信息完全指不到"你不该引这个"。
在哪校验:两处,缺一不可
| 校验点 | 入口 | 作用 |
|---|---|---|
| 上传时 | AppPackageController.parse(POST /app/package/parse,权限 app:console:add)→ inspectOrThrow | 尽早告诉发布方,别等到安装才发现 |
| 下载后 | AppPackageDeployService.deploy → inspectOrThrow | 真正的防线:校验的是即将落位的那份字节,能挡住"上传之后对象存储里的包被替换" |
AppPackageDeployService.deploy 里的顺序是刻意排的(deploy(Long, boolean)):
requireVersion → download → verifyChecksum(MD5/SHA256) → inspectOrThrow → 解包落位校验和的顺序也不能调:校验和只证明"没下坏",证明不了"是不是我们签发的"。
enforce 下不通过的包连解都不会解,插件目录里不会落位任何文件 —— 这一点实测过。
失败时抛的就是这句话(inspectOrThrow):
应用包安全检查未通过(app-market.security.mode=enforce):应用包未签名(缺少 signature.txt)。…结论还会落到两个地方:部署报告(POST /app/version/{versionId}/deploy 的返回)里的
security 字段,以及上传返回体 AppPackageParseVo.securityNote。
格式都是 Report.summary() 的一句话版本:模式=enforce, 签名=有效, 无问题。
AppPackageController.parse 是先 sysOssService.upload(file)、后 inspectOrThrow。
所以被拒的上传会在对象存储里留下一个孤儿对象(版本行并没有写)。
要清理就去 OSS 里按上传时间找,别以为"拒了就等于什么都没发生"。
三档模式(app-market.security.mode)
| 模式 | 行为 |
|---|---|
off | 不拒绝、也不告警,等价于改造前的行为(problems 还是算出来了,只是既不拒也不写 warn 日志) |
warn | 检查并记录问题(部署报告的 security 字段 + WARN 日志),照常安装 / 上架 |
enforce | 有问题就拒绝 |
判定就一行(inspect 末尾):accepted = !enforce || problems.isEmpty()。
PackageSecurityInspector.isEnforce() 是给调用方判断"问题是否只是提示"用的,当前没有调用方。
代码里的注解兜底是 warn(@Value("${app-market.security.mode:warn}"),为存量未签名包的过渡期准备),
但 wujies-platform/wujies-admin/src/main/resources/application.yml 里配的是 enforce
(2026-09-13 起)。生效值以 yml 为准。
没配公钥时 enforce 会拒绝所有包(包括你自己签的)—— 因为服务端没法验证任何签名。
报错里会说明是"未配置 app-market.security.public-key"而不是"签名无效"。
公钥是启动期读入的,改了必须重建宿主并重启(用 mvn -pl wujies-admin -am -B install -DskipTests)。
打包脚本帮你记住"要签名"
宿主是 enforce,"忘了签名"等于白打一个包,所以 wujies/wujies-plugins/package.ps1 把
keygen、签名、自查、比对公钥串成了一个入口(在插件仓库根执行):
| 场景 | 命令 | 效果 |
|---|---|---|
| 日常打包 | .\package.ps1 -Plugin wujies-gallery -SigningKey keys/private.pem | 打 zip 之前签名,产出包根 signature.txt,再用同目录 public.pem 自查 |
| 第一次用同一个参数 | 同上 | 私钥文件不存在 → 自动 keygen 到该目录 → 接着签名(路径必须以 private.pem 结尾,否则直接报错) |
| 只想生成密钥 | .\package.ps1 -Keygen | 只生成、不构建不打包 |
| 换钥后同步配置 | 上面任一条加 -SyncPublicKey | 把新公钥写进宿主 application.yml 的 public-key: 块(并提醒必须重建宿主 + 重启) |
| 故意打未签名包 | -NoSign | 给 mode=warn/off 的部署用;与 -SigningKey 互斥 |
| 忘了传私钥 | 什么都不加 | 按 Find-SigningKey 的顺序自动找:$env:WUJIES_SIGNING_KEY → 插件仓库 keys/private.pem → 平台仓库的 plugins/keys/、keys/(历史位置) |
几条纪律:
- 私钥只留在发布方,放在插件仓库
keys/(已 gitignore);公钥不是秘密,填进app-market.security.public-key即可。私钥一旦进仓库,任何人都能签出被认可的包。 - 绝不覆盖已有私钥:
keygen在目录里已存在private.pem或public.pem时直接拒绝 (覆盖等于让所有已签发的包失效,必须是显式动作)。 - 换钥要同时做三件事:① 重新生成;② 把新
public.pem贴进public-key(-SyncPublicKey可代劳);③ 重建宿主并重启。旧钥签过的包届时全部作废, 得按下面的「就地重签」重签一遍。 -Keygen未显式给-SigningKey时,默认落点仍是历史路径<平台仓库>\plugins\keys\private.pem(脚本第 697 行),而平台仓库里已经没有plugins/了。所以要么显式传-Keygen -SigningKey keys/private.pem,要么生成后自己把keys/挪到插件仓库 —— 否则自动发现(Find-SigningKey先查插件仓库keys/)找不到它。
密钥生成、算法逐字说明与底层 PackageSignerMain 命令见 密钥与签名,
打包参数全表见 打包应用。
切换 enforce 之前先自检
GET /app/console/signature-readiness # 权限 app:console:list实现是 SignatureReadinessService.check():把已发布(app_version.status = '1' 且未删除)
的版本逐个下载验签,分类计数 + 阻止切换的原因 + 逐版本明细。响应字段:
| 字段 | 含义 |
|---|---|
mode | 当前模式 |
publicKeyConfigured | 是否配置了公钥(判据是 public-key 非空白) |
ready | true 才建议切 enforce(等于"没有任何 blocker") |
summary | 一句话结论,可直接给人看 |
checked / truncated | 实际检查了几个包 / 是否因为超过上限被截断 |
counts | 五个分类的计数 |
blockers | 阻止切换的原因 |
items | 逐版本明细:versionId / appCode / version / status / reason |
分类与处置(Item.status):
| 分类 | 含义 | 怎么办 |
|---|---|---|
signed | 签名有效 | 切了会被放行 |
unsigned | 包里没有 signature.txt | 就地重签,或重新打包上传成新版本 |
invalid | 有签名但校验不过 | 包被改过,或不是用同一私钥签的 |
unverifiable | 有签名,但服务端没配公钥 | 去配 public-key(不是包的问题) |
failed | 下载失败 / 没有包地址(package_url 为空) | 不影响切 enforce,但它表示"这个版本没被检查到" |
blockers 的组成只有四条:unsigned > 0、invalid > 0、unverifiable > 0、以及"未配公钥"。
failed 刻意不进去 —— 它既不算通过也不算阻止。
MAX_CHECK = 100:它会真实下载所有包,超出会明确报 truncated。
不要做成定时任务 —— 那只会每天白拉一遍对象存储。
MarketEditionGuardFilter 在 app-market.edition=client 下挡住 MarketEdition.PUBLISH_PREFIXES
(/app/console、/app/version、/app/package、/app/category),
例外清单 PUBLISH_ALLOWLIST 里只有 /app/console/owned-menus。
自检读的是本地目录,用户版下没有意义,所以它不在例外里。见
目录中心化与版本形态。
把历史版本「就地重签」
用途:把已发布但未签名的版本补齐。目标是版本号与状态都不动,只把包换成"原内容 + signature.txt"。
# 全程在**平台仓库根**执行(-cp 用相对路径);私钥按你的实际位置替换
$base = 'http://127.0.0.1:8080' # 宿主地址
$pkgUrl = '<该版本的 package_url,取自 app_version.package_url>'
# 1) 下载已发布的包,先核对 sha256 与 app_version.package_sha256 一致
curl.exe -s -o original.zip $pkgUrl
# 2) 解包 → 只加签名(内容一个字节都不动)→ 自查 → 重打包
Expand-Archive original.zip pkg
java -cp wujies-modules/wujies-app-market/target/classes `
cn.wujies.appmarket.security.PackageSignerMain sign pkg ..\wujies\wujies-plugins\keys\private.pem
java -cp wujies-modules/wujies-app-market/target/classes `
cn.wujies.appmarket.security.PackageSignerMain verify pkg ..\wujies\wujies-plugins\keys\public.pem
# ↑ 必须打印「签名有效」,否则不要往下走
Compress-Archive -Path "pkg\*" -DestinationPath signed.zip -CompressionLevel Optimal
# 3) 走上架端上传接口(服务端落对象存储并回填校验和),权限 app:console:add
curl.exe -X POST "$base/app/package/parse" -F "file=@signed.zip" -F "appId=<appId>"
# 4) 用返回的 packageUrl / packageSize / packageMd5 / packageSha256 更新**那一行** app_version注意事项:
| 项 | 说明 |
|---|---|
| 「内容一个字节都不动」的含义 | 只新增 signature.txt;不修改、不删除、不重命名任何已有文件,也不"顺手"格式化 JSON 或改行尾 |
| 核对方式 | 比对包内容与库里记录的 package_sha256,不要比对"清单里的版本号 == 版本行版本号" |
| 重打包工具 | Compress-Archive(PackageDigest 对 \ 与 / 都做了归一化);换 7-Zip / jar cf 时要自己确认分隔符 |
上传接口的 appId | 传了会校验"包内清单的 code 与该应用一致";历史包若清单 code 有笔误,这里会被拒 |
| 别用「发一个新版本」代替 | 那会让旧版本永远是未签名的,ready 也就永远上不去 |
两个都在 AppVersionController.edit:
manifestJson必填(应用清单不能为空),所以请求体必须把库里那份清单原文一起带上; 并且当该版本状态是已发布、而提交的manifestJson与库里不同时直接拒绝 (该版本已发布,不允许再修改应用清单)。就地重签只该改packageUrl/packageSize/packageMd5/packageSha256四个字段。version字段会被清单里的值覆盖(AppVersionController.edit里的version.setVersion(manifest.getVersion())),提交什么版本号都不算数。 历史包并不保证"清单版本 == 版本行版本"—— 例如某个已上架版本,版本行是0.1.1, 而包内app.json写的是0.1.0。对这类包,用 PUT 走完就地重签会 把版本行的版本号改写成清单里的那个。 要么接受这个改写(确认不与另一个版本行冲突),要么直接更新app_version的包字段列。
补签完成后重新调一次 GET /app/console/signature-readiness,确认 ready=true(unsigned=0 且 invalid=0)。
怎么核对这一页
| 想确认 | 去看 |
|---|---|
| 白名单规则、硬禁止扩展名、6 条上限 | PackageSecurityInspector 的 ALLOWED_TOP_LEVEL / HOME_LEVEL_FILES / FORBIDDEN_FRONTEND_EXT / checkArtifacts |
| 规范化格式与归一化细则 | PackageDigest 的类注释、normalize、canonicalText |
signature.txt 的四行格式 | PackageSignerMain.sign 里拼 StringBuilder 的那几行 |
| 当前模式与公钥 | wujies-admin/src/main/resources/application.yml 的 app-market.security 块 |
| 就绪度的分类与 blocker 规则 | SignatureReadinessService.check 与 checkOne |
| 打包脚本的签名参数 | wujies/wujies-plugins/package.ps1 的 -SigningKey / -Keygen / -SyncPublicKey / -NoSign 与 Find-SigningKey |