前言

使用 oh-flutter 构建 HarmonyOS 应用时,flutter build hap 执行完之后,产出的 HAP 包还是未签名状态——要装到真机上运行,你还得手动打开 DevEco Studio,走一遍"生成签名材料 → 上传证书 → 创建 Profile → 写入 build-profile.json5"的完整流程。对开发者来说,这一步既打断心流,又依赖 IDE 环境;对 CI 场景来说,更是完全走不通的死路。

本文记录我们为 flutter_tools 增加 OHOS 自动签名能力的完整历程:如何参考 deveco-cli 开源实现(openharmony-sig/deveco-cli)的签名逻辑,在 packages/flutter_tools 里实现了一个 11 个文件、79 个测试、零外部 Dart 依赖 的自包含签名模块(lib/src/ohos/signing/),让 flutter build hap 一条命令直接产出可安装的签名 HAP。下面按"为什么必须字节级兼容 → 模块怎么切 → 判定链怎么短路 → 踩坑与修复"的顺序展开。

提示:本文所有代码均基于 oh-flutter 的 oh-3.47-dev 分支。签名模块的落库提交是 f2651e0b91(feat(ohos): add automatic signing for flutter build hap,2026-10-21,+5473 / −0),随 3.47.7-ohos-1.0.1 发布;仓库 bin/internal/dart-sdk-url.ohos 的注释把这个批次明确记为"纯框架变更(flutter_tools 新增 OHOS 自动签名 lib/src/ohos/signing/,无引擎/Dart 变更)",11 个工件逐字节重挂载——也就是说:这次功能上线没有动任何引擎产物。当前对应发布线为 3.47.8-ohos-1.0.0。

一、背景与挑战

1.1 痛点:为什么非签名 HAP 装不上真机

HarmonyOS 对应用分发的安全要求是:安装到真机的 HAP 必须经过调试证书签名。DevEco Studio 的"自动签名"功能实际上做了四件事:

  1. 登录华为开发者账号(OAuth),确定团队(Team ID);
  2. 生成本地密钥库(.p12)与证书签名请求(.csr);
  3. 把 CSR 上传到 AGC(AppGallery Connect),申请并下载调试证书(.cer)与调试配置 Profile(.p7b);
  4. 把四样材料的加密信息写入项目的 build-profile.json5,hvigor 打包时再用它们完成真正的 HAP 签名。

原来 flutter_tools 的做法是在 assembleHap 之后调用 checkOhosSignedInfo 做构建后校验,发现没签名就打印一段引导语,让用户自己去 DevEco 操作。这在"偶尔构建一次"的场景下尚可接受,但每次构建都要中断流程去 IDE 里点一圈,体验非常割裂。

1.2 设计目标与约束

在动手之前,我们先明确了四条设计目标:

  • 字节级兼容 DevEco:材料文件的命名规则、build-profile.json5 中密码的加密算法,必须与 hvigor 的 DecipherUtil 完全一致——因为最终解开这些密码、执行 HAP 签名的是 hvigor 自己(通过 hap-sign-tool.jar),而不是 flutter_tools;
  • 高内聚低耦合:新逻辑全部收敛在一个独立目录 lib/src/ohos/signing/ 下,对外只暴露一个入口函数 ensureOhosSignedInfo,方便日后提交 PR 合入上游仓库;
  • 非侵入:如果用户已经通过 DevEco 配好了签名(build-profile.json5 已有 signingConfigs),自动签名静默跳过,不碰任何已有配置;
  • 失败可降级:自动签名任何一步失败,都回退到原有的 DevEco 引导语,绝不阻塞构建主流程。

关键认知:flutter_tools 只负责"生产材料 + 写配置",真正的 HAP 签名仍由 hvigor 完成。所以我们的兼容性目标不是"产出相同的 HAP",而是"产出让 hvigor 能认的材料"。

二、总体架构

2.1 模块划分:11 个文件各管一事

签名模块按职责拆成 11 个 Dart 文件,每个文件只负责一件事,互相之间只通过明确的接口通信:

