示例仓库:https://atomgit.com/oh-flutter/flutter_did_ohos

社区引导:框架源码与版本发布见 https://atomgit.com/CPF-Flutter/flutter_flutter/tree/oh-3.41.9-release

CPF-Flutter 鸿蒙社区:https://atomgit.com/CPF-Flutter


0. 环境与实测设备

项版本/型号
DevEco Studio26.0.0.821(HarmonyOS SDK 26.0.0 Release / API 26)
Flutter OHOS 版3.41.9
鸿蒙SDK 7.0.0(API 26,SDK 版本 26.0.0.105 Release)
flutter_knock_share插件 1.0.0(本仓库,path 依赖)
实测设备华为(nova 14)

1. 为什么会想到给 App 做一套"自己的身份"

先说清楚问题是怎么撞上来的。

去年做过一个企业内部的巡检 App,逻辑很朴素:工人到点打卡、拍照、上传。第一版的身份方案是"登录拿 token"——服务端发一个 JWT,客户端存起来,每次请求带上。上线两周就出事了:厂区地下二层没信号,工人拍了照传不上去,第二天回到地面,token 过期了,App 让他重新登录,可他那台手机的号段被换过,收不到验证码。三天的巡检记录就这么卡在本地。

这不是"网络不好"的问题,是身份被绑在了错误的东西上:绑在服务端会话、绑在手机号、绑在一台具体设备的登录态上。任何一个环节变了,身份就断了。

后来我们把需求重新写了一遍,其实只有三句话:

  1. 身份得跟着人走,不跟着登录态走;
  2. 离线也要能证明"这条记录是我签的";
  3. 别人拿到我的凭证,不问我、不连我的服务器,也能验出真假。

用公钥密码学的说法,这三句话就是"自主身份":自己生成一对密钥,公钥公开、私钥自留;用私钥签东西,别人用公钥验。W3C 把这套东西标准化成了两个规范——DID Core(去中心化标识符)和 Verifiable Credentials(可验证凭证)。落到鸿蒙上,正好有三个系统能力能接住它:

  • HUKS(通用密钥库系统)负责生成和保存私钥,私钥不出安全环境,只把公钥吐出来;
  • cryptoFramework 负责哈希、随机数这些无状态运算;
  • distributedKVStore(分布式键值数据库)负责把 DID 文档同步到同账号的其它设备。

所以这个库要解决的,就是把这几个能力接起来:HUKS 出密钥,cryptoFramework 出指纹,DID 文档进分布式 KV,VC 的签名验证落到 Dart 侧一行调用。

顺带说一句为什么做成 Flutter 插件,而不是纯 Dart 包:DID 的核心是"私钥不能离开安全区",而鸿蒙的安全区只在 ArkTS 侧暴露(HUKS 的 @ohos.security.huks)。纯 Dart 方案能实现 DID 的格式和验签,但私钥只能躺在应用沙箱里,加密与否全靠自己——那就把这个库最有价值的部分丢了。做成插件,Dart 侧只留一层薄薄的 API,ArkTS 侧干重活,业务代码完全不用碰原生。

那市面上已经有 DID 的 SDK 了,为什么不直接用?我试过两个,都卡在同一处:它们的密钥后端是"软件密钥库 + 文件加密",在鸿蒙上等于自己实现一遍 HUKS 的等价物;而且它们的文档解析依赖公网 resolver,离线场景直接失效。

这个库不做什么,也先说清楚:它不是钱包,不管助记词和密钥备份;它不是 resolver 服务,只解析本机分布式 KV 里的文档;它不实现撤销列表(StatusList2021),凭证的吊销要靠过期时间或业务侧自己的名单。它是一个"能跑通的最小闭环"。

2. 先看它跑起来的样子

示例应用有三个页签:我的身份、可验证凭证、原始签名。

身份页:已经是第二次启动,之前创建的身份还在

这张图是第二次启动时截的:上一轮创建的 did:ohos:86AfREY7HeGPUcQiyqt62eRkr9xonSQ6czdE8yB7eJDo 还在列表里,创建时间、设备名都在。DID 本身是 44 个 Base58 字符,来自公钥的 SHA-256 指纹,不是随机数。

点一下身份卡片,弹出 DID 文档:

DID 文档:@context、id、verificationMethod、authentication 都是 W3C 那一套

这里能直接看到整个信任链的起点:verificationMethod 里只有一个 #keys-1,类型是 EcdsaSecp256r1VerificationKey2019,publicKeyBase58 是 65 字节公钥的 Base58 编码,authentication 指向它。任何人拿到这份文档,都能拿这把公钥去验我签过的东西——这就是"自证"的全部秘密,没有第三方参与。

第三张是凭证页。签发身份从已有的 DID 里选,下面是主体信息(姓名、年龄、角色),两个按钮:签发、验证。

凭证页:选签发身份,填主体信息

点「签发凭证」,库会在 ArkTS 侧拼出 VC、规范化、调 HUKS 签名,把 proof 塞回去,然后把整份 JSON 返回给 Dart:

签发完成:VC 的完整 JSON,底部是 proof 和 signature

注意 proof.signature 那一行——签名是 Base64 编码的 DER 结构,不是裸的 r||s。这是 HUKS 的行为,后面验签必须兼容这两种格式,否则跨端一验就挂。

再点「验证凭证」,库做的事情是:解析 JSON → 检查过期 → 按 issuer 找到 DID 文档 → 取出公钥 → 去掉 proof 重算规范化串 → 验签:

验证通过:签发者 DID 也被回显出来

验证通过时会把 issuer 回显出来,因为"验证通过"这四个字本身没有意义——你得知道是谁签的。界面上这行 签发者:did:ohos:86AfR… 是库从凭证里读出来的,不是 UI 拼的。

第三页是裸签名,用来做篡改检测的最小实验:

签名:同一段文字签名后得到 Base64 签名

对原数据验签,绿勾;点「验签(篡改后)」,用的是把数据尾部改掉之后的串:

