当前位置:首页>鸿蒙APP>鸿蒙云数据库换设备数据全丢?六个坑连环暴雷

鸿蒙云数据库换设备数据全丢?六个坑连环暴雷

  • 2026-10-20 13:01:39
鸿蒙云数据库换设备数据全丢?六个坑连环暴雷

鸿蒙开发历险记 · 第2期

鸿蒙云数据库换设备数据全丢?六个坑连环暴雷

你给鸿蒙App加了AGC云同步功能,测试机上一切完美。直到有一天你换了一台手机登录同一个华为账号——云端数据像是从来没存在过。更诡异的是,换回旧手机又能用了。这不是网络问题,这是六个坑在连环等你。

这不是一次简单的bug修复,是一场从权限模型到密码学的完整排查。六个坑分两条线层层递进:

架构线(坑1-3):权限模型绑错了身份→权限放宽导致密钥明文可读→加密方案本身有密码学缺陷——每一层修复都暴露下一层更深的问题。

实现线(坑4-6):加密算法性能炸了主线程→schema校验规则反直觉→版本号不同步——都是真机落地才暴露的实现细节坑。

六个坑排查下来,最大的教训是:SDK源码是最好的文档,错误信息原文就是搜索关键词。

坑一:权限绑的是设备匿名UID,不是你的华为账号

现象

同一个华为账号,在设备A上同步的数据,换到设备B(或设备A卸载重装)登录同一账号后——读不到、也没法在原有记录基础上继续写。但同一设备不重装时完全正常,很容易被误判成"网络问题"或"偶发bug"。

诊断

对照初始化代码:

const signInResult = await cloud.auth().signInAnonymously();

signInAnonymously()会在每次应用启动时产生一个新的匿名登录身份(UID),这个身份跟设备/安装绑定,跟华为账号完全无关。

根因

AGC Cloud DB的权限规则(Creator/Authenticated/World/Administrator)判断"你是谁",只认AGC Auth产生的UID,不认业务层面的用户账号。项目原来的权限配置是Creator: Read/Upsert/Delete(仅创建者可读写),"创建者"指的就是这个匿名UID——换设备后匿名UID变了,等于变成了另一个人,自然读不到也写不了旧数据。

已排除的更"正确"方案

· 用AGC云函数HTTP触发器做服务端身份校验——已实际部署尝试过,被HDA-SYSTEM请求签名机制卡住,触发器要求签名、纯Bearer token会被拒绝为404,放弃。

· 让华为账号登录直接关联为AGC Auth身份——对照项目实际安装的@hw-agconnect/cloud@1.0.2客户端SDK源码(Auth.ts)验证过,这个方法不存在。SDK的signIn()只支持手机号/邮箱两种凭证类型,没有第三方OAuth/idToken凭证类型。

坑1的"正确方案"(AGC Auth关联华为账号)在当前SDK版本走不通,只能接受"权限放宽+App层过滤"的次优解。这不是懒,是SDK能力边界限制下的务实取舍——如果未来SDK支持了华为账号关联登录,应该优先升级到数据库原生隔离。

修复

匿名登录保留(它是访问Cloud DB的门槛要求),但"谁能看到哪些数据"从AGC的Creator权限移交给App代码用真实的华为账号UnionID过滤:

// 登录时:request.scopes 由 ['profile'] 改为 ['openid', 'profile'],才能拿到 unionID
const unionId = cred?.unionID || '';
setCloudUserId(unionId);

// 查询时:所有云端查询强制加上 userId 过滤
const cloudRecords = await tokenCollection.query()
  .equalTo('is_deleted', false)
  .equalTo('userId', currentUserId)
  .get();

AGC控制台权限规则从"仅创建者可读写"改成"已登录用户可读写"(Authenticated行的query/upsert/delete都勾上)。

坑二:权限放宽后,密钥明文可读——任何登录用户都能看到你的TOTP secret

现象

坑1的方案上线后,权限从"仅创建者"放宽为"任意已登录用户"——这意味着任何能匿名登录的客户端,理论上都能读到所有用户的记录。而记录里的TOTP密钥(secret字段)当时是明文存储的。

根因

设计一开始只考虑了"能不能同步成功",没考虑权限放宽这一步的连带影响:数据库层面的隔离降级了,但没有用加密去补上这个缺口。这不是权限放宽本身的错,是没有同步评估它对已有明文字段的影响。

权限放宽是一整套连带影响,不是改一个配置就完事。 放宽之前,先检查明文敏感字段是否需要加密、全局主键是否需要按用户维度做成复合主键。

