- Android 端先获取挑战值,用 TEE 私钥签名后查询设备状态 - 服务端新增挑战值接口,校验时效、防重放并验签后才返回状态
🔐 安全设备 Demo — 前后端分离(Web 用户端 + Android 设备端 + Spring Boot)
场景:高权限设备端(无登录)+ 用户端(有登录体系)+ SN 绑定 + 私密照片加密
目标:设备端 TEE 密钥不可导出、服务端零知识、恢复出厂后可安全恢复
架构:前后端分离 —— Web 用户端(真实登录体系)与 Android 设备端(无登录)都通过统一 REST API 访问后端
📐 架构总览
┌─────────────────────┐ HTTPS ┌──────────────────────┐
│ Android 设备端 │ ◄──────────────────► │ Spring Boot 后端 │
│ (无登录/高权限) │ │ ──────────────── │
└─────────────────────┘ │ · 统一响应 ApiResponse│
│ │ · CORS 跨域 │
│ ① TEE 密钥对(Keystore) │ · 用户登录 Token 鉴权 │
│ ② AES-GCM 信封加密照片 │ · UK 信封加密存储 │
│ ③ RSA-SHA256 签名元数据 │ · SN 绑定 + 短信验证 │
│ ④ 恢复出厂 → 新密钥对 │ · Recovery Token 下发 │
│ └──────────┬───────────┘
┌─────────────────────┐ REST + Bearer Token │
│ Web 用户端 │ ◄───────────────────────────────┘
│ (web-client/) │ · 登录/注册(真实登录体系)
│ (有登录体系) │ · 绑定设备 / 恢复授权 / 下载解密
└─────────────────────┘
- 设备端(Android):无登录体系,只持有 TEE 密钥,信任边界 = 密钥 + SN + Recovery Token
- 用户端(Web):先登录获取
Bearer Token,再操作绑定 / 短信 / 恢复授权 / 下载解密 - 后端:统一
ApiResponse{code, message, data},跨域开放,用户端接口强制 Token 鉴权
🔑 密钥分层模型
┌─────────────────────────────────────────────────────────┐
│ │
│ User Key (UK) — 用户主密钥 │
│ ├── 生成:用户注册时创建(AES-256) │
│ ├── 存储:服务端加密存储(生产环境用 KMS 托管) │
│ └── 用途:加密所有 DEK │
│ │
│ Data Encryption Key (DEK) — 每照片一个 │
│ ├── 生成:设备端 SecureRandom(每次随机) │
│ ├── 加密:UK 加密后存服务端 │
│ └── 用途:AES-256-GCM 加密照片 │
│ │
│ Device Key Pair — TEE 硬件密钥 │
│ ├── 生成:Android Keystore(StrongBox 优先) │
│ ├── 私钥:永不导出,仅签名/解密 │
│ ├── 公钥:上传服务端,用于加密下发 │
│ └── 销毁:恢复出厂时自动清除 │
│ │
└─────────────────────────────────────────────────────────┘
🔄 完整流程
Phase 1:设备注册 + 用户绑定
设备端 后端 用户端
│ │ │
│── 生成 TEE 密钥对 ──────────│ │
│── POST /api/device/register │ │
│ {sn, publicKey} ──►│ │
│ │── 存储 device(sn, pubKey) │
│◄── {deviceId} ─────────────│ │
│ │ │
│ │◄── POST /api/device/bind │
│ │ {userId, sn} │
│ │── 绑定 userId ↔ deviceId │
│ │◄── {ok} ────────────────────│
Phase 2:拍照 → 信封加密 → 上传
设备端 后端
│ │
│── SecureRandom → DEK (256bit)│
│── AES-GCM(photo, DEK) → ct │
│── Sign(metadata, PrivKey) │
│ │
│── POST /api/photo/upload │
│ {sn, ct, iv, dek, sig} ──►│
│ │── 验签(PubKey)
│ │── wrapDEK(dek, UK) → encDEK
│ │── 存储 {ct, iv, encDEK, sig}
│◄── {photoId} ───────────────│
Phase 3:恢复出厂 → 短信验证 → 恢复
设备端(新) 后端 用户端
│ │ │
│── 新 TEE 密钥对 │ │
│── POST /api/device/register │ │
│ {sn, newPubKey} ──►│(旧设备自动停用) │
│◄── {newDeviceId} ───────────│ │
│ │ │
│ │◄── POST /api/device/sms/send │
│ │ {phone} │
│ │── 发送短信 │
│ │ │
│ │◄── POST /api/device/recover │
│ │ {userId, sn, smsCode, │
│ │ newPubKey} │
│ │── ① 验证短信 │── 输入验证码
│ │── ② 确认 SN 归属 │
│ │── ③ 生成 Recovery Token │
│ │── ④ RSA(newPubKey, Token) │
│◄── {encToken, nonce} ────────│ │
│ │ │
│── PrivKey 解密 Token │ │
│── POST /api/photo/recover │ │
│ {deviceId, token} ──►│ │
│ │── ① 验证 Token │
│ │── ② 遍历照片 │
│ │── ③ UK 解 DEK → PubKey 加密 │
│◄── [{photoId, encDEK}, ...] ─│ │
│ │ │
│── PrivKey 解密每个 DEK │ │
│── AES-GCM 解密照片 │ │
│── ✅ 照片恢复完成 │ │
📁 项目结构
secure-device-demo/
├── android-app/ # Android 设备端(无登录体系)
│ └── app/src/main/java/com/secure/
│ ├── demo/activity/main/ # MainViewModel:设备端演示链路
│ ├── demo/network/ # Retrofit + DeviceApi + ApiResponse
│ └── device/DeviceCrypto.java# TEE 安全模块(Keystore 密钥对)
│
├── web-client/ # Web 用户端(独立前端,有登录体系)
│ ├── index.html # 单页控制台
│ ├── style.css
│ └── app.js # fetch 调后端,Bearer Token 认证
│
├── springboot-server/ # Spring Boot 后端(统一 API)
│ ├── pom.xml
│ └── src/
│ ├── main/
│ │ ├── java/com/secure/demo/
│ │ │ ├── SecureDemoApplication.java
│ │ │ ├── common/ # ApiResponse 统一响应 + 全局异常
│ │ │ ├── auth/ # AuthController + TokenService(登录体系)
│ │ │ ├── config/ # WebConfig(CORS)
│ │ │ ├── controller/
│ │ │ │ └── DeviceController.java
│ │ │ ├── service/
│ │ │ │ ├── KeyManagementService.java
│ │ │ │ └── DeviceBindingService.java
│ │ │ ├── model/
│ │ │ │ ├── User.java
│ │ │ │ ├── Device.java
│ │ │ │ └── EncryptedPhoto.java
│ │ │ └── crypto/
│ │ │ ├── AesGcmUtil.java
│ │ │ └── RsaUtil.java
│ │ └── resources/
│ │ └── application.properties
│ └── test/
│ └── java/com/secure/demo/IntegrationTest.java
│
└── README.md
🚀 快速启动
1. 后端
cd springboot-server
mvn spring-boot:run # http://localhost:8080
2. Web 用户端(前后端分离演示)
cd web-client
python3 -m http.server 3000 # 浏览器打开 http://localhost:3000
浏览器打开后依次体验:注册/登录 → 绑定设备 → 发送短信 → 恢复授权 → 照片列表 → 下载解密。
所有请求携带 Authorization: Bearer <token>,可在页面底部看到统一响应 {code, message, data}。
3. Android 设备端
cd android-app
./gradlew :app:assembleDebug # 模拟器运行,基地址 BuildConfig.API_BASE=http://10.0.2.2:8080
Android 端模拟真实操作:打开 App 仅做本地 TEE 自检(设备开机),之后由用户按
① 设备注册 → ② 绑定用户 → ③ 加密上传图片 → ④ 下载解密 → ⑤ 恢复出厂(销毁 TEE 密钥)
→ ⑥ 重新注册 → ⑦ 发送短信 → ⑧ 恢复授权 → ⑨ 恢复照片,逐步手动触发,
界面实时显示设备状态(SN/公钥/注册/绑定/恢复进度)。
绑定/短信/恢复授权等「用户端」操作:Android 以 X-User-Id 兼容方式演示,
真实场景请使用 Web 用户端(Bearer Token)。
测试
cd springboot-server
mvn test
🛡️ 安全分析
攻击场景 vs 防护
| 攻击场景 | 结果 | 原因 |
|---|---|---|
| 设备被 root | 拿不到 TEE 私钥 | Keystore 硬件保护 |
| 设备被盗 | 无法解密历史数据 | 无用户登录态 |
| 恢复出厂 | 旧密钥销毁 | TEE 安全擦除 |
| 服务端被拖库 | DEK 被 UK 加密 | 信封加密 |
| SN 被伪造 | 无法绑定/恢复 | 需短信验证 + SN 归属校验 |
| 短信被截获 | 仍需 SN 归属 | 多层校验 |
| 旧设备残留 | 已停用 | 重新注册时停用旧设备 |
安全原则
- ✅ 设备零信任 — 设备只持有签名密钥,不持有解密密钥
- ✅ 前向安全 — 每次恢复生成新密钥对
- ✅ 信封加密 — DEK 永不明文存库
- ✅ 短信 + SN 双因子 — 恢复必须两者同时通过
- ✅ 一次性令牌 — Recovery Token 含 nonce + 时间窗口
❓ 为什么 TEE 密钥销毁后依然能解密照片
这是本项目最常见的一个疑问:Android 端执行「恢复出厂」销毁了 TEE 密钥,为什么照片还能被解密?答案不是 bug,而是信封加密设计使然。
一句话结论
照片内容从来就不是用 TEE 密钥加密的,TEE 密钥对只负责「设备身份认证」,照片的加密密钥(DEK)由服务端用用户主密钥(UK)包裹后存于云端,与设备上的 TEE 密钥完全解耦。
三层密钥职责对照
| 密钥 | 生成位置 | 算法 | 真正的职责 | 销毁后的影响 |
|---|---|---|---|---|
| TEE 密钥对 | Android Keystore / StrongBox | RSA-2048 | 设备身份:① 对元数据签名(防伪造)② 解密服务端下发的 Recovery Token / DEK | 设备失去「身份」,无法再签名上传、无法走恢复链路;照片内容不受影响 |
| UK 用户主密钥 | 服务端(用户注册时) | AES-256 | 解密钥匙:加密存储所有照片的 DEK | 照片彻底无法解密(唯一关键) |
| DEK 数据加密密钥 | 设备端(每张照片随机) | AES-256 | 真正加密照片内容 | 单张照片无法解密(由 UK 包裹后存服务端) |
密码学原理:信封加密(Envelope Encryption)
照片的加密链路是 DEK → 加密照片、UK → 包裹 DEK,TEE 私钥只出现在「签名」这一步:
设备端加密照片: 随机 DEK --AES-GCM--> 照片密文
元数据 --TEE私钥签名--> 防伪造
服务端存储: DEK --UK加密--> encDEK(落库)
照片密文 + encDEK + IV 永久存储
用户下载解密: encDEK --UK解密--> DEK --AES-GCM--> 照片明文
(全程不碰设备 TEE 密钥)
对应代码:
public EncryptedPayload encryptData(byte[] plaintext) {
try {
// 1. 随机 DEK
KeyGenerator kg = KeyGenerator.getInstance("AES");
kg.init(AES_KEY_SIZE);
SecretKey dek = kg.generateKey();
...
// 3. AES-256-GCM 加密
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
// 1. UK 解密 DEK
SecretKey dek = keyManagementService.unwrapDEK(photo.getEncryptedDekBase64(), userId);
// 2. DEK 解密照片
byte[] plaintext = AesGcmUtil.decrypt(
ciphertextBase64,
photo.getIvBase64(),
dek
);
那「恢复出厂」到底破坏了什么
销毁 TEE 密钥破坏的是**「设备继续使用 / 恢复的授权能力」**,而不是「已有数据的解密能力」:
- ✅ 旧私钥没了 → 旧设备无法再签名上传、无法解密云端下发的 Recovery Token / DEK;
- ✅ 服务端随即停用旧设备;
- ✅ 想找回数据,必须重走完整恢复链路:
重新注册 → 短信验证 → 恢复授权(新公钥加密下发 Token)→ 恢复照片。
类比
TEE 密钥是「门禁卡」,UK / DEK 才是「保险柜钥匙」。门禁卡销毁后你进不了门(失去身份),但保险柜里的东西和钥匙都还在(数据可解密)。
安全含义
- 照片的解密能力始终由云端 UK + DEK 掌控,天然支持「换机恢复」「多端访问」。
- 真正的数据安全边界是 UK:一旦服务端 UK 被攻破,所有照片都能被解开——这正是 README 末尾强调「UK 生产环境必须上 KMS 托管」的原因。
- TEE 密钥的定位是「设备身份凭证」,用于防伪造、防设备被冒用,而不是「数据加密钥匙」。
⚠️ 生产环境注意事项
| Demo 简化 | 生产环境应改为 |
|---|---|
| UK 直接存 DB | KMS 托管(阿里云 KMS / AWS KMS) |
| 短信码随机生成 | 对接腾讯云/阿里云短信服务 |
| 设备/用户/照片索引已用 JPA + MySQL 持久化(见下表) | Redis 缓存热数据、读写分离 |
| 明文 DEK 传输 | 设备端用服务端公钥加密 DEK 后传输 |
| 无验签实现 | 服务端用设备公钥验证 ECDSA 签名 |
| 无频率限制 | Redis 限流(短信/API) |
| 无审计日志 | 所有密钥操作写审计表 |
| Recovery Token 无过期 | 加 5 分钟时间窗口 + Redis 防重放 |
数据持久化说明(JPA + MySQL)
设备状态、用户、照片索引已落库,服务重启 / App 重启均不丢失注册与绑定状态:
| 表名 | 对应实体 | 存储内容 |
|---|---|---|
device |
Device |
设备注册/绑定状态(SN 唯一、公钥、userId、active) |
app_user |
User |
用户 + UK + 登录密码 |
encrypted_photo |
EncryptedPhoto |
照片元数据(密文仍落盘 uploads/) |
user_photo |
UserPhoto |
用户 ↔ 照片索引 |
- 数据库脚本:
springboot-server/sql/schema.sql(含建库建表 DDL)。 - 依赖
spring.jpa.hibernate.ddl-auto=update会在启动时自动建表,也可手动执行schema.sql。 - 仍为内存态(短时效,无需持久化):短信验证码、Recovery Token nonce 防重放集合、登录 Token(重启失效符合预期)。生产环境这三者应迁至 Redis。
📋 API 接口清单
所有响应统一为 ApiResponse{code, message, data}(code=0 成功)。
用户端接口鉴权优先级:Authorization: Bearer <token>(真实场景)> X-User-Id(Android demo 兼容)> body.userId(测试兼容)。
公开(无需鉴权)
| Method | Path | 说明 |
|---|---|---|
| POST | /api/auth/register |
用户注册(返回 Token) |
| POST | /api/auth/login |
用户登录(返回 Token) |
| POST | /api/auth/logout |
登出(注销 Token) |
| GET | /api/health |
健康检查 |
设备端(无登录,信任边界 = TEE 密钥 / SN / Recovery Token)
| Method | Path | 说明 |
|---|---|---|
| GET | /api/device/status?sn=xxx |
查询设备注册/绑定状态(App 启动复用,免重复注册绑定) |
| POST | /api/device/register |
设备注册(SN + 公钥,幂等:同 SN 同公钥返回原 deviceId) |
| POST | /api/photo/upload |
上传加密照片(设备签名验签) |
| POST | /api/photo/recover |
恢复后获取照片 DEK |
注册/绑定幂等说明:
/api/device/register对「同一 SN + 相同公钥」幂等——App 重启后重复注册不会生成新 deviceId,也不会误停用自己;仅当公钥变化(恢复出厂)时才停旧换新。App 启动时建议先调GET /api/device/status?sn=xxx,若registered=true且bound=true,直接复用返回的deviceId/userId,跳过注册与绑定。
用户端(需要登录 Token)
| Method | Path | 说明 |
|---|---|---|
| POST | /api/device/bind |
绑定设备(SN) |
| POST | /api/device/sms/send |
发送短信验证码(登录态防轰炸) |
| POST | /api/device/recover |
恢复授权(短信 + SN 双因子) |
| GET | /api/photo/{photoId}/decrypt |
下载并解密照片 |
| GET | /api/user/photos |
我的照片列表 |
| GET | /api/user/devices |
我的设备列表 |