HarmonyOS AssetStoreKit:短秘密落在哪里,访问边界才清楚【共创稿事节】
一个应用只需要保存一段很短的凭据,却可能为此引入文件加密、密钥生成、密钥保存和轮换策略。反过来,如果把它直接放进普通设置,又把“能保存”误当成“适合保存”。这个选择的难点不在数据量,而在访问条件、身份归属和退出账号之后该清理什么。本文把范围收紧到应用自己的短秘密,使用系统安全资产服务建立一个可检查的边界。

先确定资产是什么,再选择存储接口
工程 SecretPocket 的页面叫“凭据口袋”,提供写入、查询是否存在和删除三个动作。输入内容只用于本机验证,不承担真实服务登录。界面不会展示保存后的秘密,也不会把内容输出到日志。查询只请求资产属性,因此“已经保存”与“读取到了明文”被明确分开。
API 12 的 Stage 工程可以从 AssetStoreKit 引入安全资产模块,基础服务接口从 API 11 提供。官方资产服务参考将它定位为短敏感数据的存储与管理。本文进一步把输入限制为不超过五百一十二个字节,让示例始终处于较小的数据范围,不把完整文档或大型附件装进这一接口。
安全资产与应用数据库解决的问题不同。数据库能够组织大量记录、筛选和关联,安全资产强调特定数据的访问控制。对一段短凭据,选择后者可以减少应用自行管理底层加密材料的职责;但它并不会替应用判断凭据是否过期、账号是否被注销,也不会代替服务器的身份验证。
本例固定一个别名,表示这个功能持有的一项资产。别名不是秘密,而是稳定定位信息。账号切换场景应将别名设计成可区分账号且不直接泄露敏感身份的形式,并为每个账号规定清理行为。这里不增加多账号模型,避免把一个存储示例误画成已经完成账号隔离的产品。
访问条件选择设备当前处于解锁状态。它与“设备启动后曾经解锁过一次”并不相同,后者在再次锁屏后仍可能满足条件。同时,官方说明设备没有设置锁屏密码时,相应选项会退化为开机可访问的行为,因此产品不能把这一配置文案写成“每次都经过指纹验证”。
本文没有设置要求持久保存的标签,也没有启用每次取用时的用户认证流程,因此不申请持久资产权限。若产品要求卸载后保留数据,或要求取用前进行生物识别,需要另行核对权限和查询前后处理协议。将暂未接入的能力与当前代码分开,能避免出现看似安全却没有实现的按钮。
三个动作使用同一条精确查询条件
新增 common/SecretStore.ets,集中维护别名和资产调用。写入函数接收文本并编码,大小检查针对实际字节而不是字符数量。中文、多字节符号与普通英文占用的字节数不同,如果界面只限制字符数,不能据此证明已经满足底层存储限制。
// entry/src/main/ets/common/SecretStore.ets
import { asset } from '@kit.AssetStoreKit';
import { util } from '@kit.ArkTS';
import { BusinessError } from '@kit.BasicServicesKit';
const ALIAS: string = 'secret-pocket.local.v1';
function encode(text: string): Uint8Array {
return new util.TextEncoder().encodeInto(text);
}
function byAlias(): asset.AssetMap {
const query: asset.AssetMap = new Map();
query.set(asset.Tag.ALIAS, encode(ALIAS));
return query;
}
export async function saveSecret(text: string): Promise<void> {
const bytes = encode(text);
if (bytes.byteLength === 0 || bytes.byteLength > 512) {
throw new Error('短语须为 1—512 个 UTF-8 字节');
}
const values: asset.AssetMap = new Map();
values.set(asset.Tag.ALIAS, encode(ALIAS));
values.set(asset.Tag.SECRET, bytes);
values.set(asset.Tag.ACCESSIBILITY, asset.Accessibility.DEVICE_UNLOCKED);
try {
await asset.add(values);
} finally {
bytes.fill(0);
values.clear();
}
}
export async function exists(): Promise<boolean> {
const query = byAlias();
query.set(asset.Tag.RETURN_TYPE, asset.ReturnType.ATTRIBUTES);
try {
const result = await asset.query(query);
return result.length > 0;
} catch (error) {
if ((error as BusinessError).code === 24000002) { return false; }
throw error;
}
}
export async function removeSecret(): Promise<void> {
try {
await asset.remove(byAlias());
} catch (error) {
if ((error as BusinessError).code !== 24000002) { throw error; }
}
}
写入没有先删除旧资产。若同一别名已存在,调用会报告重复,而页面要求用户明确删除后再写入。这样虽然多一次操作,却不会因新内容无效或写入失败而先丢失旧内容。真正的凭据更新应该使用相应更新接口设计完整策略,不能拿“先删再加”伪装成原子覆盖。
查询和删除都复用精确别名条件。安全资产支持按属性匹配多条记录,空条件或过宽的标签条件可能影响超出当前功能的数据。把条件构造收在模块内部,比让每个页面自行拼装更容易审查;后来增加新的资产时,也不会因为某个清理按钮省略条件而一并删除。
未找到资产被转换为一个正常的不存在结果,其他错误继续向上抛出。服务不可用、访问条件不满足或数据损坏,都不应该显示成“你还没保存”。这种区分会直接影响用户是否再次输入秘密,以及应用是否错误地尝试覆盖已经存在的记录。

