Files
secure-device-demo/README.md
TongTongStudio 93499fa189 feat: 重构为前后端分离架构并完善设备端演示
- 新增 Web 用户端(登录体系 + 统一 REST API 调用)
- 后端增加用户认证、统一 ApiResponse、CORS 支持
- Android 设备端迁移至 MVVM + DataBinding + Retrofit 网络层
- 完善 README 架构说明与密码学原理文档
- 新增 .gitignore 与持久化数据表说明
2026-08-21 04:34:58 +08:00

389 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🔐 安全设备 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 KeystoreStrongBox 优先) │
│ ├── 私钥:永不导出,仅签名/解密 │
│ ├── 公钥:上传服务端,用于加密下发 │
│ └── 销毁:恢复出厂时自动清除 │
│ │
└─────────────────────────────────────────────────────────┘
```
---
## 🔄 完整流程
### 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/ # WebConfigCORS
│ │ │ ├── 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. 后端
```bash
cd springboot-server
mvn spring-boot:run # http://localhost:8080
```
### 2. Web 用户端(前后端分离演示)
```bash
cd web-client
python3 -m http.server 3000 # 浏览器打开 http://localhost:3000
```
浏览器打开后依次体验:注册/登录 → 绑定设备 → 发送短信 → 恢复授权 → 照片列表 → 下载解密。
所有请求携带 `Authorization: Bearer <token>`,可在页面底部看到统一响应 `{code, message, data}`
### 3. Android 设备端
```bash
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
### 测试
```bash
cd springboot-server
mvn test
```
---
## 🛡️ 安全分析
### 攻击场景 vs 防护
| 攻击场景 | 结果 | 原因 |
|---|---|---|
| 设备被 root | 拿不到 TEE 私钥 | Keystore 硬件保护 |
| 设备被盗 | 无法解密历史数据 | 无用户登录态 |
| 恢复出厂 | 旧密钥销毁 | TEE 安全擦除 |
| 服务端被拖库 | DEK 被 UK 加密 | 信封加密 |
| SN 被伪造 | 无法绑定/恢复 | 需短信验证 + SN 归属校验 |
| 短信被截获 | 仍需 SN 归属 | 多层校验 |
| 旧设备残留 | 已停用 | 重新注册时停用旧设备 |
### 安全原则
1.**设备零信任** — 设备只持有签名密钥,不持有解密密钥
2.**前向安全** — 每次恢复生成新密钥对
3.**信封加密** — DEK 永不明文存库
4.**短信 + SN 双因子** — 恢复必须两者同时通过
5.**一次性令牌** — 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 密钥)
```
对应代码:
```169:195:android-app/app/src/main/java/com/secure/device/DeviceCrypto.java
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");
```
```364:372:springboot-server/src/main/java/com/secure/demo/controller/DeviceController.java
// 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` | 我的设备列表 |