32-Token明文存储-把凭据边界收到安全仓储里

第32篇|Token 明文泄露排查:用 Asset Store Kit 把登录凭据锁回安全边界

摘要:登录 Token 最危险的地方,不是"它被存了一次",而是它会顺着页面状态、请求日志、异常上报和旧版本迁移扩散出去。实际项目里要先把泄露链路画出来,再把凭据收进唯一的 TokenVault:页面只拿会话摘要,请求前短时读取,刷新和退出登录都走同一个边界,发布前用搜索和重启验证证明明文没有残留。

一次联调里,测试同学为了排查接口 401 导出了 hilog。我们本来只想看请求耗时,结果在日志里看到了一整段 Authorization: Bearer ...。继续查下去,普通 Preferences 里有 session_tokenAppStorage 里有 accessToken,崩溃上报的扩展字段里还带了登录响应体。这个问题不能靠"把日志删掉"结束,因为真正的问题是:Token 没有 owner,谁想用都能拿,谁出错都可能带出去。

这篇文章把修复拆成四件事:

  1. 从日志、运行态、普通存储和异常上报还原 Token 泄露链路。
  2. TokenVault 收口凭据读写,底层再接 Asset Store Kit 等安全存储能力。
  3. 把页面会话状态和真实凭据拆开,避免 AppStorage 变成 Token 中转站。
  4. 覆盖登录、刷新、迁移、退出和发布前扫描,防止只修一条路径。

先还原一次真实泄露链路

安全问题最怕只说原则。排查时我会先按"Token 从哪里来、被谁复制、最后从哪里出去"画链路。

链路位置 当时看到的现象 风险点 修复方向
登录回调 LoginPage 直接拿到 accessToken 页面截图、断点、路由参数都可能接触凭据 登录服务只返回会话摘要
普通存储 Preferences 里有 session_token 误备份、误导出、旧版本迁移残留 迁到安全仓储适配器
全局状态 AppStorage 里保存 accessToken 跨页面扩散,任意页面都能读 只保存 signedIn、用户昵称等摘要
请求日志 打印完整 Authorization hilog、远程日志和截图泄露 日志工具统一脱敏
异常上报 上报登录响应体 Token 离开设备进入平台 异常上下文白名单化

这张表的作用不是写文档,而是决定代码边界。凡是"为了方便"复制 Token 的地方,都要从业务代码里拆出去。

先定边界:Token 只能有一个 owner

改造前,很多模块都觉得自己"只是临时用一下 Token"。最后的结果是页面、请求层、缓存层、日志层都有副本。

更稳的边界是:

模块 可以知道什么 不应该知道什么
Page / Component 是否登录、头像、昵称、用户 ID access token、refresh token
SessionStore 会话摘要、加载状态、过期提示 完整凭据字符串
LoginService 登录结果、保存凭据动作 安全存储实现细节
ApiClient 临时 Authorization header 长期缓存的 Token 字段
Logger / Reporter traceId、接口名、错误码 Bearer、Cookie、登录响应体
TokenVault Token 的读、写、删 页面展示逻辑

只要这个表定下来,后面的代码就不会摇摆:页面不碰 Token,请求不保存 Token,日志不透传 Token,只有 TokenVault 管真实凭据。

用 TokenVault 表达唯一读写入口

先不要急着在页面里调用安全存储 API。先抽一个足够小的接口,让业务层只知道"保存、读取、删除"。

ts 复制代码
export interface TokenPair {
  accessToken: string
  refreshToken: string
  expiresAt: number
}

export interface TokenVault {
  save(pair: TokenPair): Promise<void>
  load(): Promise<TokenPair | null>
  clear(): Promise<void>
}

export function isTokenExpired(pair: TokenPair, skewMs: number = 60_000): boolean {
  return pair.expiresAt <= Date.now() + skewMs
}

这段接口只负责凭据生命周期,不负责页面跳转,也不负责弹登录框。这样做有两个好处:一是测试环境可以替换成内存实现,二是后面从 Preferences 迁到 Asset Store Kit 时,不需要改页面。

Asset Store Kit 适配器只藏在基础设施层

HarmonyOS 的 Asset Store Kit 面向短敏感数据的安全存储。项目里不要让各业务模块直接散落 asset.add()asset.query()asset.remove(),而是集中在一个适配器里。

ts 复制代码
import { asset } from '@kit.AssetStoreKit'
import { util } from '@kit.ArkTS'

const TOKEN_ALIAS = 'auth.token.pair'

function toBytes(value: string): Uint8Array {
  const encoder = new util.TextEncoder()
  return encoder.encodeInto(value)
}

function fromBytes(value: Uint8Array): string {
  const decoder = new util.TextDecoder()
  return decoder.decodeWithStream(value)
}

export class AssetStoreTokenVault implements TokenVault {
  async save(pair: TokenPair): Promise<void> {
    await this.clear()

    const attributes: asset.AssetMap = new Map()
    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'))

    await asset.add(attributes)
  }

