32-Token明文存储-把凭据边界收到安全仓储里
第32篇Token 明文泄露排查用 Asset Store Kit 把登录凭据锁回安全边界摘要登录 Token 最危险的地方不是“它被存了一次”而是它会顺着页面状态、请求日志、异常上报和旧版本迁移扩散出去。实际项目里要先把泄露链路画出来再把凭据收进唯一的TokenVault页面只拿会话摘要请求前短时读取刷新和退出登录都走同一个边界发布前用搜索和重启验证证明明文没有残留。一次联调里测试同学为了排查接口 401 导出了 hilog。我们本来只想看请求耗时结果在日志里看到了一整段Authorization: Bearer ...。继续查下去普通 Preferences 里有session_tokenAppStorage里有accessToken崩溃上报的扩展字段里还带了登录响应体。这个问题不能靠“把日志删掉”结束因为真正的问题是Token 没有 owner谁想用都能拿谁出错都可能带出去。这篇文章把修复拆成四件事从日志、运行态、普通存储和异常上报还原 Token 泄露链路。用TokenVault收口凭据读写底层再接 Asset Store Kit 等安全存储能力。把页面会话状态和真实凭据拆开避免AppStorage变成 Token 中转站。覆盖登录、刷新、迁移、退出和发布前扫描防止只修一条路径。先还原一次真实泄露链路安全问题最怕只说原则。排查时我会先按“Token 从哪里来、被谁复制、最后从哪里出去”画链路。链路位置当时看到的现象风险点修复方向登录回调LoginPage直接拿到accessToken页面截图、断点、路由参数都可能接触凭据登录服务只返回会话摘要普通存储Preferences 里有session_token误备份、误导出、旧版本迁移残留迁到安全仓储适配器全局状态AppStorage里保存accessToken跨页面扩散任意页面都能读只保存signedIn、用户昵称等摘要请求日志打印完整Authorizationhilog、远程日志和截图泄露日志工具统一脱敏异常上报上报登录响应体Token 离开设备进入平台异常上下文白名单化这张表的作用不是写文档而是决定代码边界。凡是“为了方便”复制 Token 的地方都要从业务代码里拆出去。先定边界Token 只能有一个 owner改造前很多模块都觉得自己“只是临时用一下 Token”。最后的结果是页面、请求层、缓存层、日志层都有副本。更稳的边界是模块可以知道什么不应该知道什么Page / Component是否登录、头像、昵称、用户 IDaccess token、refresh tokenSessionStore会话摘要、加载状态、过期提示完整凭据字符串LoginService登录结果、保存凭据动作安全存储实现细节ApiClient临时 Authorization header长期缓存的 Token 字段Logger / ReportertraceId、接口名、错误码Bearer、Cookie、登录响应体TokenVaultToken 的读、写、删页面展示逻辑只要这个表定下来后面的代码就不会摇摆页面不碰 Token请求不保存 Token日志不透传 Token只有TokenVault管真实凭据。用 TokenVault 表达唯一读写入口先不要急着在页面里调用安全存储 API。先抽一个足够小的接口让业务层只知道“保存、读取、删除”。exportinterfaceTokenPair{accessToken:stringrefreshToken:stringexpiresAt:number}exportinterfaceTokenVault{save(pair:TokenPair):Promisevoidload():PromiseTokenPair|nullclear():Promisevoid}exportfunctionisTokenExpired(pair:TokenPair,skewMs:number60_000):boolean{returnpair.expiresAtDate.now()skewMs}这段接口只负责凭据生命周期不负责页面跳转也不负责弹登录框。这样做有两个好处一是测试环境可以替换成内存实现二是后面从 Preferences 迁到 Asset Store Kit 时不需要改页面。Asset Store Kit 适配器只藏在基础设施层HarmonyOS 的 Asset Store Kit 面向短敏感数据的安全存储。项目里不要让各业务模块直接散落asset.add()、asset.query()、asset.remove()而是集中在一个适配器里。import{asset}fromkit.AssetStoreKitimport{util}fromkit.ArkTSconstTOKEN_ALIASauth.token.pairfunctiontoBytes(value:string):Uint8Array{constencodernewutil.TextEncoder()returnencoder.encodeInto(value)}functionfromBytes(value:Uint8Array):string{constdecodernewutil.TextDecoder()returndecoder.decodeWithStream(value)}exportclassAssetStoreTokenVaultimplementsTokenVault{asyncsave(pair:TokenPair):Promisevoid{awaitthis.clear()constattributes:asset.AssetMapnewMap()attributes.set(asset.Tag.ALIAS,toBytes(TOKEN_ALIAS))attributes.set(asset.Tag.SECRET,toBytes(JSON.stringify(pair)))attributes.set(asset.Tag.ACCESSIBILITY,asset.Accessibility.DEVICE_FIRST_UNLOCKED)attributes.set(asset.Tag.DATA_LABEL_NORMAL_1,toBytes(login-token))awaitasset.add(attributes)}asyncload():PromiseTokenPair|null{constquery:asset.AssetMapnewMap()query.set(asset.Tag.ALIAS,toBytes(TOKEN_ALIAS))query.set(asset.Tag.RETURN_TYPE,asset.ReturnType.ALL)constresultawaitasset.query(query)constsecretresult[0]?.get(asset.Tag.SECRET)asUint8Array|undefinedif(!secret){returnnull}returnJSON.parse(fromBytes(secret))asTokenPair}asyncclear():Promisevoid{constquery:asset.AssetMapnewMap()query.set(asset.Tag.ALIAS,toBytes(TOKEN_ALIAS))awaitasset.remove(query).catch(()undefined)}}这里有三个关键点。第一ALIAS要稳定方便覆盖、查询和删除同一份资产。第二SECRET才放真实 Token不要把 Token 塞到普通附属信息里。第三读写异常不要向日志里拼接 Token 内容最多记录错误码和别名。登录服务保存凭据但只把摘要交给页面登录成功后页面真正需要的是“用户已经登录可以展示昵称和头像”。页面不需要 Token。exportinterfaceLoginResult{userId:stringdisplayName:stringavatarUrl:stringaccessToken:stringrefreshToken:stringexpiresAt:number}exportinterfaceSessionSnapshot{signedIn:booleanuserId:stringdisplayName:stringavatarUrl:string}exportfunctioncreateSignedOutSession():SessionSnapshot{return{signedIn:false,userId:,displayName:,avatarUrl:}}exportclassLoginService{constructor(privatereadonlytokenVault:TokenVault){}asyncfinishLogin(result:LoginResult):PromiseSessionSnapshot{awaitthis.tokenVault.save({accessToken:result.accessToken,refreshToken:result.refreshToken,expiresAt:result.expiresAt})return{signedIn:true,userId:result.userId,displayName:result.displayName,avatarUrl:result.avatarUrl}}}这段代码把“凭据保存”和“页面展示”拆开了。以后我的页、设置页、首页只消费SessionSnapshot没有理由读取TokenPair。AppStorage 只能保存会话摘要很多明文泄露不是从磁盘开始而是从运行态开始。为了让多个页面都能判断登录状态有人会把accessToken放进AppStorage。这会让所有页面都有读取凭据的机会。constSESSION_KEYsession_snapshotexportfunctionhydrateSessionSnapshot():void{AppStorage.setOrCreateSessionSnapshot(SESSION_KEY,createSignedOutSession())}exportfunctionpublishSignedInSession(snapshot:SessionSnapshot):void{AppStorage.setOrCreateSessionSnapshot(SESSION_KEY,snapshot)}exportfunctionpublishSignedOutSession():void{AppStorage.setOrCreateSessionSnapshot(SESSION_KEY,createSignedOutSession())}如果页面用StorageLink读取会话状态要确保应用启动时先水合默认值。这样页面首次渲染拿到的是稳定的摘要对象不会因为状态未初始化而在 Builder 里访问到undefined。请求层短时读取用完就丢请求层需要 Token但不应该把 Token 变成长期字段。更好的方式是在发送前短时读取组完 header 后就不再保存。exportclassAuthorizationHeaderProvider{constructor(privatereadonlytokenVault:TokenVault,privatereadonlytokenRefreshService:TokenRefreshService){}asyncbuild():PromiseRecordstring,string{awaitthis.tokenRefreshService.refreshIfNeeded()constpairawaitthis.tokenVault.load()if(pairnull||isTokenExpired(pair,0)){return{}}return{Authorization:Bearer${pair.accessToken}}}}这里不要把pair缓存在单例属性里也不要把 header 透传给日志工具。请求完成后业务代码只应该得到响应结果、状态码和 traceId。刷新 Token 也要走同一条路很多项目登录时收口了刷新时又绕回普通存储。刷新逻辑必须和登录逻辑一样仍然写回TokenVault。exportinterfaceAuthRemote{refresh(refreshToken:string):PromiseTokenPair}exportclassTokenRefreshService{privaterefreshing:Promisevoid|nullnullconstructor(privatereadonlytokenVault:TokenVault,privatereadonlyauthRemote:AuthRemote){}asyncrefreshIfNeeded():Promisevoid{constcurrentawaitthis.tokenVault.load()if(currentnull||!isTokenExpired(current)){return}if(this.refreshingnull){this.refreshingthis.refreshOnce(current.refreshToken).finally((){this.refreshingnull})}awaitthis.refreshing}privateasyncrefreshOnce(refreshToken:string):Promisevoid{constnextawaitthis.authRemote.refresh(refreshToken)awaitthis.tokenVault.save(next)}}refreshing的作用是防止多个接口同时发现过期后一起刷新导致后写入的旧结果覆盖新结果。更重要的是新 Token 仍然只进入TokenVault不会回到页面状态。旧版本明文迁移要宁可重新登录如果线上版本已经把 Token 写进普通 Preferences升级时会遇到迁移问题。迁移时的底线是读取旧值、写入新仓储、删除旧值全程不打印。exportinterfaceLegacyCredentialStore{getString(key:string):Promisestringdelete(key:string):Promisevoid}exportclassLegacyTokenMigrator{constructor(privatereadonlylegacyStore:LegacyCredentialStore,privatereadonlytokenVault:TokenVault){}asyncmigrate():Promisevoid{constaccessTokenawaitthis.legacyStore.getString(session_token)constrefreshTokenawaitthis.legacyStore.getString(refresh_token)if(accessToken.length0||refreshToken.length0){awaitthis.dropLegacyKeys()return}awaitthis.tokenVault.save({accessToken,refreshToken,expiresAt:Date.now()10*60*1000})awaitthis.dropLegacyKeys()}privateasyncdropLegacyKeys():Promisevoid{awaitthis.legacyStore.delete(session_token)awaitthis.legacyStore.delete(refresh_token)}}如果迁移失败不要为了“用户无感”继续保留明文。安全类改造宁可让用户重新登录也不要把旧风险带到新版本。日志和异常上报必须做白名单脱敏不是在每个调用点靠自觉而是日志工具统一处理。尤其是请求失败、登录失败和异常上报这几个地方最容易把完整上下文带出去。constSECRET_FIELD_PATTERN/(authorization|accessToken|refreshToken|session_token|cookie)/iexportfunctionsanitizeLogFields(fields:Recordstring,string):Recordstring,string{constsafe:Recordstring,string{}Object.entries(fields).forEach(([key,value]){safe[key]SECRET_FIELD_PATTERN.test(key)?maskCredential(value):value})returnsafe}exportfunctionmaskCredential(value:string):string{if(value.length8){return[masked]}return${value.slice(0,4)}****${value.slice(value.length-4)}}异常上报建议采用白名单接口名、错误码、traceId、页面名可以上报header、cookie、登录响应体默认不能上报。这样即使新同学接入日志也不容易把 Token 带出去。退出登录要清四个位置退出登录不能只把页面状态改成未登录。真正要清的是安全仓储、页面会话摘要、请求队列和旧明文键。exportclassLogoutService{constructor(privatereadonlytokenVault:TokenVault,privatereadonlyapiQueue:ApiQueue,privatereadonlylegacyStore:LegacyCredentialStore){}asynclogout():Promisevoid{this.apiQueue.cancelUnauthorizedRequests()awaitthis.tokenVault.clear()awaitthis.legacyStore.delete(session_token)awaitthis.legacyStore.delete(refresh_token)publishSignedOutSession()}}这一步要做重启验证退出登录后杀掉应用再打开如果仍然能发出带 Authorization 的请求说明还有别的缓存路径没有清干净。发布前用搜索证明没有明文路径人工看代码很容易漏。发布前直接用搜索把风险字段扫一遍。rg-naccessToken|refreshToken|Authorization|Bearer|session_token|refresh_tokenentry features common rg-nAppStorage.*token|Preferences.*token|hilog.*token|console.*token|logger.*Authorizationentry features common rg-ncrash|report|track|evententry features common命中后按下面的规则处理命中类型能不能保留处理方式DTO 字段声明可以确认只在登录结果和远端模型中出现TokenVault内部读写可以确认底层是安全仓储适配器AppStorage/ 页面字段不应该改成SessionSnapshot日志、埋点、异常上报不应该改成脱敏或白名单旧 Preferences key仅迁移期保留迁移后删除后续版本移除搜索结果要作为交付证据保存下来。安全问题不能只说“应该没有”要能指出哪些命中是合法边界哪些已经删除。验证清单验证项操作预期结果登录后普通存储搜索旧 key 和 Token 字段Preferences 不再出现完整 Token页面运行态检查AppStorage和页面状态只有SessionSnapshot没有凭据请求发送抓请求构造前后日志能发 Authorization但日志不打印完整值Token 刷新调短过期时间并并发请求只刷新一次新 Token 写回TokenVault退出登录退出后杀进程重启不恢复旧账号不再发带 Token 请求异常上报人为制造 401 和崩溃上报内容只有错误码、traceId、页面名如果只能做一次回归优先测“登录 - 请求 - 刷新 - 退出 - 重启”。这条链路能覆盖大部分 Token 残留问题。小结不要让 Token 在项目里旅行Token 明文存储的修复不是换一个存储 API 就结束。真正要改的是所有权真实凭据只能在TokenVault里出现页面拿会话摘要请求前短时读取刷新和迁移不能绕路日志和异常上报只走白名单。这样处理后Token 不会在页面、运行态、普通存储和调试链路里到处旅行排查和交付也有明确证据。