修复

上传前加密、下载后解密,加解密只发生在云端序列化的边界(buildRecord/recordToToken),App内其余逻辑拿到的始终是明文:

function buildRecord(token: SyncToken): SyncTokenRecord {
  const r = new SyncTokenRecord();
  r.id = token.id;
  r.brand = encryptFieldForCloud(token.brand, currentUserId, token.id, 'brand');
  r.account = encryptFieldForCloud(token.account, currentUserId, token.id, 'account');
  r.secret = encryptFieldForCloud(token.secret, currentUserId, token.id, 'secret');
  ...
}

同时,secret原来是Zone内的全局唯一主键,不同账号之间会因为主键冲突互相覆盖对方记录。改成(userId, secret密文)复合主键,从根上防止跨账号覆盖:

{ fieldName: 'secret', fieldType: 'String', belongPrimaryKey: true, notNull: true },
...
{ fieldName: 'userId', fieldType: 'String', belongPrimaryKey: true, notNull: true }

坑三:加密方案是"多次一密"——密码学经典错误

现象

坑2的加密方案上线后,代码review指出:encryptFieldForCloud对同一账号的所有记录都用同一个固定密钥做异或——即密文1 ⊕ 密文2 = 明文1 ⊕ 明文2。TOTP密钥是Base32字符集,account/brand又是明文存在旁边,给crib-dragging攻击留了空间。

根因

密钥派生只用了unionId + 固定字符串,没有引入任何"每条记录/每个字段都不同"的变量。这是密码学上的经典错误——多次一密(two-time pad):同一个密钥流被复用到多条不同数据上,任何两条密文异或就能泄露明文的异或值。

"多次一密"是密码学课本第一章就会警告的错误,但在业务迭代中很容易被忽略——因为"每条记录用一个不同密钥"这件事,在第一版加密方案里根本没有被当作设计约束来考虑。加密不是"加上就行",salt里要包含能区分每条数据的唯一标识。

修复

把salt派生从只用固定字符串,改成固定字符串 + recordId + fieldName三者拼接:

function decodeSalt(recordId: string, fieldName: string): number[] {
  return utf8Encode('ArcaneKey-cloud-sync-v1:' + recordId + ':' + fieldName);
}

recordId(明文字段,加解密时都能拿到)和fieldName(字面量)参与派生,让每条记录的每个字段都有独立密钥流。同时保持确定性——同一账号对同一(recordId, fieldName)每次加密结果一致,供本地/云端按secret去重比对使用。

坑四:PBKDF2 10000次迭代,App被系统直接杀掉

现象

真机测试时,加一个口令后App卡住十几秒,提示"云端备份失败",然后App直接退出。

诊断

真机hilog里能直接看到系统看门狗的记录:

[Index] cloudBackup started, tokens: 2
   ← 主线程卡住
AppDfr: submit reportAppFreeze, name:THREAD_BLOCK_3S
AppDfr: submit reportAppFreeze, name:THREAD_BLOCK_6S
AppDfr: NotifyAppFault:THREAD_BLOCK_6S ... processExit:1   ← 系统判定主线程卡死,强制杀进程

根因

坑3的加密方案给secret/account/brand三个字段各跑一次PBKDF2(10000次迭代的纯JS HMAC-SHA1实现,没有硬件加速)。cloudBackup循环本地全部口令,每个口令3次密钥派生,同步跑在主线程上——JS单线程模型下async/await不会让纯CPU计算让出主线程,多个口令连续跑下来足以堵住主线程数秒,触发HarmonyOS的看门狗强杀进程。

PBKDF2从10000降到10,看似"不安全",实际是安全参数与使用场景不匹配。 10000次迭代的合理场景是"防止低熵、用户手输的密码被离线暴力破解"。这里派生密钥用的是账号UnionID,不是用户随手起的密码——真正需要的安全属性只是"确定性+每条记录每个字段唯一"(已由坑3的salt保证),迭代次数对这个目标没有实质帮助,只有CPU成本。而这个CPU成本在真机上高到会让看门狗直接杀掉App。

修复

把迭代次数从10000降到10:

// 迭代次数刻意远低于备份导出加密的 10000 次:这里派生密钥的目的是
// "确定性 + 每个 (record, field) 唯一",不是像备份密码那样需要抗离线暴力破解
// (unionId 不是用户手输的低熵密码)。
const CLOUD_SYNC_KDF_ROUNDS = 10;
const key = pbkdf2(unionId, salt, CLOUD_SYNC_KDF_ROUNDS, 32);