文件职责依赖
aead.dart纯 Dart 实现 AES-128/256 + GCM + PBKDF2-HMAC-SHA256无(仅 dart:typed_data)
x509.dartASN.1 / PEM / PKCS#10 CSR 解析、指纹、SPKI 提取、有效期aead.dart
keymanager.dartDevEco DecipherUtil 兼容的密码加解密aead.dart
auth.dartOAuth loopback 登录、加密 token 存储、用户/团队 APIaead.dart, http
material.dart材料文件命名规则、工具链解析(java/hap-sign-tool)、P12/CSR 生成base, process
cloud.dartAGC 云 API 客户端(证书上传/下载/删除、Profile 申请)auth.dart, http
device.darthdc 本地设备发现 + 云端设备注册cloud.dart, process
profile.dart.p7b 二进制解析、build-profile.json5 写入、一致性校验x509.dart, json5
acl.dart从 app.json5 收集权限 + SDK 权限白名单校验—
regenerate.dartC0–C10 短路重生成判定链material, profile, x509
signing.dart编排入口 ensureOhosSignedInfo(六阶段流程)以上全部

拆分原则很简单:密码学原语(aead/x509)不依赖任何 Flutter 内部 API,可以独立测试、独立复用;云端交互(auth/cloud/device)只依赖 http;本地文件操作(material/profile)只依赖 base/process。这样上游 review 时可以按维度分批看。

2.2 主流程:六阶段编排

signing.dart 中的 ensureOhosSignedInfo 是整个模块的唯一入口,在 hvigor.dart 和 ohos_builder.dart 的 4 个 assemble 调用点之前被调用:

  1. 入口检查:build-profile.json5 已有 signingConfigs → 静默返回(兼容已有 DevEco 用户);
  2. 登录态检查:读本地加密 token → 调用 jwToken/check 校验 → 过期则走 OAuth loopback 重新登录;
  3. 重生成判定:跑 C0–C10 判定链,能复用现有材料就短路(跳过所有云端调用);
  4. 本地材料生成:调 hap-sign-tool.jar generate-keypair / generate-csr 产出 .p12 和 .csr;
  5. 云端材料交换:AGC 删除旧证书 → 上传 CSR → 下载签发的 .cer → 注册设备 → 下载 .p7b;
  6. 写配置:把四样材料路径 + 加密密码写入 build-profile.json5,交棒给 hvigor。

实际触发时只需要一条命令:

# 在 Flutter 工程的 example/ 目录下
flutter build hap --verbose
# 自动签名完成后 hvigor 继续 assembleHap,产物直接可安装

三、与 DevEco 的兼容:两个"字节级"难题

3.1 文件命名:一个隐藏的 SHA-256 哈希规则

DevEco 生成的材料文件不是随便命名的,而是遵循一套带哈希的规则:

{safeProduct}_{baseName}_{hash}{typeSegment}=.p12
例如:default_ohos_hZ_ZWrVriCSZPUHdd6qNU3b9HS6t1_4DYt8FtbvCGfQ=.p12

其中 hash 的计算方式是 sha256(项目绝对路径) → base64url 编码 → 只去掉填充符 =:

// material.dart:与 DevEco 完全一致的项目路径哈希
String getProjectRawHash(String projectPath) {
  final digest = sha256.convert(utf8.encode(projectPath)).digest();
  // 只剥离 base64url 的 padding '=',保留 '-' 和 '_'
  return base64Url.encode(digest).replaceAll('=', '');
}

这里有两个极易踩错的细节:

  1. 文件名里 .p12 扩展名前面那个 = 是字面量(模板的一部分),不是哈希的 base64 填充符——一开始我们以为那是填充符没去掉,结果生成的文件名和 DevEco 对不上,hvigor 找不到材料;
  2. base64url 表里的 - 和 _ 必须保留。早期版本用 replaceAll('=','').replaceAll('-','+').replaceAll('_','/') 做"反 URL-safe"转换,哈希里一旦包含 - 或 _,文件名就直接错了。

这条规则我们没有文档可查,最终是靠对照真实 DevEco 工程产出的文件名逆向验证的:拿一个 DevEco 已签名的工程,用上面两个规则算一遍,文件名逐字符一致才算通过。

经验:做工具链兼容时,"和参考实现逐字节对齐"的验证方式(golden file 式比对)比读文档可靠得多。

3.2 密码加密:纯 Dart 复刻 DecipherUtil

build-profile.json5 里的 storePassword / keyPassword 不是明文,而是 hvigor 的 DecipherUtil 用 AES-128-GCM 加密后的密文。hvigor 侧是 Node.js 代码:

// DevEco hvigor-ohos-plugin 的 decipher-util(Node 伪代码)
const key = crypto.pbkdf2Sync(
    utf8(fd0 ^ fd1 ^ fd2 ^ FIXED_COMPONENT), // 4 组 16 字节材料异或
    acSalt, 10000, 16, 'sha256');
const decipher = crypto.createDecipheriv('aes-128-gcm', key, iv);

其中 fd0/fd1/fd2/ac/ce 是写在 ~/.ohos/config/material/ 下的 5 个小文件,FIXED_COMPONENT 是一个写死的 16 字节常量。由于 Dart 生态没有现成的 GCM 实现可用(oh-flutter 自定义 SDK 也不允许随意加依赖),我们在 aead.dart 里从零实现了 AES + GCM + PBKDF2,并用 NIST 官方测试向量逐条验证。

keymanager.dart 则负责把整个"材料文件 → 密钥 → 解密"流程封装成与 DecipherUtil 对称的加解密接口。验证方式是往返测试:flutter_tools 加密 → 用 DevEco 同款算法解密 → 再用 keytool 打开 .p12,三步全通才说明字节级兼容。

四、密码学原语:纯 Dart 实现 AES-GCM 与 PBKDF2

4.1 AES 核心:照着 NIST 规范逐条实现

aead.dart 的 AES 部分严格按 NIST SP 800-38D 实现,包括密钥扩展(KeyExpansion)、加解密轮函数和 GF(2^8) 乘法。开发中修复了三个典型 bug:

  • 密钥扩展列表越界:w[i] = w[i-1] ^ w[i-Ns] 的边界条件写错,128 位密钥能过、256 位密钥直接 RangeError;
  • final 字段构造期赋值:Dart 的 final 列表字段不能在构造函数里赋值,改成在构造函数体内初始化;
  • GF 乘法实现:多项式乘法的按位异或循环少移了一位,只有特定字节模式下才暴露,纯单元测试(NIST 向量)能抓住、靠"看起来对"抓不住。

4.2 GCM:CTR 的 off-by-one 差点全废

GCM 模式最容易写错的地方是计数器初值:

// GCM 的 CTR 计数规则(NIST SP 800-38D 附录 C)
// J0 = IV(96bit) || 0x00000001
// 第 1 块密文 = E_K(J0 + 1)   ← 注意:用 J0+1,不是 J0
// 认证 Tag  = E_K(J0) XOR GHASH(...)  ← Tag 才用 J0 本身

一开始我们把首块密文也算成 E_K(J0),结果所有 GCM 向量全挂。修正后 AesGcm.seal/open 对 NIST 的全部 GCM 测试向量通过。seal 的签名也刻意做得与 Web Crypto / Node 习惯一致:

// 返回 密文 || tag(16B)
List<int> seal(List<int> plaintext, List<int> nonce, [List<int>? aad]);
// 校验 tag 失败返回 null(恒时比较,防时序攻击)
List<int>? open(List<int> ciphertextWithTag, List<int> nonce, [List<int>? aad]);

4.3 PBKDF2-HMAC-SHA256

DecipherUtil 的密钥派生参数是 iterations=10000, dkLen=16, prf=HMAC-SHA256,标准 RFC 5869 实现。10000 轮在 Dart 里单次约 50–100ms,可接受——它只在每次构建时执行一两次,不在热路径上。

// aead.dart:与 Node pbkdf2Sync('sha256') 完全一致
List<int> pbkdf2HmacSha256(List<int> password, List<int> salt,
    int iterations, int dkLen) {
  final prf = Hmac(sha256, password);
  final blocks = (dkLen / 32).ceil();
  final dk = <int>[];
  for (var i = 1; i <= blocks; i++) {
    var u = prf.convert([...salt, i, 0, 0, 0]).digest();
    final t = List<int>.from(u);
    for (var j = 1; j < iterations; j++) {
      u = prf.convert(u).digest();
      for (var k = 0; k < 32; k++) {
        t[k] ^= u[k];
      }
    }
    dk.addAll(t.sublist(0, dkLen - dk.length));
  }
  return dk;
}

五、解析二进制文件:.p7b 与 X.509