篡改后:验签失败(数据不一致)

红叉不是"验签接口报错",而是验签正常返回了 false。这个区别在排查时很重要:前者是链路问题,后者是密码学结论。

指标实测值
DID 总长度53 字符(did:ohos: 9 字符 + 44 位 Base58 指纹)
公钥长度65 字节未压缩点(04 ‖ X ‖ Y),Base58 编码后 88 字符
HUKS 导出的原始公钥91 字节(X.509 SubjectPublicKeyInfo DER)
签名长度69–71 字节 DER(0x30 0x45/0x46 …),随 r/s 高位是否补零浮动
规范化串长度439 字节(name/age/role 三个字段的 VC)
创建身份34 ms(含 HUKS 生成密钥 + 导公钥 + 写 KV)
签发凭证3 ms(含 SHA-256 规范化串 + HUKS 签名)
验证凭证58 ms(含 KV 读文档 + bigint 验签)

3. 原理:三张纸和三个系统能力

W3C 的 DID 体系如果只看骨架,其实只有三样东西:

  1. 标识符:一个字符串,did:ohos:xxxxx,全局唯一、不需要注册;
  2. 文档:一段 JSON,写着这个标识对应的公钥、认证方式、服务端点;
  3. 凭证:一段别人签过名的 JSON,里面装着关于我的断言(姓名、年龄、角色、资质)。

三者之间的关系是:凭证的签发者字段是一个 DID,验证者拿这个 DID 去解析文档,从文档里取公钥,验凭证上的签名。整个过程没有"认证中心"这个角色,唯一的信任来源是"文档里的公钥能验通凭证上的签名"。

3.1 为什么 DID 用公钥指纹,而不是随机 UUID

我最早写的是 did:ohos: + 随机 UUID,跑通了,但很快发现一个致命问题:这个字符串和密钥没有任何绑定关系。任何人都可以把别人的公钥塞进一个随便生成的 DID 文档里,然后声称这个 DID 是他的。因为 DID 本身不携带任何自证信息。

改成指纹派生之后,这条攻击路就断了:

did:ohos:Base58( SHA-256( 65 字节未压缩公钥 ) )

验证方拿到文档,自己算一遍 SHA-256(publicKeyBase58 解码后的 65 字节),再 Base58 编码,和 DID 后缀比对。不一致,文档就是伪造的。这一步不需要任何外部查询,纯本地运算。这也是 did:key 方法(W3C 的参考方法之一)的思路,我只是把前缀换成了 did:ohos,让它在鸿蒙语境里有明确的方法语义。

用 Base58 而不是 Base64 或 hex,是因为 Base58 去掉了 0 O I l + / 这些易混字符。DID 是要被人念出来、抄下来、贴在二维码上的。

3.2 私钥为什么必须待在 HUKS 里

如果把私钥存成文件,无论加不加密,都有同一类风险:应用进程能读到它,那么有 root 权限的进程、被注入的进程、调试器附着的进程也能读到。DID 的价值全部建立在"私钥只有我知道"上,私钥一泄,别人就能以我的名义签任何东西,而且没有吊销机制能救——因为 DID 本身就是那个公钥的指纹,换密钥就是换身份。

HUKS 的做法是把私钥生成在安全环境里,之后所有使用私钥的操作都通过句柄完成,密钥材料本身不可导出。代码里只有三类操作是允许的:

  • huks.generateKeyItem(alias, options):生成,只给一个别名;
  • huks.exportKeyItem(alias, options):导出公钥(HUKS 只允许导公钥);
  • huks.initSession / finishSession:用别名签数据。

也就是说,整个库从头到尾没有一行代码能读到私钥。这不是我刻意遵守的约定,是 HUKS 的 API 层面就不给这个能力。

那"别名"怎么管理?HUKS 的别名不能改,而我需要"从 DID 找到它的密钥"这个映射。方案是:生成时用一个临时别名(did_key_<时间戳>_<随机>),算出 DID 后,把 alias:did:ohos:xxx → did_key_1759... 这条映射写进 @ohos.data.preferences。

这里有个刻意的设计:映射表只存本地,绝不进分布式 KV。因为 DID 文档是给别人看的,密钥别名是给自己用的;如果把别名同步出去,等于告诉所有人"这台设备的密钥 ID 是这个",虽然拿不到私钥,但多暴露一条攻击面。现在的实现里,换设备的后果很明确:文档能同步过去,私钥不能,所以新设备只能"验证"这个身份,不能"代表"这个身份签发。

3.3 文档为什么放进分布式 KV

DID 文档得能被"别人"解析。别人可能是同设备的另一个应用,也可能是同账号的另一台设备。

鸿蒙的 distributedKVStore 正好提供了这个能力:同账号、同应用、可信设备之间自动同步,服务端不参与业务逻辑,也不需要搭一个 resolver。初始化时几个参数值得单独说:

const options: distributedKVStore.Options = {
  createIfMissing: true,
  encrypt: false,          // 文档本身是公开信息,不需要加密
  backup: false,           // 不做本地备份,避免旧文档回滚
  autoSync: true,          // 关键:写入即同步
  kvStoreType: distributedKVStore.KVStoreType.SINGLE_VERSION,
  securityLevel: distributedKVStore.SecurityLevel.S1
};

SINGLE_VERSION 是我特意选的:DID 文档的语义是"最新的一份有效",不需要历史版本;多版本反而会让"我到底该信任哪一版"变成新问题。S1 是最低安全级,对应"公开数据"——DID 文档本来就是公开的,用更高等级既不必要,也会限制同步范围。autoSync: true 让写入即触发同步,不用手动调 sync()。

3.4 验签为什么只需要公钥

这是 DID 相对"服务端签发 token"最本质的区别。

传统 token 的验证必须找签发方:JWT 要么用共享密钥(HMAC),要么去 JWKS 端点取公钥。前者要求验证方也持有秘密,后者要求签发方在线。