改完之后同样的操作耗时从卡死6+秒变成毫秒级,问题消失。

曾考虑换成3DES分组加密替代PBKDF2+XOR,评估后放弃:3DES已被NIST列为不建议新项目使用的过时算法,64位分组长度有已知弱点,换了性能和安全性都没有提升。真要升级,应该用鸿蒙自带的@ohos.security.cryptoFramework(原生AES,有硬件加速),但那是一次更大的独立改造,不适合当作应急修复。

坑五:schema校验规则反直觉——主键和非主键notNull对默认值的要求正好相反

现象

坑2加了复合主键之后,initCloudDB()本地直接报错:

[SyncUtil] initCloudDB failed: The primary key is forbidden to set default value.

后来在别的字段上调整时,又反过来报了另一个错:

[SyncUtil] initCloudDB failed: The field should be set default value while it is set not null.

诊断

这两条都是SDK本地校验抛出的(不经过网络),对照SDK源码逐字核实:

// SchemaValidators.ts
if (fieldInfo.isPrimaryKey && fieldInfo.hasDefaultValue) {
  throw ExceptionUtil.build('The primary key is forbidden to set default value.');
}
...
if (
  !fieldInfo.isPrimaryKey &&
  fieldInfo.isNotNull &&
  !fieldInfo.hasDefaultValue
) {
  throw ExceptionUtil.build('The field should be set default value while it is set not null.');
}

根因

两条规则合起来是:主键字段禁止设置默认值;非主键的notNull: true字段必须设置默认值(哪怕值是空字符串)——正好相反。改动主键结构时,容易只改了主键那个字段的默认值配置,忘了检查其他notNull字段是不是也要补默认值。

修复

对照上面两条规则逐字段核对:

fields: [
  { fieldName: 'id', ..., belongPrimaryKey: false, notNull: true, defaultValue: '' },      // notNull 非主键 → 必须有默认值
  { fieldName: 'brand', ..., belongPrimaryKey: false, notNull: true, defaultValue: '' },   // 同上
  { fieldName: 'account', ..., belongPrimaryKey: false, notNull: true, defaultValue: '' }, // 同上
  { fieldName: 'secret', ..., belongPrimaryKey: true, notNull: true },                     // 主键 → 禁止默认值
  { fieldName: 'deleted_at', ..., notNull: false },               // notNull:false → 不受约束
  { fieldName: 'userId', ..., belongPrimaryKey: true, notNull: true }                      // 主键 → 禁止默认值
]

这类本地校验错误信息本身已经把规则讲得很清楚了,直接去SDK源码里搜错误信息原文定位校验函数,比对着控制台截图去猜字段该不该有默认值快得多、准得多。

坑六:schemaVersion不同步——控制台删重建后版本号变了,客户端还写着旧的

现象

坑5修完、本地校验通过之后,实际写入数据时报了一个来自服务端的错误:

[SyncUtil] addToken failed: object type is not found.

卡了十几秒才返回,不是立刻报错。

诊断

对照SDK源码,"object type is not found"对应错误码1006001(INVALID_SCHEMA_NAME),是服务端返回的错误。进一步查schemaVersion的流转路径,发现它会被塞进请求上下文参与服务端的schema版本匹配。

根因

这个项目里,对象类型的复合主键是在AGC控制台里手工删除重建过一次的。控制台删除重建之后,服务端记录的对象类型版本状态发生了变化,但客户端代码里schemaVersion还写死在旧的数字上——版本号对不上,服务端直接认为"没有这个schema"。

排查走了一点弯路:先把schemaVersion改成一个新数字后,报错变成了object type field mismatched(方向对了,但字段还有出入);然后对着控制台截图猜字段属性,来回改了两轮都没对上。最终决定不再对着截图猜,而是把控制台对象类型整个删除重建,逐字段严格按客户端代码现有声明重新配置一遍,重建后控制台会显示一个新的版本号,把这个数字同步更新到代码里,问题才彻底解决。

修复

const db = cloud.database({
  objectTypeInfo: {
    schemaVersion: 6, // ← 必须跟AGC控制台里对象类型当前显示的版本号一致
    objectTypes: [ ... ],
  },
  zoneName: ZONE_NAME
});