5.1 .p7b 是二进制 PKCS#7 容器,不是文本

.p7b(Provision Profile)看起来像配置文件,实际是二进制 PKCS#7 SignedData(DER 编码)。真正的配置信息是一段 JSON,被嵌在 DER 树的某个 OCTET STRING 里。第一版代码用 readAsStringSync 直接读文本,二进制字节流一上来就抛异常。正确做法是读字节、遍历 DER 树定位内嵌 JSON:

// profile.dart:从 PKCS#7 DER 树中提取内嵌 JSON
Map<String, dynamic> _extractJsonFromPkcs7(List<int> der) {
  var offset = 0;
  // 自外向内遍历,找到包含可解析 JSON 的 OCTET STRING (tag 0x04)
  for (var i = 0; i < 64; i++) {
    final (tag, len, body) = _readTlv(der, offset);
    if (tag == 0x04) {
      final json = _tryParseJson(der.sublist(offset + 3, offset + 3 + len));
      if (json != null) {
        return json;
      }
    }
    offset += _tlvSize(der, offset);
    if (offset >= der.length) {
      break;
    }
  }
  throw const FormatException('.p7b: no embedded JSON found');
}

解析出来的 JSON 包含 validity(有效期)、bundle-info(包名)、development-certificate(开发证书指纹)、device-ids(绑定设备)和 acl(权限白名单)——后文的设备注册和一致性校验都依赖这些字段。

5.2 一个 RangeError 暴露的 ASN.1 长长度溢出 bug

写 ASN.1 解析器时遇到一个诡异的崩溃:RangeError (index): Invalid value: Not in inclusive range,且只在特定文件上复现。排查后发现是 DER 长形式长度字段的 64 位整数溢出:

// x509.dart:长形式长度(firstLengthByte & 0x80 != 0)的安全读取
int numBytes = firstLengthByte & 0x7f;
if (numBytes == 0 || numBytes > 4) {
  throw const FormatException('DER: invalid long-form length');
}
// 最多 4 字节:5 字节以上在 64 位 int 里移位可能变负数,
// 负数会"通过"所有下界检查,然后在 sublist 处抛 RangeError

DER 标准里长度字段理论上可以任意长,但在证书场景下 4 字节(约 4GB)绰绰有余。超过 4 字节的"长度"基本可以断定是解析器跑偏到了非结构化的字节流(比如把证书内容当 TLV 读了)。如果不加这个上限,5 个字节的长度值移位后可能变成负数,所有 >= 0 的边界检查全部失效,最后以 RangeError 的形式在 sublist 处爆掉——离真正的出错点好几层代码,极难排查。

教训:解析不受信二进制数据时,对"结构字段"设合理的硬上限(而不是只信任标准理论值),能避免一整类"负数绕过边界检查"的诡异崩溃。

5.3 X.509 解析:指纹、有效期与 SPKI 提取

x509.dart 同时实现了 PEM 证书和 PKCS#10 CSR 的解析,提供三个能力:

  • 指纹计算:SHA-256 指纹(与 AGC 上传/比对逻辑一致);
  • 有效期判断:ASN.1 的 UTCTime / GeneralizedTime 两种格式都要处理(2050 年之后证书会用后者);
  • SPKI 提取:从 TBS Certificate 的 SubjectPublicKeyInfo 里取出公钥 DER 字节——这是第八节一致性校验的核心。

另一个小坑:X.509 v3 证书的 version 字段是 [0] EXPLICIT 上下文标记,构造式标签字节是 0xA0 而不是 0x00。isContextTag 的判断写成只查低 5 位标签号,会把 0xA0 误判成普通字段,导致 v3 证书全部解析失败。

六、云端流程:OAuth 登录与 AGC API

6.1 OAuth Loopback:本地 HTTP 服务器接回调

AGC 的登录走华为开发者账号的 OAuth 流程。CLI 场景没有 Web 回调地址,标准做法是 loopback 模式:

// auth.dart:loopback 登录核心步骤
// 1. 本地起一个 127.0.0.1 的 HTTP 服务器(随机端口)
// 2. 用系统浏览器打开 OAuth 授权页(redirect_uri = http://127.0.0.1:port)
// 3. 用户在浏览器里完成登录,华为重定向回本地服务器
// 4. 从回调参数拿到 tempToken
// 5. 调 token 接口把 tempToken 换成 JWT,加密存到 ~/.ohos/flutter-signing/token.enc