DID 凭证的验证是纯本地的:凭证里写了 issuer,issuer 的文档里有公钥,公钥验签名。签发方关掉服务器、断网、甚至公司倒闭,凭证依然可验。这就是我开头那个巡检场景真正需要的东西——工人在地下二层签的记录,一年后审计来查,不需要当年的服务器还活着。

3.5 整体数据流

把上面几段收拢成一张表:

阶段Dart 侧ArkTS 侧涉及的系统能力
创建身份createDid(alias)生成密钥 → 导公钥 → 算指纹 → 建文档 → 写 KVHUKS、cryptoFramework、distributedKVStore、preferences
解析文档resolveDid(did)按 key 读 KV,反序列化distributedKVStore
签发凭证signCredential(did, subject, id, ttl)拼 VC → 规范化 → 签名 → 加 proofcryptoFramework(摘要)、HUKS(签名)
验证凭证verifyCredential(json)解析 → 查过期 → 取公钥 → 规范化 → 验签distributedKVStore、纯 ArkTS ECDSA

四步里,Dart 侧始终只做两件事:传参、反序列化结果。所有密码学都在 ArkTS 侧,因为只有那一侧能碰到 HUKS。

4. 代码:从 Dart 的一行调用到 HUKS 里的一次签名

先把工程骨架摆出来。flutter create --template=plugin --platforms=ohos 生成的结构我基本保留了,只是在 lib/ 下补了一层平台接口:

flutter_did_ohos/
├─ lib/
│  ├─ flutter_did_ohos.dart                       # 门面:业务只 import 这个
│  └─ src/
│     ├─ did_models.dart                          # DidDocument / VC / VerifyResult 等模型
│     ├─ flutter_did_ohos_platform_interface.dart # 平台抽象(可替换、可 mock)
│     └─ flutter_did_ohos_method_channel.dart     # MethodChannel 实现
├─ ohos/src/main/ets/components/plugin/
│  ├─ FlutterDidOhosPlugin.ets                    # 插件入口 + 方法分发
│  └─ did/
│     ├─ DidService.ets                           # 核心:HUKS + KV + VC
│     ├─ EcdsaVerify.ets                          # 纯 ArkTS P-256 验签
│     └─ Base58.ets                               # Base58 编解码
├─ example/                                       # 三页 Demo(本文截图就是它)
└─ test/                                          # mock 平台接口的单元测试

pubspec.yaml 里只声明一个平台插件:

flutter:
  plugin:
    platforms:
      ohos:
        pluginClass: FlutterDidOhosPlugin

pluginClass 是 ArkTS 侧导出的类名(export default class FlutterDidOhosPlugin),Flutter 引擎启动时会反射实例化它、调用 onAttachedToEngine。就是这一行把 Dart 和 ArkTS 缝在一起。

4.1 为什么中间要垫一层平台接口

Dart 侧我写了两层:门面 FlutterDidOhos(业务用的)和 FlutterDidOhosPlatform(实现用的)。多一层不是为了好看,是为了让"没有鸿蒙设备"的环境也能测业务逻辑。

门面只做转发,所有方法都是薄的:

class FlutterDidOhos {
  const FlutterDidOhos._();

  /// 单例入口,业务侧统一从这里拿 API
  static const FlutterDidOhos instance = FlutterDidOhos._();

  /// 创建身份:生成 HUKS 密钥 -> 派生 DID -> 写入分布式 KV
  Future<DidDocument> createDid({
    String? alias,
    List<DidServiceEndpoint> service = const <DidServiceEndpoint>[],
  }) =>
      FlutterDidOhosPlatform.instance.createDid(alias: alias, service: service);

  /// 签发可验证凭证(私钥签名在 HUKS 内完成)
  Future<VerifiableCredential> signCredential({
    required String did,
    required Map<String, dynamic> credentialSubject,
    String? credentialId,
    Duration? validity,
  }) =>
      FlutterDidOhosPlatform.instance.signCredential(
        did: did,
        credentialSubject: credentialSubject,
        credentialId: credentialId,
        validity: validity,
      );
}

平台接口是一个抽象类,默认实现指向 MethodChannel 版本;测试时替换 FlutterDidOhosPlatform.instance 就能跑纯 Dart 单测,不需要模拟器。这套写法是 Flutter 官方推荐的 federated plugin 模式,我按最小可用版本抄了一份。

MethodChannel 那一层有一个地方值得说:原生返回的类型必须校验。

Object _ensureArgs(Object? args) {
  // 原生侧返回 null 或异常结构时,尽早抛 PlatformException,别让调用方在
  // fromMap 里踩空指针——排障时“返回了什么”比“崩在哪一行”重要得多
  if (args is Map) return args;
  throw PlatformException(
    code: DidErrorCode.invalidFormat,
    message: 'unexpected native response: $args',
  );
}

4.2 ArkTS 侧:一个 switch 把 11 个方法分下去

插件入口很短,主要工作是拿 Context、建通道、把调用转给 DidService:

export default class FlutterDidOhosPlugin implements FlutterPlugin, MethodCallHandler {
  private channel: MethodChannel | null = null;
  private didService: DidService = new DidService();
  private appContext: Context | null = null;

  onAttachedToEngine(binding: FlutterPluginBinding): void {
    // ApplicationContext 是分布式 KV、preferences 的必需品,必须在 attach 时拿
    this.appContext = binding.getApplicationContext();
    this.channel = new MethodChannel(binding.getBinaryMessenger(), 'flutter_did_ohos');
    this.channel.setMethodCallHandler(this);
    // 预初始化(幂等),失败不影响插件挂载,业务调用时会再报错
    this.didService.initialize(this.appContext).catch((e: BusinessError) => {
      hilog.error(LOG_DOMAIN, LOG_TAG, '%{public}s',
        `pre-initialize failed: ${e.code} ${e.message}`);
    });
  }