临时字节在操作结束后清零,映射也释放引用。这只是在应用可控范围内缩短额外副本的寿命,不能保证所有运行时字符串或底层缓冲都已经被物理擦除。输入框里的字符串还有自己的生命周期,因此页面在写入成功后另行清空它,不把数组清零夸大成完整内存擦除证明。
页面只展示操作结果,不展示资产正文
页面的忙碌状态阻止重复并发操作,同一时刻只进行一种资产动作。没有这道限制,用户可能在写入尚未结束时又点击删除,最后屏幕显示的状态就只取决于两个异步结果谁先返回,而不再对应一个可理解的操作过程。
// entry/src/main/ets/pages/Index.ets
import { BusinessError } from '@kit.BasicServicesKit';
import { saveSecret, exists, removeSecret } from '../common/SecretStore';
@Entry
@Component
struct Index {
private alive: boolean = true;
@State text: string = '';
@State busy: boolean = false;
@State status: string = '尚未查询';
aboutToDisappear(): void { this.alive = false; }
private async run(action: number): Promise<void> {
if (this.busy) { return; }
this.busy = true;
try {
let message: string = '';
if (action === 0) {
await saveSecret(this.text);
message = '已写入,输入已清空';
if (this.alive) { this.text = ''; }
} else if (action === 1) {
message = await exists() ? '资产存在,未读取正文' : '尚无这项资产';
} else {
await removeSecret();
message = '这项资产已清除';
}
if (this.alive) { this.status = message; }
} catch (error) {
if (this.alive) {
const e = error as BusinessError;
this.status = e.code === 24000003 ? '资产已存在,请先明确删除'
: (e.code ? `操作失败:${e.code}` : e.message);
}
} finally {
if (this.alive) { this.busy = false; }
}
}
build() {
Column({ space: 16 }) {
Text('凭据口袋').fontSize(28).fontWeight(FontWeight.Bold)
Text('保存短秘密,查询只取属性').fontSize(14).fontColor('#64748B')
TextInput({ text: this.text, placeholder: '输入本机测试短语' })
.type(InputType.Password).height(52)
.onChange((value: string) => { this.text = value; })
Text('访问条件:设备当前解锁').fontSize(16)
Button('写入资产').width('100%').height(44).enabled(!this.busy)
.backgroundColor('#7C3AED').onClick(() => { this.run(0); })
Button('查询是否存在').width('100%').height(44).enabled(!this.busy)
.backgroundColor('#7C3AED').onClick(() => { this.run(1); })
Button('删除这项资产').width('100%').height(44).enabled(!this.busy)
.backgroundColor('#64748B').onClick(() => { this.run(2); })
Text(this.status).fontSize(17).fontColor('#6D28D9')
Text('页面不展示已保存内容').fontSize(13).fontColor('#64748B')
}.width('100%').height('100%').padding(24).backgroundColor('#FAF7FF')
}
}
本页没有额外路由,销毁时只关闭普通存活标记,已发出的系统调用仍会自行结束。结束后的结果不再写回已销毁组件,资源模块则继续执行清理。销毁回调不修改状态变量,也不把一次页面离开解释为删除长期资产;用户保存的数据与界面实例是两种寿命。
三个按钮分别说明动作范围。查询按钮没有使用“解密查看”,删除按钮说明删除的是这一项资产,状态文字区分未查询、不存在、存在和操作失败。对于安全相关功能,这些文字不是装饰,它们约束了调用者和用户对操作结果的解释。

