先看结论与判断条件

  • 缓存归属不能只依赖 conversationId;键、记录和索引至少同时绑定 tenantId、accountId、conversationId、generation 与 schemaVersion。
  • 账号切换应先发布身份代次变化,再取消旧任务和清理缓存;旧网络响应、流式分片与摘要任务不得写入新账号界面。
  • 消息正文、草稿、附件引用、检索片段、派生摘要和展示快照是同一会话对象图,退出与删除必须沿引用关系传播。
  • Android Keystore 可用于约束本地密钥用途,但不能替代账号域校验,也不能保护已经解密并交给业务层的明文。
  • 日志与分析事件只保留受控标识、阶段和错误类别,不能记录正文、提示词、令牌、原始附件名或可还原账号关系的组合字段。
  • 商店和隐私清单要反映 App 与第三方 SDK 的真实数据行为;声明、静态配置和代码审计不能冒充目标 App 的运行验证。

先定义会话归属,再讨论缓存介质

会话串号通常不是某个数据库 API 的单点故障,而是归属模型不完整。产品界面只显示 conversationId,工程实现便把它当作全局唯一键;但同一个设备可能先后登录个人账号、企业账号和测试账号,不同租户也可能各自产生编号相同的会话。若缓存键只含 conversationId,读取层即使没有越权查询,也可能在换号后命中上一身份留下的数据。正确起点是先写出谁拥有这条记录、它属于哪个会话代次、何时失效,再选择数据库、文件或内存缓存。

最低身份域应包含 tenantId、accountId、conversationId、generation 和 schemaVersion。tenantId 区分组织边界,accountId 区分同一组织内用户,conversationId 指向业务对象,generation 表示本次登录或本次会话实例,schemaVersion 防止旧结构被新代码误读。设备标识、安装标识或推送 token 都不能代替账号,因为它们描述的是客户端环境而非数据所有权。匿名模式也需要独立的 guestScope,并在升级为正式账号时采用明确迁移,而不是自动合并。

身份域必须同时出现在键、记录、索引和任务参数中。只把 accountId 拼进文件名,却让数据库索引仍按 conversationId 查询,隔离仍会被旁路;只在 UI 状态保存当前账号,却让后台 worker 从全局单例取账号,也会出现竞态。设计评审要逐一回答写入由谁授权、读取用什么谓词、删除沿哪些索引传播、异步回调凭什么证明仍属于当前身份。任何一项依赖隐含的全局当前用户,都应作为发布阻断项处理。

AI 会话缓存身份域的最低字段
字段解决的问题不能替代缺失时动作
tenantId组织或工作区隔离账号身份拒绝持久化
accountId同租户用户隔离设备或安装标识拒绝读取
conversationId定位会话对象租户与账号拒绝建立索引
generation阻断旧登录与迟到回调长期版本号取消任务
schemaVersion控制结构迁移身份代次迁移或清除
purpose限定缓存用途访问授权不创建派生物

缓存键、记录与索引必须使用同一复合身份

一个可审计的缓存键可以由固定命名空间、租户、账号、会话、generation、对象类型和对象标识组成,并在落盘前对各字段做格式与长度限制。键的目标不是把身份藏起来,而是让每次命中都能还原完整归属。若因隐私考虑需要使用摘要,摘要输入仍必须包含全部域,并使用项目控制的版本化方案;不能截断到容易碰撞的短值,也不能把摘要当成访问控制。真正的授权仍由读取谓词和当前身份上下文完成。

记录本体要重复保存关键归属字段,读取后再校验一次。这样即使索引损坏、文件被放进错误目录或迁移脚本混写,记录也会因 owner tuple 不一致而被拒绝。索引值不能只指向裸文件路径,应指向带归属的 objectId;批量查询必须把 tenantId、accountId 和 generation 放进谓词,不允许先按 conversationId 找出所有记录再在业务层过滤。后过滤会让错误数据进入内存、日志或统计,即使最终没有显示也已经越过边界。