  onMethodCall(call: MethodCall, result: MethodResult): void {
    switch (call.method) {
      case 'getPlatformVersion': {
        result.success('OpenHarmony DID');
        break;
      }
      case 'initialize': {
        this.didService.initialize(this.appContext ?? getContext())
          .then((ok: boolean) => result.success(ok))
          .catch((e: Error) => this.fail(result, e));
        break;
      }
      case 'createDid': {
        const alias: string = call.argument('alias') as string ?? '';
        const serviceRaw: ESObject = call.argument('service');
        this.didService.createDid(alias, this.parseServiceList(serviceRaw))
          .then((doc: ESObject) => result.success(doc))
          .catch((e: Error) => this.fail(result, e));
        break;
      }
      case 'listDids': {
        this.didService.listDids()
          .then((items: Array<ESObject>) => result.success({ items: items }))
          .catch((e: Error) => this.fail(result, e));
        break;
      }
      case 'resolveDid': {
        const did: string = call.argument('did') as string ?? '';
        this.didService.resolveDid(did)
          .then((doc: ESObject | null) => result.success(doc))
          .catch((e: Error) => this.fail(result, e));
        break;
      }
      case 'deleteDid': {
        const did: string = call.argument('did') as string ?? '';
        this.didService.deleteDid(did)
          .then((ok: boolean) => result.success(ok))
          .catch((e: Error) => this.fail(result, e));
        break;
      }
      case 'signCredential': {
        const did: string = call.argument('did') as string ?? '';
        const subject: ESObject = call.argument('credentialSubject') ?? {};
        const credentialId: string = call.argument('credentialId') as string ?? '';
        const validitySeconds: number = call.argument('validitySeconds') as number ?? 0;
        this.didService.signCredential(did, subject, credentialId, validitySeconds)
          .then((vc: ESObject) => result.success(vc))
          .catch((e: Error) => this.fail(result, e));
        break;
      }
      case 'verifyCredential': {
        const credentialJson: string = call.argument('credentialJson') as string ?? '';
        this.didService.verifyCredential(credentialJson)
          .then((res: ESObject) => result.success(res))
          .catch((e: Error) => this.fail(result, e));
        break;
      }
      case 'signData': {
        const did: string = call.argument('did') as string ?? '';
        const data: string = call.argument('data') as string ?? '';
        this.didService.signData(did, data)
          .then((sig: string) => result.success(sig))
          .catch((e: Error) => this.fail(result, e));
        break;
      }
      case 'verifyData': {
        const did: string = call.argument('did') as string ?? '';
        const data: string = call.argument('data') as string ?? '';
        const signature: string = call.argument('signature') as string ?? '';
        this.didService.verifyData(did, data, signature)
          .then((ok: boolean) => result.success(ok))
          .catch((e: Error) => this.fail(result, e));
        break;
      }
      case 'getSyncState': {
        this.didService.getSyncState()
          .then((res: ESObject) => result.success(res))
          .catch((e: Error) => this.fail(result, e));
        break;
      }
      default: {
        result.notImplemented();
        break;
      }
    }
  }
}

错误处理是这一层唯一的"设计":DidService 抛的是自定义 DidError,带着字符串错误码;插件把它原样透传给 Dart:

private fail(result: MethodResult, e: Error): void {
  if (e instanceof DidError) {
    // 错误码透传到 Dart,业务侧可以按 code 分支处理,不用解析 message
    result.error(e.code, e.message, null);
  } else {
    result.error(DidErrorCode.UNKNOWN, e.message, null);
  }
}

为什么不在原生侧把错误"翻译"成人话?因为业务需要判断的是"是密钥没了,还是文档没了"(KEY_NOT_FOUND vs DID_NOT_FOUND),这两种情况的 UX 完全不同:前者要引导用户重建身份,后者只需要重新同步。

4.3 HUKS:生成密钥、导出公钥、签名

这三步是整篇文章的技术核心。先看生成:

private async generateKey(alias: string): Promise<void> {
  const properties: Array<huks.HuksParam> = [
    { tag: huks.HuksTag.HUKS_TAG_ALGORITHM, value: huks.HuksKeyAlg.HUKS_ALG_ECC },
    { tag: huks.HuksTag.HUKS_TAG_KEY_SIZE, value: huks.HuksKeySize.HUKS_ECC_KEY_SIZE_256 },
    {
      tag: huks.HuksTag.HUKS_TAG_PURPOSE,
      // 只给签名和验签用途,不给加解密——最小权限
      value: huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_SIGN |
        huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_VERIFY
    },
    { tag: huks.HuksTag.HUKS_TAG_DIGEST, value: huks.HuksKeyDigest.HUKS_DIGEST_SHA256 }
  ];
  await huks.generateKeyItem(alias, { properties: properties });
}

HUKS_TAG_PURPOSE 那里我特意只留了 SIGN 和 VERIFY。默认模板里很多人会把 ENCRYPT/DECRYPT 一起勾上,觉得"反正用不到",但密钥用途是写进密钥属性里的,用途越多,能被诱导做的操作越多。DID 场景只需要签名,那就只申请签名。

导出公钥一行就够,但返回值需要仔细看:

private async exportPublicKey(alias: string): Promise<Uint8Array> {
  const result: huks.HuksReturnResult = await huks.exportKeyItem(alias, { properties: [] });
  if (result.outData == null || result.outData.length === 0) {
    throw new DidError(DidErrorCode.HUKS_ERROR, `export public key failed: ${alias}`);
  }
  return result.outData;
}

文档里说导出的是"公钥",我一开始想当然按 65 字节未压缩点处理,结果算出来的 DID 和验签全对不上。打日志一看,长度是 91 字节——HUKS 给的是 X.509 SubjectPublicKeyInfo 结构(DER),前面 26 字节是算法标识和长度头,后面才是 04 || X || Y。所以有了这个归一化函数:

/** HUKS 导出的 ECC 公钥可能是 91 字节 X.509 DER,统一截成 65 字节未压缩点 */
private normalizePubKey(pubKey: Uint8Array): Uint8Array {
  if (pubKey.length > 65) {
    return pubKey.subarray(pubKey.length - 65);
  }
  return pubKey;
}