在产品中,删除通常还需要适当的二次确认,尤其当它代表退出账号或移除本机凭据时。本例只操作固定测试别名,保留直接按钮便于验证。将来接入账号系统,应把确认结果传给这个精确删除函数,而不是让函数弹出依赖具体页面的对话框。
用访问条件和失败原因评审方案
验证先从最小数据开始:输入一段测试短语,写入后确认输入清空,再查询存在状态,随后删除并再次查询。第二次写入同一别名,应进入已存在提示;它不能覆盖成功,也不能让旧资产消失。这个序列验证的是生命周期与冲突策略,并不依赖展示任何真实秘密。
接着检查输入边界。空内容应在进入服务前被拒绝;包含中文时,按编码后的字节数检查;超过本例上限时不能先发出写入再等服务报错。页面密码样式只影响视觉呈现,不会自动把输入变成加密数据,也不能作为日志过滤或持久化保护的替代。

锁屏条件要在符合能力条件的设备上单独验证。设置锁屏密码后检查解锁与锁定场景,并记录实际返回错误;无锁屏密码的设备应按照官方退化条件解释结果。属性查询成功不等于秘密正文在该状态可读取;真正取用正文的路径必须单独核对访问条件。把不同设备的行为混在一起,很容易把访问控制差异误判为偶发故障。
日志应记录动作类型和错误码,避免打印整个属性映射、输入内容或查询结果。甚至别名也应根据它是否包含账号信息决定能否输出。诊断信息越贴近完整对象,越需要在写日志之前筛选字段;等日志进入收集系统以后再删除,往往已经太迟。
资产存在也不表示服务端仍然接受它。登录令牌可能被服务器撤销,短凭据可能超过业务有效期。应用应在认证层处理这些结果,按账号策略更新或清理资产,而不是让存储模块根据一个网络错误自行决定删除。存储成功、可被取用和业务有效,是三个不同判断。
需要多端同步时,也不能只因为本地保存成功就认为其他设备已有副本。本文没有启用任何同步或共享属性,图中只画当前应用与本机系统服务。新增跨设备需求会改变访问策略、冲突处理和撤销语义,应作为独立的架构决策,而不是在存储函数里偷偷增加一个标签。
资产标签的类型也是评审重点。别名和秘密传入字节数组,访问级别使用对应枚举值,查询返回类型使用规定的标签。不要为了方便把所有配置先转成字符串再塞入映射;类型虽然看起来都能放进一个容器,服务仍然按照每个标签的约定解释它们。
异常处理还要考虑用户接下来的动作。重复资产应引导用户决定是否删除,访问条件不满足应提示检查设备状态,系统服务错误则应保留原数据并允许稍后重试。这些错误都显示成一个“失败”数字虽然便于实现,却会把本来不同的恢复路径混在一起,导致用户反复提交相同请求。
固定别名末尾的版本号描述的是本功能的数据约定,不是系统版本。将来调整格式时,应先确定旧资产能否继续使用、是否需要迁移及失败后如何恢复。直接修改别名虽然可以避免重复错误,却可能把旧凭据遗留在设备上;因此别名变更需要配套清理计划,不能充当简单的更新手段。
页面忙碌标记能够防止同一实例重复点击,但不等价于跨页面或跨进程的互斥锁。如果应用以后存在多个写入入口,需要把写入责任集中到更高一级的服务,并以资产接口的冲突结果作为最终依据。当前单页示例有意保持一个入口,让读者先看清系统返回结果如何影响界面。
还应为调试环境准备明确的测试资产清理步骤。验证结束后使用本页删除按钮,再查询确认不存在;如果更换了测试别名,也要逐个清理旧值。这样可以避免下一次启动时遇到已存在错误,被误判成新增功能失效。清理使用同一别名约束,也能证明删除范围没有扩大到其他功能。
这个方案适合需要系统管理短敏感数据、又能清楚界定访问条件的功能。它仍要求应用控制输入副本、精确查询范围和账号退出流程。技术选型的收益在于减少一部分底层实现责任,同时把剩余责任说清楚;只有三者一起成立,一个“保存成功”才有可以审查的含义。
参考资料
更多推荐



所有评论(0)