〖共创稿事节〗HarmonyOS 7 新特性实战(51):发布包可追溯——离线签名、双层验签与换包台账
# 〖共创稿事节〗HarmonyOS 7 新特性实战(51):发布包可追溯——离线签名、双层验签与换包台账
上架流程里最容易被忽略的一段,是从"构建成功"到"审核员手里的那个包"之间发生了什么。构建日志一片绿,不代表上传的那个文件就是你以为的那个文件;签名配置写着发布证书,也不代表包内每一个 HAP 都被签过。
这个工程有一条额外的约束:发布证书的私钥口令不允许以明文形式写进仓库里的任何文件。DevEco 的自动签名会把口令落在构建配置里,这条路直接断了。于是整条链路改成离线执行,每一步都留下可复算的输出。

一、一个发布包要回答三个问题
审核员不需要读构建脚本,但他手里的包必须能回答三件事:
| 问题 | 靠什么回答 | 失败时的表现 |
|---|---|---|
| 这个包是谁签的 | 叶证书指纹与证书链 | 叶证书是调试证书,装上就被判非法 |
| 包在传输中没被改过 | 摘要校验(SHA-256 / SHA-384) | Digest verify result: false |
| 内层 HAP 也被签过 | 解包后单独验签 | 只签外层,内层 HAP 无签名块 |
三件事对应三次不同的检查,任何一次缺失,"可追溯"就不成立。
二、材料先备齐,再谈流程
发布证书不是本地随便生成的,它必须和 AGC 上的 Profile 绑定。这套材料一共五件,缺一件后面都会卡住:
| 材料 | 内容 | 字节数 |
|---|---|---|
| 发布证书 | AGC 证书管理新增,有效期至 2029-10-31 | 2,943 |
| 发布 Profile | 绑定应用 ID 与上述证书,分发类型 app_gallery | 4,057 |
| 私钥库 | 本地生成的 EC P-384(secp384r1),别名 shanhaiRelease | 1,188 |
| 证书请求 | 生成证书时提交给 AGC 的 CSR | 572 |
| 口令密文 | 20 位随机口令,仅以当前 Windows 用户的 DPAPI 密文保存 | 529 |
口令这一件最关键:它只以密文存在,构建配置里没有它,代码仓库里也没有它。签名时在进程内解密,用完即弃。代价是换机器就解不开,私钥库、证书、Profile 与口令密文必须一起备份。
手动签名的材料准备与 ACL 配置,官方说明见 应用/元服务签名。这套流程和自动签名的差别,主要在后半段:证书、Profile、口令都由自己持有,因此每一步都要自己留下证据。
三、六步链路
常规签名配置不可用,就把签名从构建里摘出来,拆成六步:
| 步骤 | 动作 | 关键观察点 |
|---|---|---|
| 1 | 临时移除构建配置里的签名引用 | 只删引用,不写口令 |
| 2 | Release 模式产出未签名 HAP 与 APP | 日志出现 No signingConfig found for product default |
| 3 | 用发布证书签 HAP | 日志出现 Generate signing block success |
| 4 | 复刻 APP 结构(HAP + 清单 + 描述) | 目录结构与官方打包一致 |
| 5 | 用发布证书签 APP | 外层获得签名块 |
| 6 | 对 APP 与包内 HAP 分别验签 | 两次都要 Verify success |
第 2 步的日志是这条链路的地基:看到 No signingConfig found,才能确认产物真的是未签名的。如果这一步跳过,后面签出来的东西可能"叠签",验签能过但叶证书不一定是你以为的那张。
四、签名与验签的实际命令
先签 HAP。注意这里没有 -compatibleVersion,原因写在第六节:
java -jar hap-sign-tool.jar sign-app -mode localSign `
-keyAlias shanhaiRelease -signAlg SHA256withECDSA `
-appCertFile release.cer -profileFile release.p7b `
-inFile entry-default.hap -outFile entry-default-signed.hap `
-keystoreFile release.p12 -keyPwd $pw -keystorePwd $pw
再复刻 APP 结构。官方打包工具在超过百兆的 HAP 上会报压缩失败,换成系统自带的压缩命令,目录结构保持一致:
$stage = 'app-stage'
New-Item -ItemType Directory -Path $stage -Force | Out-Null
Copy-Item entry-default-signed.hap $stage
Copy-Item pack.info $stage
Copy-Item pac.json $stage
Compress-Archive -Path "$stage\*" -DestinationPath release-unsigned.app -Force
然后签 APP。sign-app 只处理输入包本身,不会递归处理内层 HAP,所以顺序不能反过来:
java -jar hap-sign-tool.jar sign-app -mode localSign `
-keyAlias shanhaiRelease -signAlg SHA256withECDSA `
-appCertFile release.cer -profileFile release.p7b `
-inFile release-unsigned.app -outFile app-harmony-release.app `
-keystoreFile release.p12 -keyPwd $pw -keystorePwd $pw
最后两层各验一次:
java -jar hap-sign-tool.jar verify-app -inFile app-harmony-release.app `
-outCertChain chain.cer -outProfile prof.p7b
keytool -printcert -file chain.cer
验签的输出不是一句"成功"就够的。APP 层的完整输出是:
Digest verify result: true, DigestAlgorithm: SHA-256
verify: Verify success
verify-app success
解包后的 HAP 单独验签,输出里必须出现签名块与权限签名两段:
Find Hap Signing Block success, version: 3, block count: 3
verify codesign success
verify permission sign success.
verify: Verify success
五、证书链与「签名隔离」
验签通过只说明签名结构正确,还要看签的到底是谁。证书链打印出来是三段:
| 层级 | 指纹(节选) |
|---|---|
| 根证书 | DF:21:A3:C0:9F:79:54:57:93:05:F8:5C:64:F8:0C:AD:... |
| 中间证书 | 0D:AA:8A:96:5B:51:53:12:4F:79:D4:B4:23:78:D0:AC:... |
| 叶证书(发布) | C2:1F:18:0F:AB:4C:79:06:75:24:47:4C:F0:29:CB:D0:... |
关键不是记住这串指纹,而是拿它和调试包对照。同一个工程用调试签名打出来的包,叶证书是 DD:3A:F9:55:...:44:7F,和上面这张完全不同。两张证书并存,才证明"签的确实是发布证书",而不是构建流程悄悄退回了调试签名。
六、五个坑,每个都沉默
这条链路里最危险的不是报错,而是不报错。下面五个坑里,前两个都是日志写着成功、产物却是错的:
| 现象 | 真正的原因 | 处置 |
|---|---|---|
HAP 验签报 signature not found,但签名日志写 Sign Hap success | 传了兼容版本参数,签名块被静默跳过 | 去掉该参数重签,日志出现 Generate signing block success |
打包工具报 --pack-info-path is empty | 参数名不是想象的那个 | 用 --pack-info-path 传清单 |
打包工具报 --out-path must end with .app | 输出参数名同样不是想象的那个 | 用 --out-path 传目标文件名 |
大 HAP 报 Compress failed,加内存参数仍失败 | 压缩路径对超大文件不稳定 | 换 Compress-Archive 复刻同结构 |
签名报 11014003 Init keystore failed | 口令经 stdin 管道传入,行尾换行进了密码 | 用 -keyPwd / -keystorePwd 直接传参 |
第一条最值得记。签名工具会打印 Sign Hap success,但只有验签才会告诉你签名块根本没写进去。签名成功和验签通过是两件事,不能互相替代。
第二条和第三条属于同一类:工具的参数名和文档示例经常不一致,报错信息里其实已经把正确名字写出来了,照着报错改比查文档快。
第五条是环境问题伪装成密码错误。口令本身没错,错的是传输方式——管道会在末尾补一个换行,而密钥库校验的是逐字节相等的口令。
七、换包台账:用数字记住每个包
版本对齐之后,同一份代码先后产出过三个包。只靠"最新那个"是记不住的,必须建台账:
| 阶段 | 包体字节数 | SHA256(节选) | 说明 |
|---|---|---|---|
| 首次发布签名 | 125,321,342 | 12F8AEC7...328FD4 | 版本仍为旧值,未上传 |
| 版本对齐 + 权限裁剪 | 125,321,260 | F0CB6F46...42DB | 上传后合法性「不通过」 |
| 崩溃修复后重签 | 125,321,048 | 91315EEB...82B1 | 上传后合法性「已达标」 |
三个包的体积差只有几百字节,肉眼完全分不出来,哈希是唯一可靠的区分方式。台账记录的字段不止哈希:还包括签名算法、叶证书指纹、Profile 里的版本号与分发类型、上传时间、AGC 的合法性结论。

bundle-name = com.shanhai.spirits
type = release
app-distribution-type = app_gallery
version-name = 2.0.0
version-code = 2
validity = 2026-10-31 ~ 2029-10-31
issuer = app_gallery
Profile 的版本号必须和包内版本号一致。这个工程第一版上传时就栽在这里:Profile 写 2.0.0 (2),包内还是 1.0.0 (1000000)。处置是把应用级配置的两个版本字段改成 2.0.0 与 2,重新走一遍六步链路,而不是只改 Profile。
八、权限裁剪:一次被动的减法
版本对齐后第一次上传,合法性检测 15 项里唯一失败的是「ACL 权限声明一致性」:Profile 里没有 FLOAT_VIEW 与 USE_FLOAT_BALL,而模块配置里声明了它们。
Profile 的新建表单没有 ACL 勾选,受限权限要走单独的 ACL 申请,需要企业资质与演示材料。这一版的选择是从模块配置里删掉这两条声明,让包与 Profile 对齐:
// 详情页的闪控入口改为三段探测:
// 1. 查询系统是否支持该能力
// 2. 查询应用是否持有权限
// 3. 两者任一不满足,走普通悬浮卡片的分支
const granted: boolean = await canIUse('SystemCapability.Window.FloatingBall');
if (!granted) {
this.floatingEnabled = false;
this.floatingHint = '当前设备不支持悬浮球,已切换为页内卡片';
}
代价是闪控球与闪控窗两个实验入口在这一版里只能走降级分支。这是产品层面的取舍,不是签名问题,但必须写进台账:同一个版本号下,权限集合变了,包也就变了。
九、崩溃修复:一个 API 26 属性值一次重建
第三个包的产生原因是一次上架自检崩溃。报告给的栈是 undefined is not callable,落在底部导航壳的构建函数里。根因是代码无条件调用了 Tabs.barFloatingStyle——这个接口从 API 26 才有,而工程的可安装版本是 6.0.0(20)。
原来的护栏只挡住了传参,没有挡住调用:
// 错误写法:属性传 undefined,方法本身在低版本不存在
Tabs({ barPosition: BarPosition.End }) {
ForEach(this.tabs, (item: TabItem) => {
TabContent() { /* ... */ }
})
}
.barFloatingStyle(this.useNativeFloatingBar() ? { /* 参数 */ } : undefined)
改成两条构建分支,低版本分支完全不下发这个属性:
if (this.useNativeFloatingBar()) {
Tabs({ barPosition: BarPosition.End }) {
ForEach(this.tabs, (item: TabItem) => {
TabContent() { /* ... */ }
})
}
.barFloatingStyle({ /* API 26 参数 */ })
.barHeight(0)
} else {
Tabs({ barPosition: BarPosition.End }) {
ForEach(this.tabs, (item: TabItem) => {
TabContent() { /* ... */ }
})
}
.barHeight(0)
}
差别只有一行 .barFloatingStyle,但它决定了低版本设备是"启动成功"还是"冷启动即崩"。改完按第四节的四道工序重建:未签名 HAP 139,524,467 字节 → 签后 139,654,209 字节 → 打包 125,306,309 字节 → 签 APP 125,321,048 字节,两层验签都过。
编译产物也要核对一遍:构建出来的中间代码里,barFloatingStyle 只出现在 if 分支内,else 分支没有这个调用。只改源码不看产物,没法确认修复真的进了包。
十、自检报告与提交
新包上传后,AGC 的合法性检测直接通过,上架自检在 5 台设备上跑了 117 个用例:
| 维度 | 结论 |
|---|---|
| 兼容性 | 核心功能通过率 100%,「启动」项通过 |
| 稳定性 | 未捕获崩溃 |
| 功耗 / 性能 | 良好 |
| UX 导航栏 | 不通过 ×9(折叠屏展开态) |
| UX 状态栏 | 不通过 ×1 |
| 警告 | 基础布局 ×1、滑动动效 ×1、转场动效 ×1 |
总评依然是「不通过」,但失败项已经从"启动崩溃"收敛成沉浸适配建议。这两项不影响本次内容与功能审核,留到 2.0.1 处理;报告里缺平板与 2in1 机型,也在台账里如实标成覆盖不全。
十一、收个尾
可追溯不是一句形容词,而是四份东西:证书链能证明谁签的,双层验签能证明包没被动过,哈希台账能证明换的是哪一个包,自检报告能证明这个包跑得起来。 少任何一份,"最新那个包"就只是一个文件名。
更多推荐
所有评论(0)