用 subarray 而不是 slice,是为了不复制内存——虽然后面马上要算哈希,这点开销无所谓,但习惯上对二进制数据尽量用视图,避免在大数组上来回复制。

签名是"三段式"的,这是 HUKS 和普通 crypto 库最不一样的地方:

private async signWithKey(alias: string, data: Uint8Array): Promise<Uint8Array> {
  const properties: Array<huks.HuksParam> = [
    { tag: huks.HuksTag.HUKS_TAG_ALGORITHM, value: huks.HuksKeyAlg.HUKS_ALG_ECC },
    { tag: huks.HuksTag.HUKS_TAG_PURPOSE, value: huks.HuksKeyPurpose.HUKS_KEY_PURPOSE_SIGN },
    { tag: huks.HuksTag.HUKS_TAG_DIGEST, value: huks.HuksKeyDigest.HUKS_DIGEST_SHA256 }
  ];
  // 第一步:用别名开一个会话,拿到句柄——私钥从这里开始被“引用”而不是被“读取”
  const session: huks.HuksSessionHandle = await huks.initSession(alias, { properties: properties });
  // 第二步:把数据交给会话,HUKS 内部做摘要+签名
  const result: huks.HuksReturnResult = await huks.finishSession(session.handle, {
    properties: properties,
    inData: data
  });
  if (result.outData == null || result.outData.length === 0) {
    throw new DidError(DidErrorCode.HUKS_ERROR, 'sign failed: empty signature');
  }
  return result.outData;
}

这里省掉了 updateSession——数据不大(一条 VC 的规范化串几百字节),一次性 finishSession 就够。如果将来要签大文件,得改成 init → update(多次) → finish,否则会有单次数据量上限。

还有一个容易忽略的点:HUKS_TAG_DIGEST 我设成了 SHA256,意味着传给 finishSession 的应该是原始数据,HUKS 会自己算摘要。而我验签那边是"我自己算摘要、自己跑 ECDSA",两边必须对齐——所以我验签时先 sha256(data) 再调 EcdsaVerify.verify。这个对齐关系如果搞错,症状是"签名成功、验签永远失败"。

4.4 从公钥到 DID:一行指纹,一堆细节

/** 由公钥派生 DID:did:ohos:<base58(sha256(公钥)> */
private async deriveDid(pubKey: Uint8Array): Promise<string> {
  const digest: Uint8Array = await this.sha256(this.normalizePubKey(pubKey));
  return DID_METHOD_PREFIX + Base58.encode(digest);
}

private async sha256(data: Uint8Array): Promise<Uint8Array> {
  // 注意算法名是 'SHA256',不是 'SHA-256'
  const md = cryptoFramework.createMd('SHA256');
  await md.update({ data: data });
  const blob: cryptoFramework.DataBlob = await md.digest();
  return blob.data;
}

did:ohos: 是自己起的方法名(method),对应 W3C DID 里 did:<method>:<id> 的三段式。起名字的时候想过 did:harmony,但方法名惯例是短小写,最后选了 ohos——和 OpenHarmony 的缩写一致,也和包名对得上。

文档构造是一个纯函数,输入 DID、公钥、别名、服务端点,输出一个对象:

private buildDidDocument(did: string, pubKeyBase58: string, alias: string,
  serviceList: ServiceEndpointParam[], created: string): ESObject {
  const doc: Record<string, ESObject> = {};
  doc['@context'] = JSON.parse(DID_CONTEXT) as ESObject;
  doc['id'] = did;
  doc['verificationMethod'] = [{
    id: `${did}#keys-1`,              // 片段标识,凭证的 proof 会引用它
    type: KEY_TYPE,                    // EcdsaSecp256r1VerificationKey2019
    controller: did,
    publicKeyBase58: pubKeyBase58
  }];
  doc['authentication'] = [`${did}#keys-1`];
  doc['alias'] = alias;                // 非标准字段,方便 UI 展示
  doc['created'] = created;
  doc['updated'] = created;
  doc['device'] = this.deviceLabel();  // 记录文档来源设备,同步后能看出来自哪台
  if (serviceList.length > 0) {
    doc['service'] = serviceList;
  }
  return doc;
}

alias 和 device 不是 W3C 规定的字段,W3C 允许扩展(只要在 @context 里能解释)。我加这两个是为了调试和多设备场景:文档同步到平板后,你能看出这份文档最初是手机创建的。

4.5 凭证签名:键排序不是洁癖,是必需

这一节是整个库里最容易出错的地方,把它单独拎出来讲。

JSON 对象在规范上是无序的,但序列化出来是有序的——取决于引擎的实现。Dart 的 jsonEncode 按插入顺序输出,ArkTS 的 JSON.stringify 也按插入顺序。问题在于:

  • 签发方:先 id 后 issuer,还是先 issuer 后 id?
  • 验证方:反序列化之后再序列化,顺序能不能保证一致?

只要有一处不一致,重算出来的字节串就不同,哈希就不同,验签必然失败。而且这种失败非常隐蔽——你打印两份 JSON 肉眼看是一样的。

解决办法是规范化(canonicalization):签名前把对象的所有键递归排序,再序列化。W3C 标准里对应的方案是 JCS(RFC 8785),我实现的是一个简化版:

/** 递归排序对象键;数组保持顺序(数组有序是语义的一部分) */
private sortedValue(value: ESObject): ESObject {
  if (Array.isArray(value)) {
    return value.map((item: ESObject): ESObject => this.sortedValue(item));
  }
  // Flutter 通道传过来的 Dart Map,在 ArkTS 侧是 Map 实例而不是普通对象
  if (value instanceof Map) {
    const map: Map<string, ESObject> = value as Map<string, ESObject>;
    const keys: string[] = [];
    map.forEach((_v: ESObject, k: string) => keys.push(k));
    keys.sort();
    const fromMap: Record<string, ESObject> = {};
    for (const key of keys) {
      fromMap[key] = this.sortedValue(map.get(key) as ESObject);
    }
    return fromMap;
  }
  if (value !== null && typeof value === 'object') {
    const keys: string[] = Object.keys(value).sort();
    const out: Record<string, ESObject> = {};
    for (const key of keys) {
      out[key] = this.sortedValue(value[key]);
    }
    return out;
  }
  return value;
}