内存层同样需要隔离。常见的 LRU、图片缓存、Markdown 渲染缓存和模型响应 memoization 往往用文本摘要或 URL 作键,换号后最容易复用。键中应加入身份域和策略版本,缓存对象还要带 expiresAt 与 sensitivityClass;敏感正文不适合进入跨页面、跨进程或系统级共享缓存。若第三方组件无法提供复合键、精确清除和禁用持久化,应把它限制在非敏感派生物,或在账号切换时销毁整个实例。

各缓存层的账号隔离要求
缓存层典型对象归属检查换号处理
内存 LRU消息与渲染结果复合键加记录 owner销毁旧账号分区
本地数据库会话、草稿、摘要复合主键与查询谓词关闭旧账号句柄
文件目录附件预览与导出目录域加 manifest撤销索引并清理
任务队列同步、摘要、上传任务快照含 generation取消并拒绝回写
网络缓存响应与流式片段账号域与授权上下文禁用共享命中
第三方 SDK分析与诊断事件字段 allowlistflush 后重建实例

账号切换必须先让旧 generation 失效

安全的切换顺序是先变更身份代次,再停止生产新数据。用户点击退出或选择另一个账号时,身份控制器以原子方式把旧 generation 标为 revoked,并生成与新账号绑定的新 generation;随后 UI 清空可见状态,后台队列收到取消信号,数据库句柄和缓存分区依次关闭。这样即使网络请求、流式生成或摘要任务晚到,它们携带的旧 generation 也无法通过写入门禁。若先清 UI、后更新身份,短暂窗口内的回调仍可能把旧消息写进新页面。

异步任务不能在回调时读取全局 currentAccount 来决定归属。任务创建时应保存 immutable owner snapshot,包括 tenantId、accountId、conversationId、generation、purpose 与 requestId;每个分片、重试和最终结果都携带该快照。写入前同时比较任务快照、目标记录和当前有效 generation,三者一致才提交。网络层取消只能减少迟到事件,不能成为唯一防线,因为请求可能已到服务端、系统可能无法及时中止,或回调已在调度队列中。

流式响应要按分片执行相同检查。不能只在建立连接时验证一次,然后把后续 token 直接附加到当前打开的会话。每个分片至少关联 requestId 和 generation,聚合器发现身份失效后立即丢弃剩余分片并关闭展示通道;已写入的临时缓冲也按旧账号清理。对无法中止的服务端生成,客户端可以停止展示和持久化,并按业务协议请求终止,但是否真正停止仍需服务端回执,不能由本地取消状态推断。

账号切换状态机与硬门禁
阶段身份动作允许的后台行为拒绝条件
active持有当前 generation当前账号读写owner tuple 不完整
revoking旧 generation 先失效只允许取消与删除新增缓存或回写
clearing撤销索引和密钥引用清理旧对象图跨账号迁移未授权
signed_out无有效账号域保留最小删除回执读取敏感缓存
switching创建新账号 generation初始化空分区复用旧 UI 快照
active_new新身份正式生效仅新域读写迟到旧 generation

消息、草稿、附件和摘要要作为对象图一起清理

一条 AI 对话不只是 messages 表。用户输入可能产生草稿自动保存、附件引用、OCR 文本、检索切片、工具调用参数、模型流式缓冲、内容审核结果、标题、摘要、搜索索引、渲染 HTML 和分享预览。只删除主消息行,会留下能够恢复语义或重新指向附件的派生物。会话 schema 应为每个对象记录 parentConversationId、owner tuple、generation、purpose、retentionClass 和 deleteState,使清理器可以沿引用关系传播。

引用关系要有方向和终态。附件预览由附件派生,摘要由消息集合派生,搜索条目由会话派生;父对象删除时,子对象进入 tombstoned,后台清理完成后进入 deleted。tombstone 只保存最小对象标识、删除原因和回执状态,不保存正文。若某个对象因法规、争议处理或用户明确选择需要单独保留,应进入独立策略与权限域,不能悄悄留在普通对话缓存。具体法律保留要求需要项目合规确认,技术清单本身不提供法律结论。

