Wujies 无界平台
机制

包安全与签名

本文是 1.x 的归档文档(冻结于 1.x)。该分支只接收勘误修正, 内容基本不再变动;文中的源码路径、配置项与版本号均以 1.x 为准,不建议用于新项目。

新项目请查看 5.x 最新文档。已在 1.x 上运行的项目,本文仍是可靠依据。

这一页解决什么问题

应用包(zip)的两个部分都是完全可信代码:backend/*.jar 是任意 Java 代码,跑在宿主 JVM 里; frontend/** 是跑在宿主同源上下文里的 JS,能拿用户的 token 调任何接口。 所以"能不能装"这件事要由平台回答三个问题,这一页就是这三个问题的答案:

问题管这件事的机制落点
包内允许出现哪些文件?产物白名单PackageSecurityInspector.checkArtifacts
这个包是不是我签发的、上传之后有没有被改过?分离式签名(包根 signature.txt)PackageDigest + PackageSignerMain
检查到什么程度、什么时候拒?三档模式 off / warn / enforceapp-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,.wasm
报告只列前 6 条问题

MAX_REPORTED = 6:问题多于 6 条时报告只保留前 6 条,避免把任务日志刷满(checkArtifacts 末尾)。 包内有重名条目时直接计为问题并跳过白名单检查(zipReadable = false)—— 解包时后者覆盖前者, 而签名只算得出一份。

分离式签名(signature.txt)

签名是分离式的:包根一个 signature.txt,覆盖除它自己以外的全部文件 —— app.json、backend/*.jar、frontend/**、migrations/*.sql(会真实执行的 SQL)都在内。

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

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() 是给调用方判断"问题是否只是提示"用的,当前没有调用方。

注解兜底值 ≠ yml 实际值

代码里的注解兜底是 warn(@Value("${app-market.security.mode:warn}"),为存量未签名包的过渡期准备), 但 wujies-platform/wujies-admin/src/main/resources/application.yml 里配的是 enforce (2026-09-13 起)。生效值以 yml 为准。

切 enforce 的前提是 public-key 已配置

没配公钥时 enforce 会拒绝所有包(包括你自己签的)—— 因为服务端没法验证任何签名。 报错里会说明是"未配置 app-market.security.public-key"而不是"签名无效"。 公钥是启动期读入的,改了必须重建宿主并重启(用 mvn -pl wujies-admin -am -B install -DskipTests)。

容器化部署会覆盖 yml

1Panel 模板把 APP_MARKET_SECURITY_MODE 接到安装表单的 SECURITY_MODE 变量, 表单默认值是 warn,会盖掉镜像里 yml 的 enforce (wujies-platform/deploy/1panel/apps/wujies-platform-server/5.6.3/data.yml 与 docker-compose.yml)。 配置项全表见 配置项索引,部署侧说明见 部署配置。

打包脚本帮你记住"要签名"

宿主是 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 非空白)
readytrue 才建议切 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。 不要做成定时任务 —— 那只会每天白拉一遍对象存储。

用户版下这个接口是 403

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 也就永远上不去
第 4 步走 PUT /app/version 时有两个真实陷阱

两个都在 AppVersionController.edit:

  1. manifestJson 必填(应用清单不能为空),所以请求体必须把库里那份清单原文一起带上; 并且当该版本状态是已发布、而提交的 manifestJson 与库里不同时直接拒绝 (该版本已发布,不允许再修改应用清单)。就地重签只该改 packageUrl / packageSize / packageMd5 / packageSha256 四个字段。
  2. 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

相关阅读

目录