private canonicalString(value: ESObject): string {
  return JSON.stringify(this.sortedValue(value));
}

value instanceof Map 这个分支是我调了两个小时才加上的。原因在第 5 节讲,这里只留一句结论:跨通道传过来的对象,不要假设它一定是 plain object。

签名流程本身很直白,关键是"先算签名、后塞 proof":

const canonical: string = this.canonicalString(vc);   // 此时 vc 里还没有 proof
signature = await this.signWithKey(alias, this.bytesOf(canonical));
vc['proof'] = {
  type: PROOF_TYPE,
  created: now,
  verificationMethod: `${did}#keys-1`,
  proofPurpose: 'authentication',
  signature: this.base64Of(signature)
};

为什么必须先签名后塞 proof?因为验签方要能重建同一份字节串。如果签名时把 proof(哪怕是空的)算进去,验签方就得知道"当时 proof 里的 signature 是空串"这种实现细节,跨语言实现立刻分叉。现在的规则很简单:去掉 proof 字段后的整个对象就是被签的内容,任何语言都能实现。

4.6 验签:一段不太情愿写的椭圆曲线代码

到这一步,理想方案是调 HUKS 的 VERIFY 会话,或者用 cryptoFramework.convertKey 把公钥导入后调 verify。

# HUKS 验签会话
12000006 Crypto engine error

# cryptoFramework 公钥导入
17630001 convert key fail

第一次看到 12000006 以为是参数错了,把 HUKS_TAG_PURPOSE、HUKS_TAG_DIGEST 逐个换了一遍,还是同一个码。翻了 HUKS 的实现说明才明白:VERIFY 用途在部分设备/镜像上并没有真正实现,而 convertKey 对 ECC 公钥的格式要求比文档写的更严格。

我没有继续跟这两个错误较劲,而是换了思路:验签只用公钥,是纯数学,完全可以自己算。ECDSA P-256 的验证公式只有五行:

// w = s^-1 mod n
const w: bigint = EcdsaVerify.inverse(s, N);
// u1 = e*w, u2 = r*w  (e 是消息摘要)
const u1: bigint = EcdsaVerify.mod(e * w, N);
const u2: bigint = EcdsaVerify.mod(r * w, N);
// (x, y) = u1*G + u2*Q
const point = EcdsaVerify.add(
  EcdsaVerify.multiply(u1, { x: GX, y: GY }),
  EcdsaVerify.multiply(u2, q)
);
// 验证 x mod n == r
return EcdsaVerify.mod(point.x, N) === r;

真正的工程量在底层:模逆(扩展欧几里得)、点加、倍点、标量乘法(double-and-add),以及两种签名格式的解析。这些没什么取巧空间,写错了就是 false,所以我用 Node 的 crypto 生成了一批测试向量来对拍——同一组 (公钥, 摘要, 签名),Node 验通、我的实现也必须验通,反之亦然。

签名格式的兼容是另一个坑。HUKS 输出的是 DER:

/** 解析 DER(0x30 开头)或 r||s 裸拼接(64 字节)两种签名 */
private static parseSignature(signature: Uint8Array): bigint[] {
  if (signature[0] !== 0x30) {
    // 不是 DER:按 32+32 字节的 r||s 处理(跨端常见格式)
    if (signature.length === 64) {
      return [bytesToBigInt(signature, 0, 32), bytesToBigInt(signature, 32, 32)];
    }
    throw new Error(`unsupported signature format, length=${signature.length}`);
  }
  // DER: 30 <len> 02 <rLen> <r> 02 <sLen> <s>
  let offset = 1;
  const lenByte = signature[offset++];
  if ((lenByte & 0x80) !== 0) {
    offset += (lenByte & 0x7f);   // 长格式长度,跳过
  }
  let rLen = signature[offset + 1];
  const r = bytesToBigInt(signature, offset + 2, rLen);
  offset = offset + 2 + rLen;
  const sLen = signature[offset + 1];
  const s = bytesToBigInt(signature, offset + 2, sLen);
  return [r, s];
}

公钥也一样,两种格式都得吃:

let raw: Uint8Array = pubKey;
if (pubKey[0] === 0x30) {
  // X.509 SubjectPublicKeyInfo,取尾部 65 字节
  raw = pubKey.subarray(pubKey.length - 65);
} else if (pubKey.length > 65) {
  raw = pubKey.subarray(pubKey.length - 65);
}

这里必须说清安全边界:自己实现 ECDSA 验签是可以接受的,因为验签只用公开数据,没有秘密可泄露,最坏结果是"验错";但签名绝对不能用这种实现——签名的标量乘法如果不做常数时间处理,会被时序攻击还原私钥。这个库里签名始终走 HUKS,手写的部分只负责验。

4.7 分布式 KV:读写与一个返回值陷阱

初始化一共十来行,但每一行都有讲究:

async initialize(context: Context): Promise<boolean> {
  if (this.initialized && this.kvStore != null) {
    return true;      // 幂等:插件 attach 时会调一次,业务 initialize 再调一次
  }
  this.appContext = context;
  const kvManager: distributedKVStore.KVManager = distributedKVStore.createKVManager({
    context: context,
    bundleName: 'dev.harmony.flutter_did_ohos'   // 必须和宿主 HAP 的 bundleName 一致
  });
  const store: distributedKVStore.SingleKVStore = await kvManager.getKVStore(
    'flutter_did_store',
    {
      createIfMissing: true,
      encrypt: false,          // DID 文档是公开信息
      backup: false,           // 不做备份,避免旧版本回滚
      autoSync: true,          // 写入即向同账号设备同步
      kvStoreType: distributedKVStore.KVStoreType.SINGLE_VERSION,
      securityLevel: distributedKVStore.SecurityLevel.S1
    }
  );
  this.kvStore = store;
  this.keyMapPref = await preferences.getPreferences(context, 'flutter_did_keymap');
  this.initialized = true;
  return true;
}