切换账号与永久删除不是同一个动作。换号通常要求旧账号数据不可被新账号读取,并停止旧任务;本地数据是否保留取决于产品的离线策略、设备共享场景和用户选择。退出即清除的产品应完成全图删除;允许离线保留的产品要用账号专属密钥和精确索引封装,并在再次登录时重新授权。无论哪种策略,新账号都不能扫描旧目录后尝试猜测归属,也不能因缓存命中率而复用旧摘要。

会话对象图的清理传播
对象父对象身份字段退出或删除动作
消息正文会话owner 加 generation隐藏、撤权或删除
草稿会话或编辑器owner 加 draftId立即清空
附件引用消息owner 加 attachmentId撤销引用并级联
检索切片附件或消息owner 加 sourceId移出索引
派生摘要消息集合owner 加 sourceVersion失效并删除
渲染快照消息或摘要owner 加 rendererVersion销毁缓存
任务回执异步任务owner 加 requestId只留最小终态

密钥分区保护静态数据,但不能替代归属校验

Android Keystore 可以让密钥材料保持不可导出,并限制密钥的用途、算法或认证条件;在具备相应能力的设备上,密钥还可能由安全硬件保护。对多账号缓存,工程上可为账号域生成版本化数据密钥,再由 Keystore 中的封装密钥保护。数据库行、文件 manifest 与密钥引用都绑定 tenantId、accountId 和 keyVersion。退出或撤销时删除账号密钥引用,可以让残留密文失去正常解密路径,但该设计仍需要目标设备、备份与恢复流程的验证。

加密不能补救错误查询。若应用先用新账号读取到旧账号记录,再根据记录内的 keyAlias 寻找密钥,读取边界已经失败;日志、异常和计时信息也可能暴露对象存在。正确顺序是先用当前身份构造精确谓词,验证记录 owner tuple,再申请对应密钥解密。密钥别名本身不要包含邮箱、手机号或可读组织名,也不要写进公开日志。解密后的字符串、图像和向量仍是敏感明文,必须受内存生命周期和 UI 状态约束。

备份与换机需要显式策略。Keystore 密钥是否可迁移、数据库是否进入系统备份、用户是否希望离线历史随账号恢复,都会影响设计。不能简单承诺删除密钥就等于所有云端、备份和第三方副本已经消失;也不能因恢复方便而使用一个全局静态密钥保护全部账号。每个项目应记录密钥生成、轮换、撤销、失败恢复和旧版本清理的证据,公开文章不声称某个具体 App 已执行这些步骤。

本地密钥与缓存隔离的职责边界
控制可以约束不能证明验证材料
账号数据密钥静态密文分区查询归属正确密钥版本与记录映射
Keystore 封装密钥用途和可导出性明文使用安全设备能力与异常回执
密钥撤销后续正常解密路径远端已删除撤销与残留扫描
精确查询谓词读取对象范围存储已加密数据库查询测试
内存清理缩短明文生命周期进程无任何副本对象生命周期观察
备份策略恢复与保留边界法规结论平台配置与演练

日志、分析和第三方 SDK 不能成为旁路缓存

最严密的会话数据库隔离,也可能被一条调试日志绕过。请求体、提示词、模型输出、附件名、Authorization、账号邮箱和完整 conversationId 不应进入生产日志。OWASP MASWE-0001 描述了日志暴露凭据、令牌、个人信息和内部诊断数据的风险,并给出删除、降级或脱敏等方向。实际实现可记录策略版本、不可逆受控标识、阶段、结果类别和有限错误码,但组合字段仍可能重建用户关系,需要单独做可关联性评审。