token 的存储同样用了 aead.dart 的 AES-GCM 加密(密钥来自设备派生材料),避免明文 JWT 落在 ~/.ohos 下。每次构建先调 authrouter/auth/api/jwToken/check(header 带 jwtToken)校验有效期,只有过期才重新拉起浏览器——正常情况下一周内都不会再看到登录窗口。

6.2 AGC API:两个 DELETE 的传参方式完全不同

AGC 的 REST API 有几个反直觉的地方,整理成对照表:

操作方法参数传递说明
删除远程证书DELETEJSON body:{"certIds": [id]}放 query string 会 400
删除调试 ProfileDELETEquery string:?provisionIds[]=<id>放 body 会 400
上传 CSRPOSTmultipart + JSON 混合返回新证书 ID
下载证书/ProfilePOSTJSON body返回 base64 内容

认证头统一是三个:uid、teamId、oauth2Token。其中 teamId 要登录后才能从用户 API 查到。这些细节全部来自对 deveco-cli 开源实现的逐行对照——它的 cert-api.ts 和 generate-profile.ts 是本文所有端点、字段名和传参方式的权威参考。

提示:AGC 的 API 错误信息非常含糊(很多直接返回 400 不带详情),调试时建议先用 deveco-cli 的同一账号复现一遍,确认是参数问题而不是权限问题。

七、智能复用:C0–C10 短路判定链

7.1 为什么要一条判定链

自动签名最贵的不是计算,而是云端往返:删证书、传 CSR、下证书、注册设备、下 Profile,一次完整流程要 5–8 个 HTTP 请求。如果每次构建都重新走一遍,不仅慢,还会把 AGC 的调试证书额度白白消耗掉(每次删除重建都会产生新证书 ID)。所以 regenerate.dart 设计了一条 C0–C10 短路判定链——按成本从低到高依次检查,任何一条判定"需要重生成"就立刻短路:

// regenerate.dart:判定链(按检查成本升序排列)
// C0  材料文件缺失(.p12/.csr/.cer/.p7b 任一不存在)      → 需要
// C1  材料已过期(.cer 或 .p7b 有效期不足 7 天)           → 需要
// C2  项目路径哈希变了(工程被移动/重命名)                → 需要
// C3  bundleName 与 .p7b 内 bundle-info 不一致            → 需要
// C4  本地证书指纹与 .p7b 内 development-certificate 不符  → 需要
// C5  build-profile.json5 中的 storePassword 解密失败      → 需要
// C6  设备未绑定(当前 hdc 设备 UDID 不在 .p7b device-ids) → 需要
// C7  权限变化(app.json5 权限超出 .p7b acl 白名单)       → 需要
// C8  keytool 验证 .p12 打不开(密码/别名不匹配)          → 需要
// C9  AGC 侧证书已不存在(云端 404)                       → 需要
// C10 兜底:以上都通过但无法确认 → 保守重生成

7.2 复用路径的实测

这条链的价值在第二次构建时体现得最明显:

$ flutter build hap --verbose
[        ] Auto-signing: reusing existing material (C0-C10 all passed).
[ +917 ms] start hap build...

第二次构建零云端调用,直接跳过整个签名阶段进入 hvigor 打包。这是"自动签名"能日常可用的关键——它必须安静到用户感觉不到它的存在。

八、一致性校验:一次失败暴露的设计错误

8.1 keytool 指纹比对的陷阱

最初版本的材料一致性校验逻辑是:用 keytool 取出 .p12 里证书的指纹,和 .cer(AGC 签发的证书链)的指纹比对。端到端测试时这一步必然失败——比对结果永远是 false。

排查后发现这是个设计错误而不是实现 bug:.p12 里存的是我们自签名的证书(generate-keypair 当场生成的,subject 和 issuer 都是自己),而 .cer 是 AGC 用自己的 CA 重新签发的证书。两者的指纹永远不会相同——issuer、有效期、甚至签名算法都不同。拿两个不同签发者签的证书比指纹,等于拿两张不同银行发的身份证比照片尺寸。

8.2 修复:用 SPKI 公钥比对