读列表时踩了一个很隐蔽的坑。getEntries(prefix) 返回 Entry[],每个 Entry 有 key 和 value。直觉上应该直接用 entry.value,但它在 API 26 上返回的不是字符串,而是一个二进制包装对象;JSON.parse(entry.value) 直接抛 SyntaxError: Unexpected Text in JSON。绕开的办法是用 get(key) 再读一次:

async listDids(): Promise<Array<ESObject>> {
  const store = this.requireStore();
  const entries: distributedKVStore.Entry[] = await store.getEntries(DOC_KEY_PREFIX);
  const items: Array<ESObject> = [];
  for (const entry of entries) {
    try {
      // 不用 entry.value:部分版本返回二进制包装对象,改用 get 读文本
      const raw: ESObject = await store.get(entry.key) as ESObject;
      const text: string = this.toText(raw);
      if (text === '') {
        continue;
      }
      const doc: ESObject = JSON.parse(text) as ESObject;
      items.push({
        did: doc['id'],
        alias: doc['alias'],
        createdAt: doc['created'],
        device: doc['device']
      });
    } catch (e) {
      // 单条脏数据不能让整个列表失败
      this.logWarn(`skip invalid did record: ${e}`);
    }
  }
  items.sort((a: ESObject, b: ESObject): number => {
    const ta: string = a['createdAt'] as string ?? '';
    const tb: string = b['createdAt'] as string ?? '';
    return ta.localeCompare(tb);
  });
  return items;
}

toText 是为此写的兜底函数,按 string → TypedArray → ArrayBuffer → {0:..,length:n} 的顺序尝试解码。它不好看,但这段代码的存在本身说明了一件事:原生 API 的返回类型在不同版本/不同数据来源下并不稳定,边界处必须做类型收敛。

4.8 业务侧到底怎么用

说了这么多内部实现,业务侧看到的其实就三段:

final FlutterDidOhos did = FlutterDidOhos.instance;

// 1. 启动时初始化(幂等,重复调用无副作用)
await did.initialize();

// 2. 创建身份
final DidDocument doc = await did.createDid(alias: '我的身份');
debugPrint('DID = ${doc.id}, 公钥 = ${doc.verificationMethod.first.publicKeyBase58}');

// 3. 签发 + 验证凭证
final VerifiableCredential vc = await did.signCredential(
  did: doc.id,
  credentialSubject: <String, dynamic>{'name': '张三', 'role': '巡检员'},
  validity: const Duration(days: 1),
);
final VerifyResult result = await did.verifyCredential(vc.toJson());
debugPrint(result.valid ? '验签通过:${result.issuer}' : '验签失败:${result.message}');

三段代码对应三个业务动作:应用启动拿身份、用户注册建身份、业务流程里签凭证。

5. 踩过的坑,每一条都有真实报错

坑一:createMd('SHA-256') 让创建身份直接失败

第一次跑 createDid,界面弹出 PlatformException(UNKNOWN, create C obj failed.)。这个报错信息几乎没有信息量,我只能一层层加日志,最后定位到 cryptoFramework.createMd 这一行:

// 错的
const md = cryptoFramework.createMd('SHA-256');
// 对的(OpenHarmony 的算法名不带连字符)
const md = cryptoFramework.createMd('SHA256');

OpenHarmony 的 createMd 用的是"算法族 + 位数"的紧凑写法:SHA256、SHA384、SHA512。写成 SHA-256 不会报"参数错误",而是直接抛一个底层错误 17630001 create C obj failed。这条坑的通用教训是:算法名字符串属于 API 契约的一部分,不要按 OpenSSL / JCA 的习惯顺手写。

坑二:getEntries 返回的 value 不能直接 JSON.parse

DID 创建成功了,列表却是空的。日志里是一句 SyntaxError: Unexpected Text in JSON。

我打印了 entry.value 的类型:typeof === 'object',构造函数是 Object,length 是 15——它不是字符串,而是一个带索引的字节包装。而同一个 key 用 get(key) 读出来就是正常字符串。

最后的写法是用 getEntries(prefix) 只拿 key,再逐个 get(key) 读值;同时写了一个 toText() 做类型收敛,兼容 string / TypedArray / ArrayBuffer / {0:..,length:n} 四种形态。代价是多一次读取,列表 10 条以内感觉不到。

坑三:公钥不是 65 字节

前面提过,HUKS 导出的是 91 字节 X.509 DER。这个坑的症状特别迷惑:DID 能创建成功(因为只是把字节哈希了一下),但用 Node 独立验签永远失败,而且失败得毫无线索。

我是这么定位的:把设备上导出的公钥 Base64 打印出来,粘到本地 Node 脚本里,用 crypto.createPublicKey({ key, format: 'der', type: 'spki' }) 一导就成功了——说明它是标准 SPKI 结构,而不是裸点。于是加 normalizePubKey 截尾 65 字节,问题消失。

顺带说,publicKeyBase58 存的也是归一化之后的 65 字节,否则别人拿到文档还得猜格式。

坑四:HUKS 的 VERIFY 在模拟器上不工作

前面写过报错码:12000006 Crypto engine error(HUKS 验签会话)、17630001 convert key fail(cryptoFramework 导入公钥)。我在这两个错误上花了差不多一个下午,试过:

  • 换 HUKS_TAG_DIGEST(SHA256 / NoDigest 都试);
  • 把签名格式从 DER 转成 r||s 再验;
  • 用 huks.importKeyItem 把公钥导入成一个"验签专用密钥"再调 verify;
  • 换 cryptoFramework.createVerify('ECC256|SHA256')。

全部失败。最后接受现实:在这个镜像上,只有签名链路是通的,验签链路不通。于是写了 EcdsaVerify.ets。