  async load(): Promise<TokenPair | null> {
    const query: asset.AssetMap = new Map()
    query.set(asset.Tag.ALIAS, toBytes(TOKEN_ALIAS))
    query.set(asset.Tag.RETURN_TYPE, asset.ReturnType.ALL)

    const result = await asset.query(query)
    const secret = result[0]?.get(asset.Tag.SECRET) as Uint8Array | undefined
    if (!secret) {
      return null
    }

    return JSON.parse(fromBytes(secret)) as TokenPair
  }

  async clear(): Promise<void> {
    const query: asset.AssetMap = new Map()
    query.set(asset.Tag.ALIAS, toBytes(TOKEN_ALIAS))
    await asset.remove(query).catch(() => undefined)
  }
}

这里有三个关键点。第一,ALIAS 要稳定,方便覆盖、查询和删除同一份资产。第二,SECRET 才放真实 Token,不要把 Token 塞到普通附属信息里。第三,读写异常不要向日志里拼接 Token 内容,最多记录错误码和别名。

登录服务保存凭据,但只把摘要交给页面

登录成功后,页面真正需要的是"用户已经登录,可以展示昵称和头像"。页面不需要 Token。

ts 复制代码
export interface LoginResult {
  userId: string
  displayName: string
  avatarUrl: string
  accessToken: string
  refreshToken: string
  expiresAt: number
}

export interface SessionSnapshot {
  signedIn: boolean
  userId: string
  displayName: string
  avatarUrl: string
}

export function createSignedOutSession(): SessionSnapshot {
  return { signedIn: false, userId: '', displayName: '', avatarUrl: '' }
}

export class LoginService {
  constructor(private readonly tokenVault: TokenVault) {}