崩溃、性能、客服和产品分析 SDK 常在业务数据库之外保存事件,并可能拥有自己的磁盘队列。账号切换时,仅清理应用表并不能清除这些队列。SDK 接入清单要列明自动采集字段、用户标识设置、持久化位置类别、flush、reset 和删除能力;无法精确清理的 SDK 不应接收对话正文或附件派生物。Android Data safety 与 Apple privacy manifest 要覆盖 App 和适用第三方 SDK 的真实数据行为,但它们是透明度与审核材料,不是运行时隔离器。

客服导出和错误上报也要遵守当前账号域。用户从会话页提交问题时,界面明确显示将附带哪些脱敏诊断,不自动附上其他账号的最近会话。服务器端关联查询以提交时的 owner tuple 和授权为准,不根据设备标识拉取全部历史。工程团队还要验证退出后离线事件不会在新账号登录时以新身份补发;若必须发送旧账号的终态回执,应保留原 generation 并使用专门通道。

诊断与分析数据的最小允许字段
字段类别默认策略允许示例禁止示例
身份不可读且分域短期诊断别名邮箱、手机号、真实姓名
会话有限关联受控 objectId完整提示词与输出
授权绝不记录内容令牌是否存在Authorization 与 API Key
错误类别化cache_owner_mismatch包含正文的异常字符串
附件只记类型和阶段image_prepared原文件名与路径
SDK明确 allowlist版本与启用状态自动抓取页面文本

把隐私声明和发布证据落到可复核门禁

Android 的数据声明要求开发者了解应用代码和第三方库实际收集、共享的数据,Apple privacy manifest 则提供 App 与第三方 SDK 的隐私声明和 required reason API 材料。对 AI 会话缓存,发布前应把正文、草稿、附件引用、摘要、诊断和 SDK 事件逐项映射到声明类别、用途、接收方与保留边界。声明缺少不等于运行时一定泄露,声明完整也不代表隔离已经正确,两类证据必须分开记录。

OWASP MASVS-PRIVACY-1 强调最小化敏感数据访问,并把第三方组件纳入责任范围。项目可以据此建立设计与测试门禁:禁止无账号域缓存,禁止跨 tenant 引用,禁止旧 generation 回写,禁止日志包含正文,要求退出后索引扫描为空或符合明确离线保留策略。通用控制只给出方向,具体字段、用户同意、地区要求和删除时限仍由目标业务决定;没有运行回执时不能写成已通过。

NIST SSDF 提供安全开发、来源、验证、变更和供应链证据的组织框架。一次发布至少应绑定缓存 schema、迁移脚本、账号状态机、密钥策略、SDK 清单、测试夹具、代码审查和变更负责人。若 AI 服务、SDK 或账号系统升级,重新执行换号、退出、进程死亡、离线重试与恢复测试。SEO、GEO、排名、流量和客户效果若没有真实数据,就明确为未接入,不能从文章长度、静态门禁或模型审校推断。

  • 数据地图覆盖消息、草稿、附件引用、摘要、索引、日志和第三方 SDK
  • 缓存 schema 与所有查询都包含完整 owner tuple 和 generation
  • 账号切换、退出、进程死亡和离线重试均有独立回归用例
  • Data safety 与 privacy manifest 按真实收集、共享和 SDK 行为复核
  • 发布证据绑定 schema、迁移、密钥、测试夹具、审查和负责人
  • 无目标回执的性能、隔离效果、产品能力和运营指标保持未证实

用 manifest 门禁发现缺少账号域和旧代次回写

下面的 Python 示例检查一个公开安全的会话缓存 manifest。它要求顶层声明当前 tenantId、accountId 和 activeGeneration,每条对象使用安全标识,绑定同一 owner tuple,具有有限对象类型和明确 deleteState。脚本还校验任务 generation 必须等于当前有效代次,引用对象不得跨租户或账号,已退出账号不能留下 active 记录。任何问题都以字段级错误终止,适合放在 CI 的结构审计阶段。