正确的不变量是公钥:CSR 里请求的公钥,就是 AGC 签发证书里嵌入的公钥,也是 .p12 密钥对对应的公钥。三者通过 SubjectPublicKeyInfo(SPKI)的 DER 字节精确相等:

// profile.dart:基于 SPKI 的一致性校验(替代指纹比对)
bool checkCertificateInProfile({
  required List<int> p12CertSpki,   // .p12 自签名证书的 SPKI
  required List<int> cerLeafSpki,   // AGC 签发证书链叶子证书的 SPKI
  required List<int> csrSpki,       // CSR 的 SPKI
}) {
  // 三个 SPKI 必须两两相等 —— 相等即证明
  // "P12 里的私钥" 和 "AGC 签发的证书" 是同一对密钥
  const eq = listEquals;
  return eq(p12CertSpki, cerLeafSpki) && eq(cerLeafSpki, csrSpki);
}

这个修复还带来一个额外好处:不再依赖 keytool 子进程。SPKI 比对完全在纯 Dart 的 x509.dart 里完成,测试里可以直接用 openssl 生成的测试证书对做断言,不需要在 CI 里装 Java。

经验:校验"两个制品是否来自同一来源"时,先问一句——什么字段在两次签发中保持不变? 指纹、有效期、issuer 全都会变,只有密钥材料(公钥/私钥)是不变量。找错不变量,测试就会"永远失败",而且失败得很有迷惑性。

九、端到端验证

9.1 从零到签名 HAP 的完整运行

下面是真实账号下第一次构建的完整输出(密码已在日志层面脱敏):

在这里插入图片描述

图 1:一条命令触发自动签名——生成 P12/CSR、向 AGC 申请证书与 Profile、写入 build-profile.json5,随后交棒 hvigor 打包

注意输出里的几个关键节点:

  • Auto-signing: regeneration required —— C0–C10 判定链的结论(首次构建,材料缺失);
  • generate-keypair success / generate-csr success —— 本地材料生成(走 DevEco JBR 的 java);
  • Downloaded ...=.cer (2987 bytes) / .p7b (4343 bytes) —— AGC 云端材料到位;
  • Wrote auto-signing config "default" into .../build-profile.json5 —— 加密配置落盘。

9.2 产物交叉验证

签名 HAP 产出后,我们用 hap-sign-tool.jar verify-app 做了独立交叉验证(不依赖 hvigor 的打包链路):

# 独立验证签名 HAP 的完整命令
java -jar $SDK/default/openharmony/toolchains/lib/hap-sign-tool.jar verify-app \
  -inFile /tmp/signed_test.hap \
  -outCertChain /tmp/out_chain.cer \
  -outProfile /tmp/out_profile.p7b
# 输出 Digest verify result: true / verify permission sign success / verify-app success

交叉验证还确认了一个细节:签名 HAP 内嵌的证书链顺序是 root → leaf → intermediate(不是直觉上的 root → intermediate → leaf),这是 hap-sign-tool 的行为,解析工具按"链里包含 AGC 叶子证书指纹"判断而不是按位置判断。

9.3 验证矩阵

整体验收覆盖了从单元到真机全链路,结果如下:

验证项方式结果
密码学原语NIST SP 800-38D / RFC 5869 官方测试向量全部通过
静态分析dart analyze(模块 11 + 测试 9 文件)0 issue
单元测试dart test test/general.shard/ohos/signing/(79 个)79/79 通过
文件命名兼容与真实 DevEco 工程产物逐字符比对一致
密码往返flutter_tools 加密 → DevEco 算法解密 → keytool 打开 .p12通过
AGC 全链路真实账号:删旧证 → 传 CSR → 下载证书/Profile通过
SPKI 一致性证书 ↔ CSR ↔ P12 公钥三方比对通过
真实签名/验签hap-sign-tool sign-app + verify-appPASS
复用短路第二次构建日志确认零云端调用通过

十、踩坑与经验

10.1 环境与工具链的坑