  async finishLogin(result: LoginResult): Promise<SessionSnapshot> {
    await this.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。这会让所有页面都有读取凭据的机会。

ts 复制代码
const SESSION_KEY = 'session_snapshot'

export function hydrateSessionSnapshot(): void {
  AppStorage.setOrCreate<SessionSnapshot>(SESSION_KEY, createSignedOutSession())
}

export function publishSignedInSession(snapshot: SessionSnapshot): void {
  AppStorage.setOrCreate<SessionSnapshot>(SESSION_KEY, snapshot)
}

export function publishSignedOutSession(): void {
  AppStorage.setOrCreate<SessionSnapshot>(SESSION_KEY, createSignedOutSession())
}

如果页面用 @StorageLink 读取会话状态,要确保应用启动时先水合默认值。这样页面首次渲染拿到的是稳定的摘要对象,不会因为状态未初始化而在 Builder 里访问到 undefined

请求层短时读取,用完就丢

请求层需要 Token,但不应该把 Token 变成长期字段。更好的方式是在发送前短时读取,组完 header 后就不再保存。

ts 复制代码
export class AuthorizationHeaderProvider {
  constructor(
    private readonly tokenVault: TokenVault,
    private readonly tokenRefreshService: TokenRefreshService
  ) {}

  async build(): Promise<Record<string, string>> {
    await this.tokenRefreshService.refreshIfNeeded()

    const pair = await this.tokenVault.load()
    if (pair === null || isTokenExpired(pair, 0)) {
      return {}
    }

    return { Authorization: `Bearer ${pair.accessToken}` }
  }
}

这里不要把 pair 缓存在单例属性里,也不要把 header 透传给日志工具。请求完成后,业务代码只应该得到响应结果、状态码和 traceId。

刷新 Token 也要走同一条路

很多项目登录时收口了,刷新时又绕回普通存储。刷新逻辑必须和登录逻辑一样,仍然写回 TokenVault

ts 复制代码
export interface AuthRemote {
  refresh(refreshToken: string): Promise<TokenPair>
}

export class TokenRefreshService {
  private refreshing: Promise<void> | null = null

  constructor(
    private readonly tokenVault: TokenVault,
    private readonly authRemote: AuthRemote
  ) {}

  async refreshIfNeeded(): Promise<void> {
    const current = await this.tokenVault.load()
    if (current === null || !isTokenExpired(current)) {
      return
    }

    if (this.refreshing === null) {
      this.refreshing = this.refreshOnce(current.refreshToken)
        .finally(() => {
          this.refreshing = null
        })
    }

    await this.refreshing
  }

  private async refreshOnce(refreshToken: string): Promise<void> {
    const next = await this.authRemote.refresh(refreshToken)
    await this.tokenVault.save(next)
  }
}

refreshing 的作用是防止多个接口同时发现过期后一起刷新,导致后写入的旧结果覆盖新结果。更重要的是,新 Token 仍然只进入 TokenVault,不会回到页面状态。

旧版本明文迁移要宁可重新登录

如果线上版本已经把 Token 写进普通 Preferences,升级时会遇到迁移问题。迁移时的底线是:读取旧值、写入新仓储、删除旧值,全程不打印。

ts 复制代码
export interface LegacyCredentialStore {
  getString(key: string): Promise<string>
  delete(key: string): Promise<void>
}

export class LegacyTokenMigrator {
  constructor(
    private readonly legacyStore: LegacyCredentialStore,
    private readonly tokenVault: TokenVault
  ) {}

  async migrate(): Promise<void> {
    const accessToken = await this.legacyStore.getString('session_token')
    const refreshToken = await this.legacyStore.getString('refresh_token')

    if (accessToken.length === 0 || refreshToken.length === 0) {
      await this.dropLegacyKeys()
      return
    }

    await this.tokenVault.save({
      accessToken,
      refreshToken,
      expiresAt: Date.now() + 10 * 60 * 1000
    })
    await this.dropLegacyKeys()
  }

  private async dropLegacyKeys(): Promise<void> {
    await this.legacyStore.delete('session_token')
    await this.legacyStore.delete('refresh_token')
  }
}

如果迁移失败,不要为了"用户无感"继续保留明文。安全类改造宁可让用户重新登录,也不要把旧风险带到新版本。

日志和异常上报必须做白名单

脱敏不是在每个调用点靠自觉,而是日志工具统一处理。尤其是请求失败、登录失败和异常上报,这几个地方最容易把完整上下文带出去。

ts 复制代码
const SECRET_FIELD_PATTERN = /(authorization|accessToken|refreshToken|session_token|cookie)/i

export function sanitizeLogFields(fields: Record<string, string>): Record<string, string> {
  const safe: Record<string, string> = {}

  Object.entries(fields).forEach(([key, value]) => {
    safe[key] = SECRET_FIELD_PATTERN.test(key) ? maskCredential(value) : value
  })

  return safe
}

export function maskCredential(value: string): string {
  if (value.length <= 8) {
    return '[masked]'
  }
  return `${value.slice(0, 4)}****${value.slice(value.length - 4)}`
}

异常上报建议采用白名单:接口名、错误码、traceId、页面名可以上报;header、cookie、登录响应体默认不能上报。这样即使新同学接入日志,也不容易把 Token 带出去。

退出登录要清四个位置

退出登录不能只把页面状态改成未登录。真正要清的是:安全仓储、页面会话摘要、请求队列和旧明文键。

ts 复制代码
export class LogoutService {
  constructor(
    private readonly tokenVault: TokenVault,
    private readonly apiQueue: ApiQueue,
    private readonly legacyStore: LegacyCredentialStore
  ) {}

  async logout(): Promise<void> {
    this.apiQueue.cancelUnauthorizedRequests()
    await this.tokenVault.clear()
    await this.legacyStore.delete('session_token')
    await this.legacyStore.delete('refresh_token')
    publishSignedOutSession()
  }
}

这一步要做重启验证:退出登录后杀掉应用再打开,如果仍然能发出带 Authorization 的请求,说明还有别的缓存路径没有清干净。

发布前用搜索证明没有明文路径

人工看代码很容易漏。发布前直接用搜索把风险字段扫一遍。

powershell 复制代码
rg -n "accessToken|refreshToken|Authorization|Bearer|session_token|refresh_token" entry features common
rg -n "AppStorage.*token|Preferences.*token|hilog.*token|console.*token|logger.*Authorization" entry features common
rg -n "crash|report|track|event" entry 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 不会在页面、运行态、普通存储和调试链路里到处旅行,排查和交付也有明确证据。

相关推荐
特立独行的猫a2 小时前
Node.js三方库鸿蒙 PC 移植:鸿蒙PC开发者面对面 · 线下课件分享
华为·harmonyos·三方库移植·鸿蒙pc
春卷同学3 小时前
HarmonyOS掌上记账APP开发实践第23篇:弹性动效与手势 — EdgeEffect.Spring 与手势系统实战
spring·华为·harmonyos
l134062082353 小时前
鸿蒙应用开发实战【88】— DevEco Studio高效开发技巧
华为·harmonyos·鸿蒙系统
一缕清烟在人间4 小时前
鸿蒙应用开发实战【54】— 应用详情页AppDetailPage开发(下)
华为·harmonyos·鸿蒙系统
l134062082355 小时前
鸿蒙应用开发实战【85】— 应用签名与正式HAP打包
华为·harmonyos·鸿蒙系统
l134062082355 小时前
鸿蒙应用开发实战【87】— 常见崩溃排查null指针与类型断言
华为·harmonyos·鸿蒙系统
爸爸6195 小时前
鸿蒙应用开发实战【06】— 应用入口与生命周期管理(EntryAbility 深度解析)
华为·harmonyos·鸿蒙系统
不羁的木木5 小时前
《HarmonyOS技术精讲-NearLink Kit(星闪服务)》第8篇:智能家居控制——手机控制智能灯实战
智能手机·智能家居·harmonyos
用户0934077735146 小时前
HarmonyOS WPS Open SDK 实践:用能力分层重构 OpenFileRequest
typescript·harmonyos