示例不读取真实数据库,也不证明设备缓存、Keystore、第三方 SDK 或服务端已经正确隔离。生产测试还需要构造两个授权测试账号,依次覆盖换号、旧流式响应迟到、进程死亡恢复、附件清理、SDK 离线队列和密钥撤销,并核对网络、数据库、文件与 UI 回执。测试数据必须是专门夹具,不使用客户对话;本文不提供凭据、内网地址或可运行攻击链。

本站关于 AI 请求幂等与推理并发序列化的技术说明分别处理网络重复提交和同账号执行次序,不能代替当前页面的多账号所有权门禁。准备具体 App 的缓存隔离评估时,可整理脱敏 schema、账号状态图、缓存层清单、SDK 清单、保留策略与回归夹具,再从御盾中央平台提交申请;页面框架会统一提供中央行动按钮和本站相关文章入口。

  • tenantId、accountId 与 activeGeneration 均为必填身份域
  • 每个对象使用唯一安全标识并重复保存 owner tuple
  • 对象 generation 与当前有效代次不一致时拒绝读取和写入
  • 父子引用只能指向同一 manifest 中的受控对象
  • 退出状态不允许继续保留 active 会话缓存对象
  • 结构通过后仍执行真实数据库、文件、网络、SDK 和密钥回归
AI 会话缓存账号隔离 manifest 校验器
from pathlib import Path
import json
import re
import sys

if len(sys.argv) != 2:
    raise SystemExit("usage: cache_owner_gate.py manifest.json")
path = Path(sys.argv[1])
if not path.is_file():
    raise SystemExit("manifest file is missing")
try:
    document = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
    raise SystemExit("manifest is not valid JSON") from exc
if not isinstance(document, dict):
    raise SystemExit("manifest must be an object")

safe_id = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
required_owner = ("tenantId", "accountId")
for field in required_owner:
    if not safe_id.fullmatch(str(document.get(field, ""))):
        raise SystemExit(f"{field} is missing or invalid")
active_generation = document.get("activeGeneration")
if not isinstance(active_generation, int) or active_generation < 1:
    raise SystemExit("activeGeneration must be a positive integer")

objects = document.get("objects")
if not isinstance(objects, list):
    raise SystemExit("objects must be a list")
known_ids = set()
for number, item in enumerate(objects, 1):
    if not isinstance(item, dict):
        raise SystemExit(f"object {number} must be an object")
    object_id = str(item.get("objectId", ""))
    if not safe_id.fullmatch(object_id) or object_id in known_ids:
        raise SystemExit(f"object {number} has an invalid or duplicate id")
    known_ids.add(object_id)
    for field in required_owner:
        if item.get(field) != document[field]:
            raise SystemExit(f"object {object_id} crosses the {field} boundary")
    if item.get("generation") != active_generation:
        raise SystemExit(f"object {object_id} belongs to a revoked generation")
    if item.get("type") not in {"message", "draft", "attachment_ref", "summary"}:
        raise SystemExit(f"object {object_id} has an unsupported type")
    if item.get("deleteState") not in {"active", "tombstoned", "deleted"}:
        raise SystemExit(f"object {object_id} has an invalid deleteState")

for item in objects:
    parent_id = item.get("parentId")
    if parent_id is not None and parent_id not in known_ids:
        raise SystemExit(f"object {item['objectId']} references an unknown parent")

tasks = document.get("tasks", [])
if not isinstance(tasks, list):
    raise SystemExit("tasks must be a list")
for task in tasks:
    if not isinstance(task, dict) or task.get("generation") != active_generation:
        raise SystemExit("a task may write from a revoked generation")
    if task.get("accountId") != document["accountId"]:
        raise SystemExit("a task crosses the account boundary")

if document.get("accountState") == "signed_out":
    if any(item.get("deleteState") == "active" for item in objects):
        raise SystemExit("signed-out account still has active cache objects")
print(f"validated_objects={len(objects)} validated_tasks={len(tasks)}")

事实依据与适用边界

以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。