开发过程中环境类问题比算法类问题花的时间还多,整理一份清单:

  1. macOS 的 /usr/bin/java 是个 stub:canRun 检查能通过,但真正执行时没有 JVM。Java 解析顺序必须写成 JAVA_HOME → DevEco JBR(/Applications/DevEco-Studio.app/Contents/jbr)→ PATH;
  2. DEVECO_SDK_HOME 必须指向 SDK 目录(.../Contents/sdk):hvigor 从这里找 hap-sign-tool.jar 和 hdc,指错一层目录就会报"工具链不存在";
  3. oh-flutter 自定义 Dart SDK 的 API 差异:bin/cache/dart-sdk-ohos 把 String.allMatches 的参数类型从 Pattern 改成了 String,签名模块里所有正则匹配都改用 RegExp.allMatches(String) 或纯循环,保证双 SDK 兼容;
  4. hvigor/node 路径:DevEco 自带的 node v24 和 hvigorw 都在 /Applications/DevEco-Studio.app/Contents/tools/ 下,不在系统 PATH 里。

10.2 一个已知的、与签名无关的既有问题

端到端构建的最后一关(AOT 编译阶段)会遇到 Invalid SDK hash——aot_ohos_asset_bundle 的 IconTreeShaker 用 host 的 dart-sdk 去校验 dart-sdk-ohos 的构建产物,build ID 不匹配。这个问题在加入自动签名之前就存在,属于 oh-flutter 工具链自身的 SDK 管理问题,与本文的签名模块无关,后续会在工具链侧单独修复。

边界声明:自动签名模块解决的是"材料生产 + 配置写入",签名本身由 hvigor 完成。如果构建在 AOT 阶段失败,请优先排查工具链 SDK 问题,而不是签名配置。

十一、常见问题 FAQ

11.1 flutter build hap 成功,但安装时仍提示未签名?

先用 --verbose 查看是否有 Auto-signing 日志:自动签名的任何一步失败都会静默降级为 DevEco 引导语,不会阻塞构建。确认项目根目录的 build-profile.json5 已生成 signingConfigs,并检查 hvigor 读取的是同一份配置;已有 DevEco 签名配置时模块会主动跳过。

11.2 第二次构建会重新申请证书吗?

不会。C0–C10 判定链全部通过时会短路复用现有材料,日志出现 reusing existing material,整个签名阶段零云端调用。只有材料缺失/过期、工程路径变化、设备未绑定、权限超出 Profile 白名单等情况才会触发重生成。

11.3 删除 AGC 证书或 Profile 返回 400?

两种 DELETE 的传参位置不同:删除证书要把 {"certIds":[...]} 放在 JSON body,删除 Profile 要把 provisionIds[] 放在 query string;放错位置会直接 400。可先用 deveco-cli 的同账号复现,区分参数问题与权限问题。

11.4 .p12 打不开,或 build-profile.json5 里的密码解密失败?

先确认材料文件名中的项目路径哈希与当前工程绝对路径一致,再确认 storePassword / keyPassword 使用与 hvigor DecipherUtil 相同的 AES-128-GCM 流程加密。可用 keytool 打开 .p12 做独立验证;密码错误时优先检查材料文件是否来自另一台机器或旧工程。

11.5 macOS 上提示 java 不可用,或找不到 hap-sign-tool.jar?

macOS 的 /usr/bin/java 只是 stub,不能作为 JVM 使用。Java 解析顺序应为 JAVA_HOME → DevEco JBR(/Applications/DevEco-Studio.app/Contents/jbr)→ PATH;同时确认 DEVECO_SDK_HOME 指向 .../Contents/sdk,hvigor 才能找到 hap-sign-tool.jar 与 hdc。

11.6 构建在 AOT 阶段报 Invalid SDK hash,是签名失败吗?

不是。该问题在自动签名模块加入之前就存在,原因是 host 的 dart-sdk 与 dart-sdk-ohos 的 build ID 不匹配,属于工具链 SDK 管理问题;签名材料与 HAP 验签可以通过,优先按 10.2 节排查工具链。

十二、系列导航

本文是「鸿蒙Flutter框架 0day适配实战」系列的第 4 篇,也是消费侧的收官篇(工具链专题)。全系列 10 篇,分消费侧(4 篇)与生产侧(6 篇);与本文直接相关的是下面几篇:

篇目主题与本文的关系
第 1 篇背景、分叉仓库、版本命名与 fvm fork 安装装到含自动签名能力的 oh-3.47-dev 版本线
第 2 篇手动 bootstrap 高版本 SDK 与版本核验tag 版本安装,本文代码所在 SDK 的获取方式
第 3 篇鸿蒙模拟器/真机跑 Flutter 项目第三章「签名:没有真机证书时怎么出包」——本文把它自动化了
第 5 篇上游同步总览与 Breaking Changes工具链改造在同步批次中的位置与验证门口径
第 6 篇同步 Phase 1+2:基线建立与 Framework 适配flutter_tools 侧 API 漂移修复,与本文同一模块