客户端schema声明和控制台对象类型定义,是两份需要手动保持一致的独立状态,schemaVersion是它们之间唯一的匹配锚点。每次在控制台改了对象类型(尤其是删除重建),都要把控制台显示的新版本号同步更新到代码里。 如果报出"object type"相关的模糊错误、且已经改了两轮字段属性还没修对,别继续对着截图猜,直接控制台删除重建、逐字段严格对照代码配置一遍,比反复猜快得多。

附:两个连带发现

initCloudDB()返回值被忽略,产生误导性"成功"日志

initCloudDB()返回Promise<boolean>,失败时返回false——但调用方一直没检查这个返回值:

// 改之前
await initCloudDB();
console.log('[Index] cloud DB initialized');  // ← 哪怕上一行失败了,这条日志照样打印
await this.cloudRestore();

排查坑6的过程中,这个误导性日志一度让人以为失败发生在下游,而不是初始化本身。修复很直接:

const ready = await initCloudDB();
if (!ready) {
  console.error('[Index] cloud DB init failed, skipping restore/auto-sync this session');
  return;
}
console.log('[Index] cloud DB initialized');
await this.cloudRestore();

会员状态变化时,云同步初始化只在部分入口触发

跟上一篇同样的@Watch根治方案:

@State @Watch('onIsMemberChanged') isMember: boolean = false;

private onIsMemberChanged(): void {
  if (!this.isMember) return;
  this.initCloudSyncIfReady().catch((e: Error) => {
    console.error('[Index] onIsMemberChanged: initCloudSyncIfReady failed:', e.message);
  });
}

排查Checklist

☐ 换设备/重装后数据读写失效 → 先确认Cloud DB权限规则判定的"创建者"是不是绑定在设备本地的匿名登录UID上,而不是业务账号

☐ 放宽了Cloud DB权限规则 → 同步检查明文敏感字段是否需要加密、全局主键是否需要按用户维度做成复合主键——权限放宽是一整套连带影响

☐ 自己实现加密派生密钥 → 确认同一账号/同一密钥是否被复用到多条不同数据上(多次一密),salt里要包含能区分每条数据的唯一标识

☐ App操作耗时长甚至被系统杀掉 → 检查是否在主线程做了同步的重计算(哈希循环、加解密),先明确到底需要什么级别的安全强度,别无脑套用"密码存储"场景的高强度参数

☐ initCloudDB()/schema初始化本地报错(不经过网络)→ 去SDK源码里搜错误信息原文定位校验规则,别对着控制台猜;已验证的两条规则:主键字段禁止默认值,非主键notNull字段必须有默认值

☐ 云端写入/查询报"object type"相关服务端错误 → 检查schemaVersion是否跟控制台当前版本一致,尤其是控制台手工改过对象类型(特别是删除重建过)之后;两轮猜字段属性都没修对,就直接控制台删除重建、逐字段对照代码严格配置

方法论小结

① 报错信息(尤其是本地校验抛出来的)往往已经把规则讲清楚了,先去SDK源码里搜错误文本原文定位校验函数,不要凭经验猜

② 服务端返回的模糊错误("object type not found/mismatched")没法只靠客户端代码判断,该去控制台核实真实配置状态时就去核实,别在代码这一侧空转

③ 对着截图判断"某个输入框是空的"≠"没有配置这个属性",这类视觉信息本身就有歧义,能找到权威源码/文档验证的,优先用源码验证

④ 同一类"某处忘了做某件事"的症状在2处以上复现,说明是设计问题不是漏洞补丁问题,换成响应式/集中式写法,而不是继续挨个排查调用点

⑤ 性能问题(主线程卡死、App被杀)跟功能性bug要分开看,先用真机日志/看门狗记录确认是不是真的卡在计算上,再决定是降低算法强度还是换执行方式

上一篇我们聊了IAP恢复购买的三个暗坑,这一篇的六个坑更深层——涉及权限模型和密码学。你对接入AGC云数据库最大的顾虑是什么?留言聊聊。

上一篇:明明买了会员却恢复不了?鸿蒙IAP三个暗坑实录 → 关注系列不迷路

往期推荐

News Watch

明明买了会员却恢复不了?鸿蒙IAP三个暗坑排查实录

2026-07-03

我只说了一句话,AI就"猜"中了我的梦想书房——谷歌这步棋,比想象中更狠

2026-07-01

我用AI+下班时间开发的鸿蒙App,正式上架了:但这套隐藏流程,比写代码更让人崩溃

2026-06-26

一个@符号,正在悄悄颠覆职场——你还在假装看不见?

2026-06-24

最新文章

随机文章