本文判断事实或工程依据适用限制
Android 应用的数据声明需要覆盖应用代码和第三方 SDK 实际收集或共享的数据。Declare Android app data use 说明开发者需要根据应用及第三方库的数据行为完成 Data safety 信息。商店声明不会自动隔离会话缓存,也不能证明账号切换、日志和删除已经正确执行。
Android Keystore 可限制密钥用途,并在支持的设备上使用安全硬件保护密钥材料。Android Keystore system 文档描述不可导出密钥、用途限制、用户认证与硬件保护等能力。Keystore 不替代 tenant、account 与 generation 查询校验,也不保护交给业务层后的明文。
Apple 平台的 App 与适用第三方 SDK 需要维护相应 privacy manifest 数据与 API 声明。Apple privacy manifests 说明在 App 或第三方 SDK 中添加、聚合和验证 privacy manifest 的流程。manifest 是声明与审核材料,不证明运行时账号隔离、缓存清理或密钥撤销已经完成。
生产日志可能暴露凭据、令牌、个人信息和内部诊断内容。OWASP MASWE-0001 描述敏感数据进入日志的风险与删除、降级、脱敏等缓解方向。该页面处于 Beta;文章只采用稳定风险原则,不把它当作目标 App 已通过检查的证据。
移动应用应最小化敏感数据访问,并把第三方组件纳入数据责任边界。OWASP MASVS-PRIVACY-1 要求尽量减少敏感数据访问,控制范围包含 App 和第三方依赖。通用控制不替代具体会话数据分类、用户选择、地区合规和运行环境验证。
会话缓存隔离的发布过程应保留来源、变更、验证和供应链证据。NIST SP 800-218 SSDF 给出组织级安全软件开发与供应链风险管理实践。SSDF 不定义具体缓存 schema,也不证明御盾或任何 AI 应用具备特定隔离能力。
换号时先撤销旧 generation,可以阻止迟到网络响应和后台任务进入新账号缓存。工程判断:任务创建时保存 owner snapshot,回写前比较当前有效 generation,可把身份切换变成显式门禁。是否能终止服务端生成仍取决于具体协议;本地失效不能冒充远端已经停止或删除。
消息、草稿、附件引用、摘要和索引需要沿对象引用关系执行一致清理。工程判断:派生对象若没有 parent、owner tuple 和 deleteState,退出后无法证明旧账号语义已不可达。实际对象类型与保留条件由目标 App 数据流决定,静态 manifest 不能替代数据库、文件和服务回执。

工程常见问题

缓存键加上 accountId 就能完全避免会话串号吗?

不能。还要加入 tenantId、conversationId 和 generation,并让记录、索引、查询谓词、任务快照和回调检查使用同一复合身份。

用户退出后,取消全部网络请求是否已经足够?

不够。请求可能已经到达服务端或回调已经排队;应先使旧 generation 失效,再取消任务、拒绝迟到回写并清理对象图。

使用 Android Keystore 加密缓存后,还需要账号域校验吗?

需要。Keystore 保护密钥材料和用途,不会修正错误查询;必须先按当前 owner tuple 精确读取并核对记录归属,再申请解密。

退出账号是否必须删除所有离线对话?

取决于明确的产品与合规策略。无论删除还是保留,数据都必须对新账号不可达;保留时使用账号专属密钥、索引和重新授权。

只清理消息表,附件和摘要留着是否有风险?

有。附件引用、OCR、检索切片、摘要、渲染快照和日志可能恢复会话语义,应通过 parent 关系和 deleteState 执行级联清理。

manifest 校验通过是否代表多账号缓存隔离已经安全?

不代表。它只检查声明结构;仍要用受控双账号夹具验证数据库、文件、流式迟到回调、进程恢复、第三方 SDK 和密钥撤销。

想用自己的 App 验证?

提交候选包、目标系统和关键业务路径,申请御盾 PoC 与兼容性评估。

继续阅读: 移动 AI 应用的运行时安全边界