全系列 10 篇总览(消费侧 4 篇 + 生产侧 6 篇):

#篇目侧一句话主题
01背景、分叉仓库、版本命名与 fvm fork 安装消费三层架构、三个接缝、七层工作量、fvm 直装 tag、版本墙
02tag 版本的手动安装与三层核验消费浅克隆 bootstrap、首次初始化、SSH 修复、异常分诊
03鸿蒙模拟器/真机跑 Flutter + 消费侧踩坑集消费工具链三件套、HAP 构建/安装/运行、占位签名、截图取证、CP1-CP14
04flutter build hap 自动签名(本篇)消费P12/CSR、AGC 证书与 Profile、纯 Dart AES-GCM、C0-C10 复用
05上游同步总览、BC 全景与端到端 SOP生产批次解剖、五阶段验证门、12 项适配表、端到端 SOP 与 P1-P29 索引
06Phase 1+2:基线建立与 Framework 适配生产代理 HTTP/1.1 修障、421 文件重打、Dart SDK 源码构建
07Phase 3 Engine:三方重放与冲突裁决生产826 文件、15 真冲突两分法、契约对齐 16 项
08引擎构建链攻防生产依赖树、工具链版本、musl 交叉链接、测试面漂移
09构建发布与产物分发生产版本锁、11 个工件(Dart SDK 双宿主)、11 条覆盖键、换新 tag 铁律
10验证与真机验收生产四道质量闸、设备侧单测、三档引擎、字体静默故障

阅读顺序建议:第 3 篇 → 本篇。第 3 篇解决「没有证书怎么出包」,本篇解决「一条命令出可安装的签名包」,两篇合起来才是完整的真机调试链路。

总结

回顾整个改造,最有价值的三个认知是:第一,工具链兼容的本质是找对"不变量"——文件名哈希、DecipherUtil 密钥派生、SPKI 公钥,每一个都是逆向后逐字节验证的;第二,纯 Dart 实现密码学原语完全可行,前提是拿 NIST/RFC 官方测试向量当验收标准;第三,自动化的终点是"安静"——C0–C10 判定链让第二次构建完全无感,这才是日常可用的状态。

目前模块(lib/src/ohos/signing/ 11 个文件 + test/general.shard/ohos/signing/ 9 个测试文件,单笔提交 +5473 / −0 行、79 个单测)已随 3.47.7-ohos-1.0.1 进入 oh-flutter/flutter_flutter,下一步是整理 design doc 后向 flutter/flutter 上游提交 PR,并跟进 AOT SDK hash 工具链问题的修复。

下一篇(第 5 篇)切换视角进入生产侧:3.44.9→3.47.4 上游同步批次总览——2263 个文件 / +192740 行的适配构成、五阶段与五道验证门、3.47 Breaking Changes 适配表,以及可复用的端到端同步 SOP。

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源:

环境与版本

本文的示例与截图基于以下环境组合(2026-10 实测):

组件版本说明
上游 Flutter SDK3.47.8github.com/flutter/flutter
Flutter SDK(OH 适配版)3.47.8-ohos-1.0.0oh-flutter/flutter_flutter,oh-3.47-dev 分支
DevEco Studio26.0.0 Release提供 hvigor / ohpm / hdc / JBR 等工具链
OpenHarmony SDK / APIAPI 26(26.0.0)模拟器与 SDK 均为 API 26
运行设备OpenHarmony 模拟器API 26,1280×2832
签名执行方hvigor + hap-sign-tool.jarflutter_tools 只负责生产材料与写配置

环境搭建(分叉获取、DevEco Studio 与 SDK 配置、模拟器创建)请按官方指导操作:OpenHarmony-flutter 环境搭建指导。

本文改造的是 packages/flutter_tools 内的工具链代码,不是 pub.dev 三方库,因此没有 pubspec.yaml 依赖声明;获取代码请直接使用上面的 oh-flutter 仓库与 oh-3.47-dev 分支。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