这个决定有代价:多 190 行密码学代码,多一份需要自己保证正确性的实现。所以我在仓库里放了测试向量(用 Node crypto 生成),并且明确规定:只有验签走自研,签名永远走 HUKS。如果哪天 HUKS 的 VERIFY 在目标设备上可用,把 verifyWithPubKey 换回 HUKS 调用即可,接口没变。

坑五:Dart 的 Map 到了 ArkTS 不是 Map,也是 Map

这个坑最阴。签发凭证时验证总是 SIGNATURE_INVALID,我把签发端和验证端的规范化串都打印出来对比——字符串完全一样,但签名就是对不上。

真相是:credentialSubject 从 Dart 传过来时,在 ArkTS 侧是一个 Map 实例,不是 plain object。我的 sortedValue 只处理了 typeof === 'object',而 Map 的 Object.keys() 返回空数组——于是整个 subject 被序列化成了 {}。

签发的时候签名的是 {"credentialSubject":{},"issuer":...,"id":...} 这样一份 subject 为空的对象,验证的时候(凭证已在 Dart 侧序列化成 JSON 再传回)反序列化出来是真正的对象,算出来是完整内容。两边当然对不上。

修法是两处:sortedValue 里加 value instanceof Map 分支;插件入口的 field() 也兼容 Map。这件事让我在后面的所有跨通道数据上都加了"先归一化再使用"的习惯。

坑六:DISTRIBUTED_DATASYNC 权限少了字段,hvigor 直接拒绝编译

报错是 hvigor ERROR: 00303218 Configuration Error。原因是 user_grant 类权限必须同时声明 reason 和 usedScene:

"requestPermissions": [
  { "name": "ohos.permission.INTERNET" },
  {
    "name": "ohos.permission.DISTRIBUTED_DATASYNC",
    "reason": "$string:datasync_reason",           // 必须是资源引用,不能是字面量
    "usedScene": {
      "abilities": ["EntryAbility"],
      "when": "inuse"
    }
  }
]

还有个细节:reason 的值必须指向 string.json 里的资源($string:xxx),直接写中文会再报一次错。when 只能是 inuse 或 always,always 需要额外审核。

坑七:ArkTS 的严格模式,比 TypeScript 严一档

从 TS 迁过来的代码会被这几条规则拦住,报错码都是 arkts-no-*:

报错触发写法改法
arkts-no-untyped-obj-literalsArray<{ id: string }> 当类型用显式 interface ServiceEndpointParam
arkts-no-obj-literals-as-types直接拿对象字面量当类型同上
arkts-no-destruct-declsconst { r, s } = pair逐字段赋值
arkts-no-any-unknownfor (const x of list) 里隐式 any显式 Array<ESObject> + 下标循环

还有一条不在这张表里但更烦:getKVStore<T>() 这种泛型调用,如果前面是链式调用,会报 “Untyped function calls may not accept type arguments”。必须先落一个带类型的中间变量:

const kvManager: distributedKVStore.KVManager = distributedKVStore.createKVManager(config);
const store = await kvManager.getKVStore<distributedKVStore.SingleKVStore>(id, options);

坑八:console.info 不进 hilog

这个问题我在写这篇文章时才撞上。插件里用 console.info 打的日志,hdc shell hilog 里一条都看不到(console.error 也一样)。原因不是日志没打,而是 JS 引擎的 console 输出没有走 hilog 的公共域,hilog -x 抓不到。

改成显式 API 才稳:

import hilog from '@ohos.hilog';

const LOG_DOMAIN: number = 0xF0D1;
const LOG_TAG: string = 'flutter_did_ohos';

// '%{public}s' 必须加:hilog 默认会把参数按隐私数据处理,不加可能只看到 <private>
hilog.info(LOG_DOMAIN, LOG_TAG, '%{public}s', `did created in ${ms}ms: ${did}`);

改完之后,hdc shell hilog -T flutter_did_ohos 就能只看到这个库的日志,排查第三方库问题时非常省事。


6. 结论:

本文完整落地了一套面向鸿蒙 Flutter 生态的分布式数字身份插件 flutter_did_ohos,核心目标是解决离线场景下身份凭证不依赖服务端会话、不绑定手机号与设备登录态的痛点,适配厂区巡检这类弱网/无网业务。方案依托鸿蒙原生安全能力 HUKS 托管私钥,私钥全程留在安全环境不可导出,仅对外暴露公钥;搭配 cryptoFramework 做哈希运算、distributedKVStore 实现同账号可信设备间 DID 文档自动同步,构建了一套离线可用、密码学自证的最小 DID+VC 闭环。

整体架构采用联邦插件分层设计,Dart 层只做参数转发、模型封装与业务调用,ArkTS 层承载所有密钥管理、签名、分布式存储、ECDSA 验签等底层逻辑,兼顾 Flutter 业务的易用性与鸿蒙系统安全能力的强约束。DID 标识符采用 did:ohos: 方法,由归一化后的公钥 SHA-256 指纹派生,天然和密钥绑定,杜绝随意伪造 DID 文档的风险;凭证签发遵循 W3C VC 规范,实现 JSON 键递归排序的规范化处理,保证签名与验签侧序列化字节一致,签名由 HUKS 内部完成,验签则自研兼容 DER、裸 r||s 两种格式的 P-256 ECDSA 验签实现,规避了部分设备镜像上 HUKS 验签接口不可用的限制。

项目在开发中梳理了包括算法名称大小写、HUKS 导出公钥为 X.509 SPKI 结构、MethodChannel 传递 Map 类型特殊处理、分布式KV返回值类型不稳定等典型坑点,形成可复用的适配经验。该插件定位为轻量化基础组件,不包含助记词备份、公网Resolver、凭证吊销列表等复杂能力,适合企业内部离线可信存证、现场作业记录等场景。业务接入仅需少量 Dart 代码,就能完成身份创建、凭证签发、离线验签,断网环境下凭证依然具备可验证性,实现“身份跟人走、记录自证可信”的业务诉求。


参考资料

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