先看结论与判断条件
- 先冻结原始包与加固包、应用签名身份、测试数据和密钥配置;候选或配置变化后,旧回执不能直接继承。
- 密钥存在不等于调用契约正确。alias、算法、purpose、block mode、padding、digest 和用户认证条件都要进入对照记录。
- 加密回归比较解密后的业务断言和元数据,不比较随机化加密产生的密文是否完全相同,也不能为测试复用固定 IV。
- 签名回归既要验证成功路径,也要验证错误 key、错误消息、认证缺失和输入被改动时确实失败,避免只记录一次绿色结果。
- 设备锁定、认证状态或密钥失效后的恢复由业务策略决定;捕获异常后无条件删 key 会把可诊断故障变成数据永久丢失。
- CTS、Play Integrity 与 OWASP 控制提供平台和安全上下文,但不能替代目标 App 在真实候选、设备与账号上的 Keystore 业务验收。
先冻结候选、签名身份和密钥配置
Keystore 回归的第一个对象不是某段测试代码,而是唯一可识别的候选。记录 APK 或设备派生包摘要、applicationId、versionCode、签名证书摘要、构建变体和加固配置标识;原始包与加固包必须来自同一源码和依赖基线。候选身份不一致时,测试差异无法归因于加固。
密钥配置同样需要版本化。至少登记 alias 命名规则、算法、用途、密钥长度或曲线、block mode、padding、digest、是否要求用户认证、认证条件和创建触发点。不要把配置散落在代码、远程开关和测试说明中;回执要能回答本次调用实际使用了哪一套配置。
Android Keystore 允许应用限制密钥用途,并可能在设备支持时使用硬件保护,但页面定义不证明某台设备的具体实现,也不证明加固候选可以正常调用。事实层只引用平台契约;是否创建成功、是否硬件支持、异常如何映射和业务能否恢复,必须由目标候选的设备回执确认。
| 对象 | 最小标识 | 比较方式 | 缺失后果 |
|---|---|---|---|
| 应用候选 | 文件摘要与版本 | 原始包和加固包一一对应 | 无法归因差异 |
| 签名身份 | 证书摘要 | 确认安装与升级身份连续 | 混入重签变量 |
| 密钥配置 | profile 版本和字段 | 逐字段比较 | 同 alias 不同语义 |
| 测试数据 | 公开样本摘要 | 两候选使用相同输入 | 业务断言不可比 |
| 设备 | 型号、系统与安全能力 | 每次执行保留回执 | 厂商差异被忽略 |
| 测试代码 | runner 与用例版本 | 绑定候选和结果 | 测试变更冒充回归 |
把 alias 和 KeyGenParameterSpec 当成接口契约
许多问题表现为找不到密钥,根因却是调用方使用了不同 alias、不同用户范围或不同创建条件。首次创建前先查询目标 alias,创建后读取可公开检查的属性并记录结果;后续签名或解密路径只能消费已经登记的 alias,不应在捕获任意异常后悄悄创建同名新 key。
KeyGenParameterSpec 一类配置决定 key 可以执行什么操作。只要 purpose、padding、block mode、digest 或认证要求不匹配,调用就应失败,而不是尝试更宽松的配置继续。加固前后分别记录配置投影和实际操作,能区分调用代码变换导致的错误与本来就不允许的使用方式。
工程上应把密钥 profile 与业务数据格式共同版本化。例如密文元数据记录 key profile、算法版本和必要参数,签名请求记录 profile 与消息格式。文章不规定通用 alias 或密钥长度;这些值应来自项目威胁模型、平台支持和兼容要求,并由安全负责人批准。
| 字段 | 正向断言 | 负向断言 | 记录边界 |
|---|---|---|---|
| alias | 找到预期条目 | 错误 alias 不得回退 | 不记录密钥材料 |
| purpose | 操作属于允许集合 | 解密 key 不得用于签名 | 记录枚举值 |
| algorithm | 与数据格式一致 | 算法错配明确失败 | 不宣称强度结论 |
| padding 或 mode | 初始化参数一致 | 不兼容组合被拒绝 | 绑定 profile 版本 |
| digest | 签名验签契约一致 | 错误 digest 不得静默接受 | 记录名称而非输入明文 |
| 认证条件 | 满足条件后可调用 | 未满足时返回可识别错误 | 不记录生物特征数据 |
首次安装先验证生成与持久化边界
首次安装场景要从应用无本地状态开始,确认初始化代码只创建预期 alias,并且重复进入初始化不会覆盖现有 key。测试同时验证应用数据库或首选项中的 profile 标识、密文元数据和 Keystore alias 能够互相对应。只看到 containsAlias 返回成功,不足以证明后续业务操作可用。
生成路径要覆盖正常创建、已经存在、配置不支持和认证前置条件未满足等分支。错误必须映射为业务可处理状态,例如提示重新认证、限制功能或进入人工恢复;不能把所有异常都转换成创建成功,也不能用 catch 后删除重建来让测试变绿。
设备能力会影响实际结果。instrumented test 适合验证真实 Android 运行时和系统 API,但单台设备通过不能代表全部 API、ABI 或厂商实现。矩阵至少记录设备型号、系统版本、锁屏配置、Keystore provider 可观察信息和实际结果,覆盖策略由用户设备分布与风险决定。
| 步骤 | 预期输出 | 失败分类 | 禁止补偿 |
|---|---|---|---|
| 查询 alias | 明确存在或不存在 | 查询异常 | 默认视为不存在 |
| 创建 key | 产生指定 profile | 不支持或参数错误 | 放宽用途重试 |
| 写入 profile | 版本与 alias 对应 | 本地写入失败 | 只保留半套状态 |
| 再次初始化 | 复用原 key | 配置漂移冲突 | 覆盖同名 key |
| 执行探针 | 签名或解密成功 | 操作级错误 | 只凭 key 存在通过 |
| 保存回执 | 绑定候选和设备 | 证据不完整 | 手工补写结论 |
加密解密回归比较明文断言而不是密文字节
随机化加密的密文通常不应在两次执行中保持完全相同,因此原始包与加固包不能以密文字节相等作为通过条件。更合理的断言是:每个候选使用自己的受控 key 对同一公开样本加密,再用相应 key 解密,得到与输入完全一致的业务数据,同时保存算法 profile、必要参数和失败类型。
测试不得为了可比较而固定或复用本应唯一的 nonce 或 IV。回归记录可以保存公开测试样本摘要、密文长度、参数存在性和解密断言,不保存生产明文、真实令牌或用户数据。对篡改后的密文、错误参数和错误 alias 还要执行拒绝测试,确认系统没有返回部分明文或静默空值。
进程重建后再解密能检查持久化边界:密钥条目仍由 Keystore 管理,应用只恢复必要元数据和密文。若恢复失败,先分辨 alias/profile 不一致、本地元数据丢失、认证状态改变与密文损坏;这些原因需要不同处置,不能统称为加固后解密失败。
| 用例 | 输入 | 预期 | 证据 |
|---|---|---|---|
| 同 key 往返 | 公开样本 | 解密后完全一致 | 样本摘要和结果 |
| 进程重建 | 已保存密文和元数据 | 恢复后可正确解密 | 重建时间线 |
| 错误 alias | 另一个测试 key | 明确拒绝 | 错误类型 |
| 密文改动 | 修改后的公开密文 | 认证失败 | 拒绝回执 |
| 参数缺失 | 不完整元数据 | 不尝试猜测 | 字段错误 |
| 认证未满足 | 受限 key | 要求认证或拒绝 | 业务映射 |
签名验签要覆盖消息绑定与错误 key
签名回归使用公开、固定且带场景标识的消息,签名后由对应公钥验证。通过条件不是签名结果两次相同,而是每个候选都能按同一 profile 完成签名,并且验证器能识别正确消息。签名实现可能具有随机性,因此不能把签名字节完全相等当作跨候选语义一致。
拒绝路径至少包含消息被改动、使用错误公钥、alias 不存在、purpose 不允许和认证未满足。业务层要保留原始异常类别与映射结果,便于判断是需要重新认证、配置错误还是不可恢复状态。把所有失败统一成签名失败会丢失恢复决策所需信息。
如果签名用于服务端挑战或高风险指令,还要验证挑战、主体、用途和有效会话的绑定;Keystore 只负责受限 key 操作,不自动提供完整授权。Play Integrity verdict 可以作为服务端额外信号,但任何单一 verdict 都不应被写成设备绝对可信或本地签名必然有效。
| 场景 | 签名调用 | 验签结果 | 业务决策 |
|---|---|---|---|
| 正确消息与 key | 允许 | 通过 | 继续受控流程 |
| 消息被修改 | 使用原签名 | 失败 | 拒绝请求 |
| 错误公钥 | 签名不变 | 失败 | 标记身份错配 |
| 错误 alias | 初始化失败 | 不执行 | 报告配置错误 |
| purpose 不允许 | 操作被拒绝 | 不执行 | 阻止放宽配置 |
| 认证未满足 | 暂不可用 | 不执行 | 请求重新认证 |
升级和进程重建不能靠一次启动成功验收
覆盖升级要从已使用旧候选创建 key 和业务数据开始,再安装目标加固候选。升级后先核对应用签名与数据连续性,再读取 profile、执行解密或签名探针,并进入真实业务路径。新装通过只能证明创建路径,不能证明旧版本留下的 alias、元数据和数据格式仍能被新版本消费。
进程重建场景要在密钥操作前后分别终止进程,恢复 Activity、Service 或持久任务后继续操作。调用方不能依赖只存在于内存的 cipher、signature 或认证状态;恢复逻辑应从持久化的业务状态重新初始化原语,并对已经消耗的请求保持幂等。
这篇文章不重复 Room 数据库迁移的 schema 与逐级迁移问题。若 Keystore profile 与数据库字段共同升级,只在此核对二者的引用一致性和操作结果;数据库结构与迁移不变量仍由独立迁移验收负责,避免一个测试报告同时给两个搜索问题第二套答案。
| 阶段 | 准备状态 | 核心断言 | 失败归属 |
|---|---|---|---|
| 旧版准备 | 旧候选创建数据 | profile 和样本可识别 | 基线失败 |
| 覆盖安装 | 签名与版本符合预期 | 安装成功且数据未被重置 | 发布身份或安装 |
| 升级后读取 | 旧 alias 和元数据 | 引用一致 | 配置或数据边界 |
| 升级后操作 | 旧数据与目标候选 | 解密或签名契约成立 | Keystore 调用 |
| 进程终止 | 保存操作状态 | 无半完成副作用 | 生命周期处理 |
| 恢复执行 | 重建调用对象 | 结果幂等且错误可解释 | 恢复策略 |
设备锁定变化和密钥失效必须分开处理
用户认证绑定 key 的可用性会受配置和设备安全状态影响。测试应在项目允许的锁屏、认证和设备变化场景中记录操作前置条件、实际异常与业务提示。不要在文章中给所有设备套用同一失效结论;平台、系统版本、厂商实现和 key 配置都可能改变结果。
业务错误至少区分暂时缺少认证、需要用户重新认证、key 已不可用、alias 不存在、参数不匹配和未知 provider 错误。只有产品明确允许且数据可以恢复时,才能进入新 key 流程;若旧 key 保护的是不可再生数据,无条件重建会永久切断解密能力。
恢复方案要在故障前设计。可再生的会话 key、服务端可恢复的数据 key 和仅用于本地签名的身份具有不同处置。工程判断必须写明谁批准重建、哪些数据会失效、如何通知用户、旧密文怎样标记,以及新 key 创建后哪些服务端绑定需要更新。
用结构化脚本检查回归矩阵是否真的覆盖关键路径
下面的 Python 代码不访问 Android Keystore,也不生成任何真实 key;它读取设备端 instrumented test 导出的公开安全 JSON,只检查矩阵完整性。每条结果必须绑定候选摘要、设备、profile、场景、操作、预期和实际状态,缺少关键路径或同一 profile 配置漂移时直接失败。
示例要求每个候选覆盖生成、加密解密、签名验签、认证拒绝、进程恢复和受控恢复六类场景。真实项目可以增加升级、锁屏变化或厂商设备,但不能删除与业务实际使用相关的路径。脚本仅给出结构门禁,不证明 Android API 调用正确,也不替代人工分析异常。
代码特意拒绝在结果中出现 keyMaterial、plaintext、token 等敏感字段。业务排障只需要摘要、profile 和错误类型;若必须保存更详细日志,应按数据分类、最小访问和保留策略处理。测试通过也只适用于记录中的候选、设备与场景,不能外推为御盾产品或所有 Android 设备结论。
import json
import re
import sys
from pathlib import Path
REQUIRED_SCENARIOS = {
'generate',
'encrypt_decrypt',
'sign_verify',
'auth_rejected',
'process_restore',
'controlled_recovery',
}
SENSITIVE_FIELDS = {'keyMaterial', 'plaintext', 'token', 'privateKey'}
if len(sys.argv) != 2:
raise SystemExit('usage: validate_keystore_matrix.py results.json')
input_path = Path(sys.argv[1])
if not input_path.is_file():
raise SystemExit('results file is missing')
data = json.loads(input_path.read_text(encoding='utf-8'))
results = data.get('results')
if not isinstance(results, list) or not results:
raise SystemExit('results array is missing')
profiles = {}
coverage = {}
for index, item in enumerate(results):
if not isinstance(item, dict):
raise SystemExit(f'result {index} is not an object')
leaked = SENSITIVE_FIELDS.intersection(item)
if leaked:
raise SystemExit(f'result {index} contains sensitive fields: {sorted(leaked)}')
required = {'candidateSha256', 'device', 'profile', 'scenario', 'operation', 'expected', 'actual'}
if not required.issubset(item):
raise SystemExit(f'result {index} is missing required fields')
candidate = str(item['candidateSha256']).lower()
if not re.fullmatch(r'[0-9a-f]{64}', candidate):
raise SystemExit(f'result {index} has an invalid candidate digest')
scenario = str(item['scenario'])
if scenario not in REQUIRED_SCENARIOS:
raise SystemExit(f'result {index} has an unknown scenario')
profile = item['profile']
if not isinstance(profile, dict):
raise SystemExit(f'result {index} profile is not an object')
profile_fields = {'id', 'alias', 'algorithm', 'purposes', 'authRequired'}
if not profile_fields.issubset(profile):
raise SystemExit(f'result {index} has an incomplete profile')
profile_id = str(profile['id'])
stable_profile = json.dumps(profile, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
if profile_id in profiles and profiles[profile_id] != stable_profile:
raise SystemExit(f'profile {profile_id} changes between results')
profiles[profile_id] = stable_profile
coverage.setdefault(candidate, set()).add(scenario)
if item['actual'] != item['expected']:
error_type = str(item.get('errorType', '')).strip()
if not error_type:
raise SystemExit(f'result {index} failed without an error type')
for candidate, scenarios in coverage.items():
missing = sorted(REQUIRED_SCENARIOS - scenarios)
if missing:
raise SystemExit(f'candidate {candidate} lacks scenarios: {missing}')
print(json.dumps({'candidates': len(coverage), 'profiles': len(profiles), 'status': 'pass'}))把允许、拒绝和恢复回执绑定到发布判断
发布门禁需要同时看到正向、拒绝和恢复证据。正向证明生成、解密和签名契约可执行;拒绝证明错误 key、错误输入和认证缺失不会被静默放行;恢复证明进程重建、升级或受控 key 失效后,业务给出明确状态而不是崩溃、空数据或无限重试。
CTS 验证设备实现与 Android 兼容性定义的一致性,但设备通过 CTS 不等于第三方加固 App 的业务路径通过。OWASP MASVS 将抗篡改与抗逆向放在纵深防御中,也不提供某个候选的保护强度结论。NIST SSDF 支持保留来源、构建、验证与变更证据,具体通过线仍由项目定义。
需要继续定位 Keystore 失败时,可先阅读本站的加固后闪退分层排查指南,确定最早异常与候选身份,再携带脱敏矩阵、错误类型和设备回执申请御盾技术评估。所有申请、登录和控制台动作仍由御盾中央平台承接;文章不声称尚未取得的兼容结果或产品能力。
事实依据与适用边界
以下内容区分官方事实、本文工程判断和不能外推的范围,避免把设计建议写成未经验证的产品结论。
| 本文判断 | 事实或工程依据 | 适用限制 |
|---|---|---|
| Android Keystore 可以限制 key 的允许用途,并可能在设备支持时使用硬件保护。 | Android Keystore 说明密钥存储、用途限制与硬件支持边界。 | 文档不证明某台设备一定硬件保护,也不保护解密后进入业务内存的明文。 |
| 依赖 Android 运行时、组件和系统 API 的语义应在设备端执行测试。 | Android instrumented tests 说明 instrumented test 在 Android 设备或模拟器环境运行。 | 单一设备通过不能代表完整系统、ABI、厂商和业务账号矩阵。 |
| CTS 用于验证设备实现与 Android 兼容性定义的一致性。 | Android CTS overview 说明 CTS 的兼容性测试定位。 | CTS 通过不代表第三方 App 加固候选的 Keystore 业务回归通过。 |
| 完整性响应包含请求详情与多类平台信号,服务端应按场景组合使用。 | Play Integrity verdicts 说明 verdict 字段和服务端解释方式。 | 单一 verdict 不是绝对信任、Root 判定、Keystore 证明或 VMP 配置证明。 |
| 抗篡改与抗逆向控制属于移动安全的纵深防御。 | OWASP MASVS-RESILIENCE 描述移动应用韧性控制域。 | 控制目录不证明某个候选已经达到防护强度,也不替代服务端授权。 |
| 安全发布应保留来源、构建、验证和变更证据。 | NIST SP 800-218 SSDF 提供组织级安全软件开发与供应链风险实践。 | SSDF 不定义御盾或其他加固产品功能,也不给出本项目的通过结论。 |
| Keystore 回归应把 alias、profile、候选、设备、预期与实际状态共同记录。 | 工程判断:缺少任一标识会使配置错配、环境差异和候选差异无法归因。 | 记录完整只证明证据可追溯,不证明 key 强度、实现安全或业务已经通过。 |
| 捕获任意异常后删除并重建 key 不是通用恢复方案。 | 工程判断:重建可能永久切断旧密文或服务端身份绑定,恢复必须依据错误类型与数据可再生性。 | 实际处置取决于 key 用途、数据恢复能力、产品流程和项目批准,文章不提供统一删除策略。 |
工程常见问题
加固后还能查到 alias,是否说明 Keystore 没有兼容问题?
不能。还要按实际 profile 执行生成、加密解密、签名验签、认证拒绝和恢复路径,并核对业务错误映射;key 存在只是一个前置状态。
为什么原始包和加固包生成的密文不应该直接比较相等?
随机化加密通常会产生不同密文。回归应比较各自解密后的公开样本断言、profile 和拒绝路径,不应为了相等而固定或复用 nonce、IV。
遇到 Keystore 异常后直接删除 key 再创建可以吗?
不能作为通用处理。先区分认证缺失、配置错误、alias 丢失和 key 不可用;若旧 key 保护不可再生数据,删除会造成永久数据损失。
设备通过 CTS 是否可以省略 App 的 Keystore 回归?
不可以。CTS 验证设备兼容性实现,不验证第三方 App 的加固候选、密钥 profile、业务数据和错误恢复路径。
Play Integrity verdict 能否证明本地 Keystore key 安全?
不能。verdict 是服务端风险决策的一类平台信号,不是某个 key 的硬件保护证明,也不能替代 Keystore 操作和业务授权验收。
申请御盾协助定位 Keystore 回归需要准备什么?
准备候选摘要、签名身份、脱敏 key profile、设备和系统、场景矩阵、最早异常、错误类型及恢复结果,再通过御盾中央平台提交。