feat: 重构为前后端分离架构并完善设备端演示
- 新增 Web 用户端(登录体系 + 统一 REST API 调用) - 后端增加用户认证、统一 ApiResponse、CORS 支持 - Android 设备端迁移至 MVVM + DataBinding + Retrofit 网络层 - 完善 README 架构说明与密码学原理文档 - 新增 .gitignore 与持久化数据表说明
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/springboot-server/target/
|
||||
/springboot-server/target/
|
||||
231
README.md
231
README.md
@@ -1,7 +1,8 @@
|
||||
# 🔐 安全设备 Demo — Android + Spring Boot
|
||||
# 🔐 安全设备 Demo — 前后端分离(Web 用户端 + Android 设备端 + Spring Boot)
|
||||
|
||||
> **场景**:高权限设备端(无登录)+ 用户端(有登录体系)+ SN 绑定 + 私密照片加密
|
||||
> **目标**:设备端 TEE 密钥不可导出、服务端零知识、恢复出厂后可安全恢复
|
||||
> **架构**:前后端分离 —— Web 用户端(真实登录体系)与 Android 设备端(无登录)都通过统一 REST API 访问后端
|
||||
|
||||
---
|
||||
|
||||
@@ -10,16 +11,25 @@
|
||||
```
|
||||
┌─────────────────────┐ HTTPS ┌──────────────────────┐
|
||||
│ Android 设备端 │ ◄──────────────────► │ Spring Boot 后端 │
|
||||
│ (无登录/高权限) │ │ (用户有登录体系) │
|
||||
└─────────────────────┘ └──────────────────────┘
|
||||
│ │
|
||||
│ ① TEE 密钥对(Keystore) │ ① 用户主密钥(UK)
|
||||
│ ② AES-GCM 信封加密照片 │ ② DEK 信封加密存储
|
||||
│ ③ ECDSA 签名元数据 │ ③ SN 绑定 + 短信验证
|
||||
│ ④ 恢复出厂 → 新密钥对 │ ④ Recovery Token 下发
|
||||
│ ⑤ UK 解 DEK → 设备公钥加密下发
|
||||
│ (无登录/高权限) │ │ ──────────────── │
|
||||
└─────────────────────┘ │ · 统一响应 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 鉴权
|
||||
|
||||
---
|
||||
|
||||
## 🔑 密钥分层模型
|
||||
@@ -126,15 +136,26 @@
|
||||
|
||||
```
|
||||
secure-device-demo/
|
||||
├── android-device/
|
||||
│ └── DeviceCrypto.java # Android 端完整安全模块
|
||||
├── android-app/ # Android 设备端(无登录体系)
|
||||
│ └── app/src/main/java/com/secure/
|
||||
│ ├── demo/activity/main/ # MainViewModel:设备端演示链路
|
||||
│ ├── demo/network/ # Retrofit + DeviceApi + ApiResponse
|
||||
│ └── device/DeviceCrypto.java# TEE 安全模块(Keystore 密钥对)
|
||||
│
|
||||
├── springboot-server/
|
||||
├── 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/
|
||||
@@ -150,10 +171,7 @@ secure-device-demo/
|
||||
│ │ └── resources/
|
||||
│ │ └── application.properties
|
||||
│ └── test/
|
||||
│ ├── java/com/secure/demo/
|
||||
│ │ └── IntegrationTest.java
|
||||
│ └── resources/
|
||||
│ └── application-test.properties
|
||||
│ └── java/com/secure/demo/IntegrationTest.java
|
||||
│
|
||||
└── README.md
|
||||
```
|
||||
@@ -162,13 +180,37 @@ secure-device-demo/
|
||||
|
||||
## 🚀 快速启动
|
||||
|
||||
### 后端
|
||||
### 1. 后端
|
||||
|
||||
```bash
|
||||
cd springboot-server
|
||||
mvn spring-boot:run
|
||||
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
|
||||
@@ -176,28 +218,6 @@ cd springboot-server
|
||||
mvn test
|
||||
```
|
||||
|
||||
### Android 端集成
|
||||
|
||||
将 `DeviceCrypto.java` 复制到 Android 项目的对应包路径下,
|
||||
在 `Application` 或 `MainActivity` 中初始化:
|
||||
|
||||
```java
|
||||
DeviceCrypto crypto = new DeviceCrypto(context);
|
||||
|
||||
// 注册
|
||||
String pubKey = crypto.getPublicKeyBase64();
|
||||
String sn = crypto.getDeviceSN();
|
||||
// → POST /api/device/register {sn, publicKeyBase64: pubKey}
|
||||
|
||||
// 拍照加密
|
||||
byte[] photo = capturePhoto();
|
||||
DeviceCrypto.EncryptedPayload payload = crypto.encryptData(photo);
|
||||
String signature = crypto.signMetadata(sn + "|" + timestamp + "|" + photoId);
|
||||
// → POST /api/photo/upload {sn, photoId, ciphertextBase64, ivBase64,
|
||||
// dekBase64: payload.dekBase64,
|
||||
// metadataSignature: signature, metadata}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ 安全分析
|
||||
@@ -224,30 +244,145 @@ String signature = crypto.signMetadata(sn + "|" + timestamp + "|" + photoId);
|
||||
|
||||
---
|
||||
|
||||
## ❓ 为什么 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) |
|
||||
| 短信码随机生成 | 对接腾讯云/阿里云短信服务 |
|
||||
| 内存 ConcurrentHashMap | JPA + PostgreSQL/MySQL |
|
||||
| 设备/用户/照片索引已用 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/device/register` | 设备注册(SN + 公钥) |
|
||||
| POST | `/api/device/bind` | 用户绑定设备 |
|
||||
| POST | `/api/device/sms/send` | 发送短信验证码 |
|
||||
| POST | `/api/photo/upload` | 上传加密照片 |
|
||||
| POST | `/api/device/recover` | 恢复设备(短信验证) |
|
||||
| POST | `/api/photo/recover` | 恢复后获取照片 DEK |
|
||||
| GET | `/api/photo/{id}/decrypt` | 用户下载并解密照片 |
|
||||
| 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` | 我的设备列表 |
|
||||
|
||||
@@ -12,6 +12,7 @@ android {
|
||||
targetSdkVersion 36
|
||||
versionCode 1
|
||||
versionName "1.0"
|
||||
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
@@ -21,10 +22,21 @@ android {
|
||||
|
||||
buildFeatures {
|
||||
buildConfig true
|
||||
dataBinding true
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'androidx.appcompat:appcompat:1.6.1'
|
||||
implementation 'com.google.android.material:material:1.11.0'
|
||||
|
||||
// 网络层(技术栈对齐 WebRTCController:Retrofit + OkHttp + Gson + RxJava3)
|
||||
implementation 'com.squareup.retrofit2:retrofit:3.0.0'
|
||||
implementation 'com.squareup.retrofit2:converter-gson:3.0.0'
|
||||
implementation 'com.squareup.retrofit2:adapter-rxjava3:3.0.0'
|
||||
implementation 'com.squareup.okhttp3:okhttp:5.3.2'
|
||||
implementation 'com.squareup.okhttp3:logging-interceptor:5.3.2'
|
||||
implementation 'io.reactivex.rxjava3:rxjava:3.1.12'
|
||||
implementation 'io.reactivex.rxjava3:rxandroid:3.0.2'
|
||||
implementation 'com.google.code.gson:gson:2.14.0'
|
||||
}
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- 网络请求权限 -->
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
|
||||
<!-- 文件读写权限(分区存储兼容) -->
|
||||
<!-- Android 13+ 细粒度媒体权限 -->
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||
<!-- Android 9- 旧式存储权限(maxSdk 限制为 Q 之前,Q+ 走分区存储 / SAF) -->
|
||||
<uses-permission
|
||||
android:name="android.permission.READ_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission
|
||||
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="29" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:label="@string/app_name"
|
||||
android:usesCleartextTraffic="true"
|
||||
android:theme="@style/Theme.SecureDevice">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:name=".activity.main.MainActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
package com.secure.demo;
|
||||
|
||||
import android.os.Bundle;
|
||||
import android.widget.TextView;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import com.secure.device.DeviceCrypto;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* 最精简演示:在 KeyStore(TEE) 中生成 RSA-2048 密钥对,
|
||||
* 完成「信封加密 → 解密还原」与「元数据签名 → 公钥验签」全流程,
|
||||
* 无需任何后端即可在真机/模拟器上验证 DeviceCrypto 可用。
|
||||
*/
|
||||
public class MainActivity extends AppCompatActivity {
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
setContentView(R.layout.activity_main);
|
||||
TextView tv = findViewById(R.id.log);
|
||||
|
||||
new Thread(() -> {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
try {
|
||||
DeviceCrypto crypto = new DeviceCrypto(this);
|
||||
|
||||
String pub = crypto.getPublicKeyBase64();
|
||||
sb.append("设备公钥(前48字符):\n").append(pub, 0, Math.min(48, pub.length()))
|
||||
.append("...\n\n");
|
||||
|
||||
// 1) 信封加密(模拟拍照)
|
||||
String plaintext = "私密照片数据 Hello TEE!";
|
||||
DeviceCrypto.EncryptedPayload p = crypto.encryptData(plaintext.getBytes("UTF-8"));
|
||||
sb.append("原始数据: ").append(plaintext).append("\n");
|
||||
sb.append("密文: ").append(p.ciphertextBase64, 0, Math.min(32, p.ciphertextBase64.length()))
|
||||
.append("...\n");
|
||||
sb.append("IV: ").append(p.ivBase64).append("\n");
|
||||
sb.append("DEK(明文, 实际应仅经HTTPS给服务端): ")
|
||||
.append(p.dekBase64, 0, Math.min(24, p.dekBase64.length())).append("...\n\n");
|
||||
|
||||
// 2) 服务端用 UK 加密 DEK 后下发,此处用相同 DEK 还原,验证解密链路
|
||||
byte[] dekBytes = Base64.getDecoder().decode(p.dekBase64);
|
||||
byte[] decrypted = crypto.decryptPhoto(p.ciphertextBase64, p.ivBase64, dekBytes);
|
||||
sb.append("解密还原: ").append(new String(decrypted, "UTF-8")).append("\n\n");
|
||||
|
||||
// 3) 元数据签名 + 公钥验签
|
||||
String metadata = crypto.getDeviceSN() + "|" + System.currentTimeMillis() + "|photo001";
|
||||
String sig = crypto.signMetadata(metadata);
|
||||
sb.append("元数据: ").append(metadata).append("\n");
|
||||
sb.append("签名(前32字符): ").append(sig, 0, Math.min(32, sig.length())).append("...\n");
|
||||
boolean ok = verify(metadata, sig, pub);
|
||||
sb.append("验签结果: ").append(ok ? "通过 ✅" : "失败 ❌").append("\n");
|
||||
} catch (Exception e) {
|
||||
sb.append("异常: ").append(e).append("\n");
|
||||
e.printStackTrace();
|
||||
}
|
||||
final String text = sb.toString();
|
||||
runOnUiThread(() -> tv.setText(text));
|
||||
}).start();
|
||||
}
|
||||
|
||||
private boolean verify(String metadata, String sigBase64, String pubKeyBase64) throws Exception {
|
||||
java.security.PublicKey pub = KeyFactory.getInstance("RSA")
|
||||
.generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(pubKeyBase64)));
|
||||
Signature s = Signature.getInstance("SHA256withRSA");
|
||||
s.initVerify(pub);
|
||||
s.update(metadata.getBytes("UTF-8"));
|
||||
return s.verify(Base64.getDecoder().decode(sigBase64));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,292 @@
|
||||
package com.secure.demo.activity.main;
|
||||
|
||||
import android.net.Uri;
|
||||
import android.os.Bundle;
|
||||
import android.text.Layout;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.widget.Toast;
|
||||
|
||||
import androidx.annotation.Nullable;
|
||||
|
||||
import com.secure.demo.R;
|
||||
import com.secure.demo.base.mvvm.BaseMvvmActivity;
|
||||
import com.secure.demo.databinding.ActivityMainBinding;
|
||||
import com.secure.demo.network.ApiClient;
|
||||
import com.secure.demo.util.PermissionHelper;
|
||||
import com.secure.demo.util.SafPicker;
|
||||
|
||||
/**
|
||||
* 主界面(标准 MVVM)—— 模拟真实用户操作。
|
||||
*
|
||||
* 视图层仅负责:
|
||||
* - 渲染 ViewModel 暴露的 LiveData(日志 / 忙状态 / 提示 / 状态摘要 / 选中图片预览);
|
||||
* - 处理运行权限(兼容 Android 10+ 分区存储);
|
||||
* - 通过 SAF 拉起系统图片选择器(免权限、可持久化 URI);
|
||||
* - 把 9 个真实操作按钮的点击与输入框内容转发给 ViewModel,不持有业务逻辑。
|
||||
*
|
||||
* 所有 View 均通过 DataBinding({@code ActivityMainBinding})访问,不使用 findViewById。
|
||||
*
|
||||
* 不再自动跑一键演示链路:打开页面仅做本地 TEE 初始化(模拟设备开机),
|
||||
* 之后每一步(注册/绑定/上传/下载/恢复出厂/重新注册/短信/恢复授权/恢复照片)
|
||||
* 均由用户手动触发,模拟真实操作流程。
|
||||
*/
|
||||
public class MainActivity extends BaseMvvmActivity<ActivityMainBinding, MainViewModel> {
|
||||
|
||||
private static final int REQ_STORAGE_PERMISSION = 1001;
|
||||
|
||||
private SafPicker safPicker;
|
||||
|
||||
@Override
|
||||
protected int getLayoutId() {
|
||||
return R.layout.activity_main;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Class<MainViewModel> getViewModelClass() {
|
||||
return MainViewModel.class;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onReady(@Nullable Bundle savedInstanceState) {
|
||||
// 网络栈初始化(必须在首次发起请求前调用,否则 deviceApi() 会抛异常)
|
||||
ApiClient.init(getApplicationContext());
|
||||
|
||||
// 把每次网络请求(method/url/状态码/耗时)实时显示到界面日志区
|
||||
ApiClient.setHttpLogListener(msg -> viewModel.ui.appendLog(msg));
|
||||
|
||||
observeViewModel();
|
||||
// 将点击处理器注入 DataBinding,供布局中的 android:onClick="@{click::xxx}" 方法引用调用
|
||||
binding.setClick(new ViewClick());
|
||||
setupLogScrolling();
|
||||
|
||||
// SAF 选择器(生命周期安全的 ActivityResult 注册)
|
||||
safPicker = new SafPicker(this, uri -> viewModel.uploadSelectedImage(uri));
|
||||
|
||||
// 模拟设备开机:仅做本地 TEE 自检,不自动跑网络链路。
|
||||
// 必须在 observeViewModel() 之后调用,确保 busy 观察者已注册、能正确接收状态变化
|
||||
viewModel.initDevice();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onDestroy() {
|
||||
ApiClient.setHttpLogListener(null);
|
||||
super.onDestroy();
|
||||
}
|
||||
|
||||
/** 解决日志 TextView 在 NestedScrollView 中的滑动冲突:内容可滚动时禁止父级拦截 */
|
||||
private void setupLogScrolling() {
|
||||
binding.log.setMovementMethod(new android.text.method.ScrollingMovementMethod());
|
||||
binding.log.setOnTouchListener((v, event) -> {
|
||||
if (v.getId() == R.id.log) {
|
||||
v.getParent().requestDisallowInterceptTouchEvent(true);
|
||||
if (event.getAction() == MotionEvent.ACTION_UP) {
|
||||
v.getParent().requestDisallowInterceptTouchEvent(false);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
/** 观察 UI 状态 LiveData,单向渲染 */
|
||||
private void observeViewModel() {
|
||||
viewModel.ui.getLogLines().observe(this, lines -> {
|
||||
binding.log.setText(lines);
|
||||
binding.log.post(() -> {
|
||||
Layout layout = binding.log.getLayout();
|
||||
if (layout != null) {
|
||||
int scrollAmount = layout.getLineTop(binding.log.getLineCount()) - binding.log.getHeight();
|
||||
if (scrollAmount > 0) {
|
||||
binding.log.scrollTo(0, scrollAmount);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
viewModel.ui.getStatus().observe(this, binding.status::setText);
|
||||
viewModel.ui.getBusy().observe(this, busy -> updateControlsState());
|
||||
viewModel.ui.getCurrentStep().observe(this, step -> updateControlsState());
|
||||
viewModel.ui.getRegistered().observe(this, registered -> updateControlsState());
|
||||
viewModel.ui.getBound().observe(this, bound -> updateControlsState());
|
||||
viewModel.ui.getToast().observe(this, msg -> {
|
||||
if (msg != null) {
|
||||
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show();
|
||||
}
|
||||
});
|
||||
viewModel.ui.getSelectedImageUri().observe(this, uri -> {
|
||||
if (uri != null) {
|
||||
binding.imgPreview.setImageURI(Uri.parse(uri));
|
||||
}
|
||||
});
|
||||
viewModel.ui.getDecryptedImageBase64().observe(this, base64 -> {
|
||||
if (base64 != null && !base64.isEmpty()) {
|
||||
displayDecryptedImage(base64);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 将解密后的图片明文(Base64)解码为 Bitmap 并显示到预览区。
|
||||
*
|
||||
* 注意:Bitmap 解码属于 CPU 密集操作,必须放到后台线程执行,避免主线程 ANR。
|
||||
* 解码完成后通过 runOnUiThread 回到主线程更新 ImageView。
|
||||
*/
|
||||
private void displayDecryptedImage(String base64) {
|
||||
java.util.concurrent.ExecutorService exec = java.util.concurrent.Executors.newSingleThreadExecutor();
|
||||
exec.execute(() -> {
|
||||
try {
|
||||
byte[] bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT);
|
||||
android.graphics.Bitmap bitmap = android.graphics.BitmapFactory.decodeByteArray(bytes, 0, bytes.length);
|
||||
runOnUiThread(() -> {
|
||||
if (bitmap != null) {
|
||||
binding.imgPreview.setImageBitmap(bitmap);
|
||||
} else {
|
||||
binding.imgPreview.setImageDrawable(null);
|
||||
}
|
||||
});
|
||||
} catch (Exception e) {
|
||||
// 非图片明文(如纯文本测试数据)无法解码为 Bitmap,忽略即可
|
||||
} finally {
|
||||
exec.shutdown();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 综合「忙状态」与「业务进度/注册绑定状态」更新所有控件的可用性 */
|
||||
private void updateControlsState() {
|
||||
boolean isBusy = Boolean.TRUE.equals(viewModel.ui.getBusy().getValue());
|
||||
Integer stepVal = viewModel.ui.getCurrentStep().getValue();
|
||||
int step = stepVal != null ? stepVal : 0;
|
||||
boolean registered = Boolean.TRUE.equals(viewModel.ui.getRegistered().getValue());
|
||||
boolean bound = Boolean.TRUE.equals(viewModel.ui.getBound().getValue());
|
||||
|
||||
// 忙时整体禁用,闲时按「注册/绑定」状态 + 流程进度开启
|
||||
boolean idle = !isBusy;
|
||||
|
||||
// ---- 核心业务按钮:由「注册/绑定」状态驱动(不再依赖单一 step 数字)----
|
||||
// 注册:未注册时可点(注册成功后自动置 registered=true,按钮随之禁用)
|
||||
binding.btnRegister.setEnabled(idle && !registered);
|
||||
// 绑定:已注册但未绑定时可点
|
||||
binding.btnBind.setEnabled(idle && registered && !bound);
|
||||
// 上传图片:已绑定时可点(已绑定设备随时可继续上传,无需回到 step==3)
|
||||
binding.btnUploadImage.setEnabled(idle && bound);
|
||||
// 下载解密:已绑定时可点(ViewModel 内部对「尚未上传」有 toast 兜底)
|
||||
binding.btnDownload.setEnabled(idle && bound);
|
||||
// 恢复出厂:只要初始化完成即可点(本地 TEE 操作,与注册/绑定无关)
|
||||
binding.btnReset.setEnabled(idle);
|
||||
|
||||
// ---- 恢复链路按钮:仍由 step 引导(恢复出厂后的专用流程)----
|
||||
binding.btnReregister.setEnabled(idle && step == 6);
|
||||
binding.btnSms.setEnabled(idle && step == 7);
|
||||
binding.btnRecover.setEnabled(idle && step == 8);
|
||||
binding.btnRecoverPhotos.setEnabled(idle && step == 9);
|
||||
|
||||
// ---- 输入框逻辑 ----
|
||||
// 用户ID/手机号:未绑定时可编辑(首次绑定阶段)
|
||||
binding.userId.setEnabled(idle && !bound);
|
||||
binding.phone.setEnabled(idle && (!bound || step == 7));
|
||||
// 短信验证码:恢复授权阶段可编辑
|
||||
binding.smsCode.setEnabled(idle && step >= 7);
|
||||
|
||||
// ---- 文字反馈 ----
|
||||
binding.btnUploadImage.setText(isBusy && step == 3 ? "上传中..." : "③ 选择并上传图片");
|
||||
if (isBusy) {
|
||||
binding.status.setText("⏳ 正在处理,请稍候...");
|
||||
}
|
||||
}
|
||||
|
||||
/** 权限适配:Android 10+ 无需权限,直接 SAF;旧设备按需申请 */
|
||||
private void ensurePermissionThenPick() {
|
||||
if (PermissionHelper.needsRequestPermission(this)) {
|
||||
requestPermissions(
|
||||
PermissionHelper.getMissingImagePermissions(this)
|
||||
.toArray(new String[0]),
|
||||
REQ_STORAGE_PERMISSION);
|
||||
} else {
|
||||
// Android 10+:分区存储 / SAF 路径,无需危险权限
|
||||
safPicker.openImagePicker();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode,
|
||||
@androidx.annotation.NonNull String[] permissions,
|
||||
@androidx.annotation.NonNull int[] grantResults) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
if (requestCode == REQ_STORAGE_PERMISSION) {
|
||||
boolean allGranted = true;
|
||||
for (int r : grantResults) {
|
||||
if (r != android.content.pm.PackageManager.PERMISSION_GRANTED) {
|
||||
allGranted = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (allGranted) {
|
||||
safPicker.openImagePicker();
|
||||
} else {
|
||||
// 用户拒绝:仍可走 SAF(仍可访问自己选中的文件),提示并降级
|
||||
Toast.makeText(this, "权限被拒,将使用系统选择器(SAF)方式", Toast.LENGTH_SHORT).show();
|
||||
safPicker.openImagePicker();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击处理器(DataBinding 方法引用式绑定)。
|
||||
*
|
||||
* 布局中以 {@code android:onClick="@{click::methodName}"} 引用这里的方法;
|
||||
* 方法签名必须为 {@code public void xxx(android.view.View)},与
|
||||
* {@link android.view.View.OnClickListener#onClick(View)} 一致。
|
||||
*
|
||||
* 此类为 MainActivity 的非静态内部类,可直接访问外部 Activity 的
|
||||
* {@code binding} / {@code viewModel} 等成员。
|
||||
*/
|
||||
public class ViewClick {
|
||||
|
||||
/** ① 注册设备 */
|
||||
public void registerDevice(View view) {
|
||||
viewModel.registerDevice();
|
||||
}
|
||||
|
||||
/** ② 绑定用户(读取输入框文本) */
|
||||
public void bindUser(View view) {
|
||||
viewModel.bindUser(
|
||||
binding.userId.getText().toString().trim(),
|
||||
binding.phone.getText().toString().trim());
|
||||
}
|
||||
|
||||
/** ③ 选择并上传图片(权限 + SAF 属 UI 职责,由 Activity 处理) */
|
||||
public void uploadImage(View view) {
|
||||
ensurePermissionThenPick();
|
||||
}
|
||||
|
||||
/** ④ 下载解密照片 */
|
||||
public void downloadPhoto(View view) {
|
||||
viewModel.downloadLastPhoto();
|
||||
}
|
||||
|
||||
/** ⑤ 恢复出厂 */
|
||||
public void resetDevice(View view) {
|
||||
viewModel.resetDevice();
|
||||
}
|
||||
|
||||
/** ⑥ 重新注册 */
|
||||
public void reRegisterDevice(View view) {
|
||||
viewModel.reRegisterDevice();
|
||||
}
|
||||
|
||||
/** ⑦ 发送短信验证码(读取手机号) */
|
||||
public void sendSms(View view) {
|
||||
viewModel.sendSms(binding.phone.getText().toString().trim());
|
||||
}
|
||||
|
||||
/** ⑧ 恢复授权(读取短信验证码) */
|
||||
public void recoverDevice(View view) {
|
||||
viewModel.recoverDevice(binding.smsCode.getText().toString().trim());
|
||||
}
|
||||
|
||||
/** ⑨ 恢复照片 */
|
||||
public void recoverPhotos(View view) {
|
||||
viewModel.recoverPhotos();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,650 @@
|
||||
package com.secure.demo.activity.main;
|
||||
|
||||
import android.app.Application;
|
||||
import android.net.Uri;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
import com.secure.demo.base.mvvm.BaseViewModel;
|
||||
import com.secure.demo.base.mvvm.UiState;
|
||||
import com.secure.demo.network.ApiClient;
|
||||
import com.secure.demo.network.DeviceApi;
|
||||
import com.secure.demo.network.model.ApiResponse;
|
||||
import com.secure.demo.network.model.BindRequest;
|
||||
import com.secure.demo.network.model.DeviceStatusResponse;
|
||||
import com.secure.demo.network.model.DownloadDecryptResponse;
|
||||
import com.secure.demo.network.model.EncryptedDek;
|
||||
import com.secure.demo.network.model.MessageResponse;
|
||||
import com.secure.demo.network.model.PhotoRecoverRequest;
|
||||
import com.secure.demo.network.model.PhotoRecoverResponse;
|
||||
import com.secure.demo.network.model.RecoverRequest;
|
||||
import com.secure.demo.network.model.RecoverResponse;
|
||||
import com.secure.demo.network.model.RegisterRequest;
|
||||
import com.secure.demo.network.model.RegisterResponse;
|
||||
import com.secure.demo.network.model.SmsRequest;
|
||||
import com.secure.demo.network.model.UploadPhotoRequest;
|
||||
import com.secure.demo.network.model.UploadPhotoResponse;
|
||||
import com.secure.device.DeviceCrypto;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
import io.reactivex.rxjava3.schedulers.Schedulers;
|
||||
|
||||
/**
|
||||
* 主界面 ViewModel(标准 MVVM)。
|
||||
* <p>
|
||||
* 模拟真实用户操作:不再一键自动跑完整链路,而是把整条安全链路拆成
|
||||
* 独立的、由用户逐步手动触发的操作(对应真实使用流程):
|
||||
* <p>
|
||||
* ① 设备注册 → ② 绑定用户 → ③ 加密上传照片 → ④ 下载解密照片
|
||||
* → ⑤ 恢复出厂(销毁 TEE 密钥) → ⑥ 重新注册 → ⑦ 发送短信验证码
|
||||
* → ⑧ 恢复授权(短信+SN归属双因子) → ⑨ 恢复照片(取回 DEK 并还原)。
|
||||
* <p>
|
||||
* 每一步都维护设备状态(SN/公钥/deviceId/userId),并通过 {@link UiState#setStatus(String)}
|
||||
* 实时展示当前设备状态摘要,方便观察操作顺序对状态的影响。
|
||||
*/
|
||||
public class MainViewModel extends BaseViewModel {
|
||||
|
||||
private static final String TAG = "MainViewModel";
|
||||
|
||||
private final Application app;
|
||||
public final UiState ui = new UiState();
|
||||
|
||||
// ==================== 设备/用户状态(跨步骤保持) ====================
|
||||
|
||||
/** 本地 TEE 密钥载体(初始化后保持复用) */
|
||||
private DeviceCrypto crypto;
|
||||
/** 是否已完成开机自检(ViewModel 跨 Activity 重建复用时避免重复初始化) */
|
||||
private boolean initialized;
|
||||
/** 设备 SN(TEE 派生,固定不变) */
|
||||
private String sn;
|
||||
/** 当前设备公钥(恢复出厂后轮换) */
|
||||
private String pubKey;
|
||||
/** 当前后端注册的设备 ID(恢复出厂后重新注册会变更) */
|
||||
private String deviceId;
|
||||
/** 是否已绑定用户(准确追踪绑定态,驱动 UI 按钮可用性) */
|
||||
private boolean bound;
|
||||
/** 当前绑定/操作的用户 */
|
||||
private String userId = "user-001";
|
||||
/** 用户手机号(绑定与短信必须一致,恢复校验依赖) */
|
||||
private String phone = "13800138000";
|
||||
/** 恢复授权后 TEE 解出的明文 Recovery Token(一次性) */
|
||||
private String recoveryToken;
|
||||
|
||||
// ==================== 最近上传照片的密文/IV(下载与恢复还原用) ====================
|
||||
|
||||
private String lastPhotoId;
|
||||
private String lastCiphertextBase64;
|
||||
private String lastIvBase64;
|
||||
|
||||
public MainViewModel(@NonNull Application application) {
|
||||
super(application);
|
||||
this.app = application;
|
||||
}
|
||||
|
||||
// ==================== 通用步骤执行器 ====================
|
||||
|
||||
/**
|
||||
* 以「一步真实操作」为单位执行:忙状态 → 订阅 → 成功回调(主线程) / 失败提示。
|
||||
* 加密/解密等耗时操作请放在 {@code single} 内部并通过 Schedulers.computation() 调度。
|
||||
*/
|
||||
private <T> void runStep(String step, Single<T> single, Consumer<T> onOk) {
|
||||
ui.setBusy(true);
|
||||
Disposable d = single
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
data -> {
|
||||
try {
|
||||
onOk.accept(data);
|
||||
} catch (Exception ex) {
|
||||
Log.e(TAG, step + " 结果处理失败: " + ex.getMessage(), ex);
|
||||
ui.appendLog("❌ " + step + " 结果处理失败: " + ex.getMessage());
|
||||
ui.postToast(step + " 失败: " + ex.getMessage());
|
||||
}
|
||||
ui.setBusy(false);
|
||||
},
|
||||
e -> {
|
||||
Log.e(TAG, step + " 失败: " + e.getMessage(), e);
|
||||
ui.appendLog("❌ " + step + " 失败: " + e.getMessage());
|
||||
ui.postToast(step + " 失败: " + e.getMessage());
|
||||
ui.setBusy(false);
|
||||
}
|
||||
);
|
||||
addDisposable(d);
|
||||
}
|
||||
|
||||
// ==================== 设备开机:本地 TEE 初始化(无网络) ====================
|
||||
|
||||
/**
|
||||
* 模拟设备开机:本地 TEE 自检(生成密钥对、信封加解密自测、元数据签名自测)。
|
||||
* 由视图层在页面就绪后自动调用一次。
|
||||
*/
|
||||
public void initDevice() {
|
||||
if (initialized) {
|
||||
return; // 已初始化(旋转/重建后 ViewModel 复用),避免重复自检
|
||||
}
|
||||
ui.appendLog("—— 设备开机,本地 TEE 自检(模拟真实设备启动)——");
|
||||
ui.appendLog(" 正在初始化 TEE 密钥对(首次生成 RSA-2048 可能需数秒,请稍候)...");
|
||||
ui.setBusy(true);
|
||||
Disposable d = Single.fromCallable(() -> {
|
||||
// 先探测是否已有持久化密钥,用于区分「首次生成」与「复用」
|
||||
boolean alreadyExists = new DeviceCrypto(app).hasKeyPair();
|
||||
crypto = new DeviceCrypto(app); // 内部 containsAlias 已保证:存在则不重新生成
|
||||
sn = crypto.getDeviceSN();
|
||||
pubKey = crypto.getPublicKeyBase64();
|
||||
return new SelfTestResult(alreadyExists, localSelfTest(crypto, pubKey));
|
||||
})
|
||||
.subscribeOn(Schedulers.computation())
|
||||
// 兜底:Keystore 生成异常慢/异常时避免界面永远停在「处理中」
|
||||
.timeout(30, TimeUnit.SECONDS)
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
result -> {
|
||||
initialized = true;
|
||||
ui.appendLog(result.alreadyExists
|
||||
? " TEE 密钥对已存在,复用持久化密钥(未重新生成)"
|
||||
: " TEE 密钥对已新建并持久化");
|
||||
ui.appendLog(result.selfTest);
|
||||
ui.appendLog("设备就绪,正在查询云端注册/绑定状态...");
|
||||
// 本地自检完成后,查询后端是否已注册/绑定,复用已有状态
|
||||
queryDeviceStatus();
|
||||
},
|
||||
e -> {
|
||||
Log.e(TAG, "TEE 初始化失败: " + e.getMessage(), e);
|
||||
ui.appendLog("❌ TEE 初始化失败: " + e.getMessage());
|
||||
ui.appendLog(" 可重新打开页面再次尝试(若为密钥生成超时,请稍候再试)");
|
||||
ui.postToast("TEE 初始化失败: " + e.getMessage());
|
||||
ui.setBusy(false); // 失败/超时也要恢复按钮
|
||||
}
|
||||
);
|
||||
addDisposable(d);
|
||||
}
|
||||
|
||||
/** 本地自检(无需后端) */
|
||||
private String localSelfTest(DeviceCrypto crypto, String pub) throws Exception {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("TEE 密钥对已生成(Android Keystore)\n");
|
||||
sb.append("设备 SN: ").append(sn).append("\n");
|
||||
sb.append("设备公钥(前48): ").append(pub, 0, Math.min(48, pub.length())).append("...\n");
|
||||
|
||||
DeviceCrypto.EncryptedPayload p = crypto.encryptData("本地自检数据".getBytes(StandardCharsets.UTF_8));
|
||||
byte[] dek = Base64.getDecoder().decode(p.dekBase64);
|
||||
String restored = new String(crypto.decryptPhoto(p.ciphertextBase64, p.ivBase64, dek), StandardCharsets.UTF_8);
|
||||
sb.append("信封加密→解密: ").append(restored).append("\n");
|
||||
|
||||
String meta = crypto.getDeviceSN() + "|" + System.currentTimeMillis() + "|local";
|
||||
String sig = crypto.signMetadata(meta);
|
||||
boolean ok = verify(meta, sig, pub);
|
||||
sb.append("元数据签名→验签: ").append(ok ? "通过" : "失败").append("\n");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** 开机自检结果:是否复用已有密钥 + 自检日志 */
|
||||
private static class SelfTestResult {
|
||||
final boolean alreadyExists;
|
||||
final String selfTest;
|
||||
|
||||
SelfTestResult(boolean alreadyExists, String selfTest) {
|
||||
this.alreadyExists = alreadyExists;
|
||||
this.selfTest = selfTest;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 开机查询云端状态(复用注册/绑定) ====================
|
||||
|
||||
/**
|
||||
* 本地自检完成后,查询后端该 SN 是否已注册/已绑定。
|
||||
*
|
||||
* - 已注册且已绑定:直接复用 deviceId / userId,跳到「下载解密」可用步骤,
|
||||
* 用户无需再手动注册 + 绑定(App 重启后免重复操作)。
|
||||
* - 已注册但未绑定:复用 deviceId,跳到「绑定」步骤。
|
||||
* - 未注册:保持原流程,从「注册设备」开始。
|
||||
*
|
||||
* 后端接口 GET /api/device/status?sn=xxx(设备端无登录)。
|
||||
*/
|
||||
private void queryDeviceStatus() {
|
||||
Disposable d = ApiClient.deviceApi().deviceStatus(sn)
|
||||
.map(ApiResponse::getData)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe(
|
||||
s -> {
|
||||
if (s.isRegistered()) {
|
||||
deviceId = s.getDeviceId();
|
||||
if (s.isBound() && s.getUserId() != null && !s.getUserId().isBlank()) {
|
||||
// 已注册且已绑定:复用,跳到可用状态
|
||||
userId = s.getUserId();
|
||||
bound = true;
|
||||
ApiClient.saveUserId(userId); // 后续鉴权接口自动携带 X-User-Id
|
||||
ui.appendLog("✅ 云端已有注册/绑定记录,已复用:deviceId=" + deviceId
|
||||
+ ",userId=" + userId + "(免重新注册绑定)");
|
||||
ui.appendLog(" 可直接点击「选择并上传图片」继续,或点击「下载解密照片」");
|
||||
ui.setCurrentStep(4); // 直接进入可用状态(上传/下载/恢复出厂均可用)
|
||||
} else {
|
||||
// 已注册但未绑定:复用 deviceId,跳到绑定步骤
|
||||
ui.appendLog("✅ 云端已有注册记录,已复用:deviceId=" + deviceId + "(尚未绑定用户)");
|
||||
ui.appendLog(" 请输入用户 ID 与手机号后点击「绑定用户」");
|
||||
ui.setCurrentStep(2); // 进入第2步:绑定
|
||||
}
|
||||
} else {
|
||||
// 未注册:保持初始流程
|
||||
ui.appendLog(" 云端未查询到注册记录,需首次注册");
|
||||
ui.appendLog("设备就绪,可开始操作:先点击「注册设备」");
|
||||
ui.setCurrentStep(1); // 进入第1步:注册
|
||||
}
|
||||
refreshStatus();
|
||||
ui.setBusy(false);
|
||||
},
|
||||
e -> {
|
||||
// 查询失败(如后端未启动):设备本地已就绪,回退到手动注册流程
|
||||
Log.w(TAG, "查询云端状态失败,回退到手动注册: " + e.getMessage());
|
||||
ui.appendLog("⚠️ 查询云端状态失败(后端未启动或网络异常),回退到手动流程");
|
||||
ui.appendLog("设备就绪,可开始操作:先点击「注册设备」");
|
||||
ui.setCurrentStep(1);
|
||||
refreshStatus();
|
||||
ui.setBusy(false);
|
||||
}
|
||||
);
|
||||
addDisposable(d);
|
||||
}
|
||||
|
||||
// ==================== ① 设备注册 ====================
|
||||
|
||||
/**
|
||||
* 注册设备(设备端无登录):SN + 公钥 上报后端。
|
||||
* 真实场景:用户购买设备后首次联网激活。
|
||||
*/
|
||||
public void registerDevice() {
|
||||
if (!requireCrypto()) return;
|
||||
ui.appendLog("—— ① 设备注册 ——");
|
||||
runStep("设备注册",
|
||||
ApiClient.deviceApi().registerDevice(new RegisterRequest(sn, pubKey))
|
||||
.map(ApiResponse::getData),
|
||||
(RegisterResponse r) -> {
|
||||
deviceId = r.getDeviceId();
|
||||
ui.appendLog("✅ 注册成功: deviceId=" + deviceId + "(" + r.getMessage() + ")");
|
||||
ui.appendLog(" 下一步:输入用户 ID 与手机号后点击「绑定用户」");
|
||||
ui.setCurrentStep(2); // 进入第2步:绑定
|
||||
refreshStatus();
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ② 绑定用户 ====================
|
||||
|
||||
/**
|
||||
* 用户绑定设备(用户端鉴权):X-User-Id 注入 + SN + 手机号。
|
||||
* 真实场景:用户在 App/网页端登录后扫码或输码绑定自己的设备。
|
||||
*/
|
||||
public void bindUser(String userId, String phone) {
|
||||
if (deviceId == null) {
|
||||
ui.postToast("请先完成「设备注册」");
|
||||
return;
|
||||
}
|
||||
this.userId = userId;
|
||||
this.phone = phone;
|
||||
ApiClient.saveUserId(userId); // 后续接口自动携带 X-User-Id
|
||||
ui.appendLog("—— ② 绑定用户 ——");
|
||||
runStep("绑定用户",
|
||||
ApiClient.deviceApi().bindDevice(new BindRequest(userId, sn, phone))
|
||||
.map(ApiResponse::getData),
|
||||
(MessageResponse r) -> {
|
||||
bound = true;
|
||||
ui.appendLog("✅ 绑定成功: " + userId + " ↔ " + sn
|
||||
+ "(已保存身份,后续接口自动携带 X-User-Id)");
|
||||
ui.appendLog(" 下一步:点击「选择并上传图片」");
|
||||
ui.setCurrentStep(3); // 进入第3步:上传
|
||||
refreshStatus();
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ③ 图片加密上传 ====================
|
||||
|
||||
/**
|
||||
* 从 SAF 返回的 URI 读取图片字节 → 信封加密 + 签名 → 上传。
|
||||
* 读法兼容 SAF(ContentResolver 持久化 URI,无需存储权限)。
|
||||
*/
|
||||
public void uploadSelectedImage(Uri uri) {
|
||||
if (uri == null) {
|
||||
ui.postToast("未选择图片");
|
||||
return;
|
||||
}
|
||||
if (deviceId == null) {
|
||||
ui.postToast("请先完成「设备注册」与「绑定用户」");
|
||||
return;
|
||||
}
|
||||
ui.setSelectedImageUri(uri.toString());
|
||||
ui.appendLog("—— ③ 加密上传所选图片(SAF 选择)——");
|
||||
ui.setBusy(true);
|
||||
|
||||
Disposable imgChain = Single.fromCallable(() -> {
|
||||
byte[] imageBytes = readUriBytes(uri);
|
||||
return new ImageInit(crypto, imageBytes);
|
||||
})
|
||||
.subscribeOn(Schedulers.io())
|
||||
.flatMap(r -> encryptAndUploadImage(r.crypto, r.imageBytes)
|
||||
.subscribeOn(Schedulers.computation()))
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.doOnSuccess(resp -> {
|
||||
ui.appendLog("✅ 图片上传完成: photoId=" + resp.getPhotoId()
|
||||
+ "(服务端已用设备公钥验签 + UK 信封加密 DEK)");
|
||||
ui.appendLog(" 下一步:点击「下载解密照片」");
|
||||
ui.setCurrentStep(4); // 进入第4步:下载
|
||||
})
|
||||
.doFinally(() -> ui.setBusy(false))
|
||||
.subscribe(
|
||||
resp -> ui.postToast("图片上传成功: " + resp.getPhotoId()),
|
||||
e -> {
|
||||
ui.appendLog("❌ 图片上传失败: " + e.getMessage());
|
||||
ui.postToast("图片上传失败: " + e.getMessage());
|
||||
}
|
||||
);
|
||||
addDisposable(imgChain);
|
||||
}
|
||||
|
||||
/** 图片上传初始化载体:已读取字节的 DeviceCrypto 与图片数据 */
|
||||
private static class ImageInit {
|
||||
final DeviceCrypto crypto;
|
||||
final byte[] imageBytes;
|
||||
|
||||
ImageInit(DeviceCrypto crypto, byte[] imageBytes) {
|
||||
this.crypto = crypto;
|
||||
this.imageBytes = imageBytes;
|
||||
}
|
||||
}
|
||||
|
||||
private Single<UploadPhotoResponse> encryptAndUploadImage(DeviceCrypto crypto, byte[] imageBytes) {
|
||||
String sn = crypto.getDeviceSN();
|
||||
String photoId = "photo-" + System.currentTimeMillis();
|
||||
DeviceCrypto.EncryptedPayload p = crypto.encryptData(imageBytes);
|
||||
String metadata = sn + "|" + System.currentTimeMillis() + "|" + photoId;
|
||||
String sig = crypto.signMetadata(metadata); // TEE 私钥签名,服务端验签
|
||||
|
||||
lastPhotoId = photoId;
|
||||
lastCiphertextBase64 = p.ciphertextBase64;
|
||||
lastIvBase64 = p.ivBase64;
|
||||
|
||||
ui.appendLog(" 图片加密: photoId=" + photoId
|
||||
+ ",大小=" + imageBytes.length + " 字节,DEK 已生成");
|
||||
|
||||
UploadPhotoRequest req = new UploadPhotoRequest(sn, photoId,
|
||||
p.ciphertextBase64, p.ivBase64, p.dekBase64, sig, metadata);
|
||||
return ApiClient.deviceApi().uploadPhoto(req)
|
||||
.map(ApiResponse::getData); // 解包统一响应,返回业务数据
|
||||
}
|
||||
|
||||
// ==================== ④ 下载解密照片 ====================
|
||||
|
||||
/**
|
||||
* 用户端下载并解密最近上传的照片(返回明文 Base64)。
|
||||
*
|
||||
* 不再依赖内存态 lastPhotoId(App 重启会丢失),而是:
|
||||
* ① 先调 GET /api/user/photos 从后端获取该用户的照片 ID 列表;
|
||||
* ② 取最近一张(列表末尾);
|
||||
* ③ 再调 GET /api/photo/{id}/decrypt 下载并解密。
|
||||
*
|
||||
* 真实场景:用户在其他设备上登录,下载并查看自己设备的加密照片。
|
||||
*/
|
||||
public void downloadLastPhoto() {
|
||||
ui.appendLog("—— ④ 用户下载解密照片 ——");
|
||||
String userId = ApiClient.getUserId();
|
||||
runStep("下载解密",
|
||||
ApiClient.deviceApi().userPhotos(userId)
|
||||
.map(ApiResponse::getData)
|
||||
.flatMap(photos -> {
|
||||
List<String> ids = photos.getPhotos();
|
||||
if (ids == null || ids.isEmpty()) {
|
||||
return Single.error(new IllegalStateException("后端无照片记录,请先「选择并上传图片」"));
|
||||
}
|
||||
// 取最近一张(列表按上传时间升序,末尾即最新)
|
||||
String latestId = ids.get(ids.size() - 1);
|
||||
return ApiClient.deviceApi()
|
||||
.downloadAndDecrypt(latestId, userId)
|
||||
.map(ApiResponse::getData);
|
||||
}),
|
||||
(DownloadDecryptResponse d) -> {
|
||||
ui.setDecryptedImageBase64(d.getPlaintextBase64()); // 解密后的图片明文(Base64)→ 预览显示
|
||||
// 注意:不要在日志里打印整段明文(大图会导致 TextView 巨量重绘而 ANR),
|
||||
// 也不在主线程二次 Base64 解码(估算字节数即可)。
|
||||
String b64 = d.getPlaintextBase64();
|
||||
int approxBytes = b64.length() / 4 * 3;
|
||||
ui.appendLog("✅ 下载解密成功(" + d.getPhotoId() + "),明文约 " + approxBytes
|
||||
+ " 字节(已显示到预览区,服务端从后端照片列表取最近一张,信封解密)");
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ⑤ 恢复出厂(销毁 TEE 密钥) ====================
|
||||
|
||||
/**
|
||||
* 模拟恢复出厂:TEE 旧私钥销毁并生成全新密钥对(公钥轮换)。
|
||||
* 真实场景:换机 / 恢复出厂设置后,旧密钥不可恢复,必须走恢复链路找回数据。
|
||||
*/
|
||||
public void resetDevice() {
|
||||
if (!requireCrypto()) return;
|
||||
ui.appendLog("—— ⑤ 恢复出厂(销毁 TEE 密钥)——");
|
||||
runStep("恢复出厂",
|
||||
Single.fromCallable(() -> {
|
||||
crypto.resetKeyPair(); // TEE 旧私钥销毁
|
||||
return crypto.getPublicKeyBase64(); // 新公钥
|
||||
}).subscribeOn(Schedulers.computation()),
|
||||
(String newPub) -> {
|
||||
pubKey = newPub;
|
||||
deviceId = null;
|
||||
bound = false;
|
||||
recoveryToken = null;
|
||||
ui.appendLog("✅ TEE 旧私钥已销毁,新公钥已生成: "
|
||||
+ pubKey.substring(0, Math.min(24, pubKey.length())) + "...");
|
||||
ui.appendLog(" 新公钥无法解开旧数据,必须重新注册 + 短信恢复授权");
|
||||
ui.setCurrentStep(6); // 跳过5(当前步),进入第6步:重新注册
|
||||
refreshStatus();
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ⑥ 重新注册 ====================
|
||||
|
||||
/**
|
||||
* 恢复出厂后重新注册(新公钥),后端会停用旧设备,SN 归属保留。
|
||||
*/
|
||||
public void reRegisterDevice() {
|
||||
if (!requireCrypto()) return;
|
||||
if (deviceId != null) {
|
||||
ui.appendLog("当前已注册 deviceId=" + deviceId + ",仍将用最新公钥重新注册(后端幂等覆盖)");
|
||||
}
|
||||
ui.appendLog("—— ⑥ 重新注册(新公钥)——");
|
||||
runStep("重新注册",
|
||||
ApiClient.deviceApi().registerDevice(new RegisterRequest(sn, pubKey))
|
||||
.map(ApiResponse::getData),
|
||||
(RegisterResponse r) -> {
|
||||
deviceId = r.getDeviceId();
|
||||
ui.appendLog("✅ 重新注册成功: newDeviceId=" + deviceId
|
||||
+ "(旧设备已停用,SN 归属保留)");
|
||||
ui.appendLog(" 下一步:点击「发送短信验证码」");
|
||||
ui.setCurrentStep(7); // 进入第7步:短信
|
||||
refreshStatus();
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ⑦ 发送短信验证码 ====================
|
||||
|
||||
/**
|
||||
* 用户端发送短信验证码到绑定手机号(登录态防轰炸)。
|
||||
* 注意:手机号必须与「绑定用户」时一致,恢复时后端按用户档案手机号校验。
|
||||
*/
|
||||
public void sendSms(String phone) {
|
||||
if (deviceId == null) {
|
||||
ui.postToast("请先完成「重新注册」");
|
||||
return;
|
||||
}
|
||||
this.phone = phone;
|
||||
ApiClient.saveUserId(userId); // 短信接口需要登录态(X-User-Id)
|
||||
ui.appendLog("—— ⑦ 发送短信验证码 → " + phone + " ——");
|
||||
runStep("发送短信",
|
||||
ApiClient.deviceApi().sendSms(new SmsRequest(phone))
|
||||
.map(ApiResponse::getData),
|
||||
(MessageResponse s) -> {
|
||||
ui.appendLog("✅ 短信已发送: " + s.getMessage()
|
||||
+ "(demo 后端固定验证码 000000,见后端控制台)");
|
||||
ui.appendLog(" 下一步:输入验证码后点击「恢复授权」");
|
||||
ui.setCurrentStep(8); // 进入第8步:恢复授权
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ⑧ 恢复授权 ====================
|
||||
|
||||
/**
|
||||
* 恢复授权:短信验证码 + SN 归属双因子,后端校验通过后用设备当前公钥
|
||||
* 加密下发 Recovery Token,设备 TEE 私钥解密得到明文一次性 Token。
|
||||
* 真实场景:新设备上验证短信后,云端信任新设备。
|
||||
*/
|
||||
public void recoverDevice(String smsCode) {
|
||||
if (!requireCrypto()) return;
|
||||
if (deviceId == null) {
|
||||
ui.postToast("请先完成「重新注册」");
|
||||
return;
|
||||
}
|
||||
ui.appendLog("—— ⑧ 恢复授权(短信 + SN 归属双因子)——");
|
||||
runStep("恢复授权",
|
||||
ApiClient.deviceApi()
|
||||
.recoverDevice(new RecoverRequest(userId, sn, smsCode, pubKey))
|
||||
.flatMap(r -> Single.fromCallable(() -> {
|
||||
byte[] tokenBytes = Base64.getDecoder()
|
||||
.decode(r.getData().getEncryptedRecoveryToken());
|
||||
return crypto.decryptRecoveryToken(tokenBytes); // TEE 私钥解密
|
||||
}).subscribeOn(Schedulers.computation())),
|
||||
(String token) -> {
|
||||
recoveryToken = token;
|
||||
bound = true; // 恢复授权通过即视为已重新绑定
|
||||
ui.appendLog("✅ 恢复授权成功,TEE 解密 Recovery Token: " + token
|
||||
+ "(nonce 一次性 + 5 分钟窗口)");
|
||||
ui.appendLog(" 下一步:点击「恢复照片」");
|
||||
ui.setCurrentStep(9); // 进入第9步:恢复照片
|
||||
refreshStatus();
|
||||
});
|
||||
}
|
||||
|
||||
// ==================== ⑨ 恢复照片(取回 DEK 并还原) ====================
|
||||
|
||||
/**
|
||||
* 恢复后取回照片 DEK 列表:服务端用设备当前公钥逐条加密 DEK 下发,
|
||||
* 设备 TEE 解密 DEK 后配合密文还原照片。
|
||||
* 真实场景:云端不再信任旧设备,但新设备凭 Token 取回数据。
|
||||
*/
|
||||
public void recoverPhotos() {
|
||||
if (!requireCrypto()) return;
|
||||
if (deviceId == null || recoveryToken == null) {
|
||||
ui.postToast("请先完成「重新注册」与「恢复授权」");
|
||||
return;
|
||||
}
|
||||
ui.appendLog("—— ⑨ 恢复照片(取回 DEK 并还原)——");
|
||||
runStep("恢复照片",
|
||||
ApiClient.deviceApi()
|
||||
.recoverPhotos(new PhotoRecoverRequest(deviceId, recoveryToken, userId))
|
||||
.flatMap(pr -> Single.fromCallable(() -> restoreFromDeks(pr.getData()))
|
||||
.subscribeOn(Schedulers.computation())),
|
||||
(String result) -> ui.appendLog(result));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 DEK 列表还原最近上传的照片(返回日志文案)。
|
||||
* 密文沿用本地上传缓存(真实场景中密文文件从云端下载,这里聚焦 DEK 还原链路)。
|
||||
*/
|
||||
private String restoreFromDeks(PhotoRecoverResponse pr) {
|
||||
List<EncryptedDek> deks = pr.getDeks();
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("✅ 取回 DEK 列表: ").append(pr.getPhotoCount()).append(" 张(Token 验证通过,DEK 用设备公钥逐条加密)\n");
|
||||
if (deks == null || deks.isEmpty()) {
|
||||
return sb.append(" 恢复列表为空,无需还原").toString();
|
||||
}
|
||||
// 匹配本次链路最新上传的 photoId,否则用旧照片 DEK 解新照片密文会 GCM 校验失败。
|
||||
EncryptedDek target = matchDek(deks, lastPhotoId);
|
||||
byte[] dekBytes = crypto.decryptDek(target.getEncryptedDekBase64()); // TEE 解 DEK
|
||||
if (lastCiphertextBase64 == null) {
|
||||
return sb.append(" 本地无密文缓存,跳过内容还原").toString();
|
||||
}
|
||||
byte[] photo = crypto.decryptPhoto(lastCiphertextBase64, lastIvBase64, dekBytes); // AES-GCM 解照片
|
||||
// 只打印明文大小,不打印整段内容(大图会导致日志 TextView 巨量重绘而 ANR)
|
||||
sb.append(" TEE 解密 DEK(").append(target.getPhotoId()).append(") → 还原成功,明文大小 ")
|
||||
.append(photo.length).append(" 字节");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** 按 photoId 匹配 DEK,未命中时兜底取第一张 */
|
||||
private EncryptedDek matchDek(List<EncryptedDek> deks, String photoId) {
|
||||
if (photoId != null) {
|
||||
for (EncryptedDek d : deks) {
|
||||
if (photoId.equals(d.getPhotoId())) {
|
||||
return d;
|
||||
}
|
||||
}
|
||||
}
|
||||
return deks.get(0);
|
||||
}
|
||||
|
||||
// ==================== 设备状态摘要 ====================
|
||||
|
||||
private void refreshStatus() {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
sb.append("设备 SN: ").append(sn == null ? "-" : sn).append("\n");
|
||||
sb.append("公钥(前24): ").append(pubKey == null ? "-" : pubKey.substring(0, Math.min(24, pubKey.length()))).append("...\n");
|
||||
sb.append("注册状态: ").append(deviceId == null ? "未注册" : "deviceId=" + deviceId).append("\n");
|
||||
sb.append("绑定用户: ").append(userId).append("(").append(phone).append(")\n");
|
||||
sb.append("恢复出厂: ").append(crypto == null || pubKey == null ? "-" : "已就绪,点击即可销毁密钥").append("\n");
|
||||
sb.append("恢复授权: ").append(recoveryToken == null ? "未授权" : "Token 已就绪").append("\n");
|
||||
sb.append("最近照片: ").append(lastPhotoId == null ? "无" : lastPhotoId);
|
||||
ui.setStatus(sb.toString());
|
||||
|
||||
// 同步驱动按钮可用性的布尔状态
|
||||
ui.setRegistered(deviceId != null);
|
||||
ui.setBound(bound);
|
||||
}
|
||||
|
||||
/** 设备未就绪时给出提示并返回 false,避免抛异常导致界面假死 */
|
||||
private boolean requireCrypto() {
|
||||
if (crypto == null) {
|
||||
ui.postToast("设备尚未初始化,请稍候重试");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==================== 工具 ====================
|
||||
|
||||
/**
|
||||
* 通过 ContentResolver 读取 URI(兼容 SAF、MediaStore、云端文档)
|
||||
*/
|
||||
private byte[] readUriBytes(Uri uri) throws Exception {
|
||||
try (InputStream is = app.getContentResolver().openInputStream(uri)) {
|
||||
if (is == null) {
|
||||
throw new IllegalStateException("无法打开 URI: " + uri);
|
||||
}
|
||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||
byte[] buf = new byte[16 * 1024];
|
||||
int n;
|
||||
while ((n = is.read(buf)) > 0) {
|
||||
bos.write(buf, 0, n);
|
||||
}
|
||||
return bos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
private boolean verify(String metadata, String sigBase64, String pubKeyBase64) throws Exception {
|
||||
java.security.PublicKey pub = KeyFactory.getInstance("RSA")
|
||||
.generatePublic(new X509EncodedKeySpec(Base64.getDecoder().decode(pubKeyBase64)));
|
||||
Signature s = Signature.getInstance("SHA256withRSA");
|
||||
s.initVerify(pub);
|
||||
s.update(metadata.getBytes(StandardCharsets.UTF_8));
|
||||
return s.verify(Base64.getDecoder().decode(sigBase64));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.secure.demo.base.mvvm;
|
||||
|
||||
import android.os.Bundle;
|
||||
|
||||
import androidx.annotation.LayoutRes;
|
||||
import androidx.annotation.Nullable;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
import androidx.databinding.DataBindingUtil;
|
||||
import androidx.databinding.ViewDataBinding;
|
||||
import androidx.lifecycle.ViewModelProvider;
|
||||
|
||||
import java.lang.reflect.ParameterizedType;
|
||||
import java.lang.reflect.Type;
|
||||
|
||||
/**
|
||||
* MVVM Activity 基类(DataBinding 版)。
|
||||
*
|
||||
* 泛型参数:
|
||||
* - VB:DataBinding 生成的 Binding 类(如 ActivityMainBinding)
|
||||
* - VM:ViewModel 子类(业务逻辑与 UI 状态容器)
|
||||
*
|
||||
* 职责:
|
||||
* - 通过 {@link DataBindingUtil#setContentView} 自动 inflate 并生成 Binding;
|
||||
* - 设置 {@link #binding#setLifecycleOwner(this)},使布局内 LiveData 绑定随生命周期自动更新;
|
||||
* - 通过 {@link ViewModelProvider} 自动实例化 ViewModel;
|
||||
* - 暴露 {@link #getBinding()} / {@link #getViewModel()} 给子类;
|
||||
* - 在 {@link #onReady(Bundle)} 回调中子类绑定 UI 事件与观察者。
|
||||
*/
|
||||
public abstract class BaseMvvmActivity<VB extends ViewDataBinding, VM extends BaseViewModel>
|
||||
extends AppCompatActivity {
|
||||
|
||||
protected VB binding;
|
||||
protected VM viewModel;
|
||||
|
||||
/** 布局资源 id(对应 DataBinding 生成的 Binding) */
|
||||
@LayoutRes
|
||||
protected abstract int getLayoutId();
|
||||
|
||||
/** ViewModel 的具体 Class(用于 ViewModelProvider 实例化) */
|
||||
protected abstract Class<VM> getViewModelClass();
|
||||
|
||||
/** 可选:自定义 ViewModel 工厂(如带 Application 的工厂),默认无参 */
|
||||
@Nullable
|
||||
protected ViewModelProvider.Factory getViewModelFactory() {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 绑定完成后回调:子类在此设置点击监听、LiveData 观察等 */
|
||||
protected abstract void onReady(@Nullable Bundle savedInstanceState);
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
protected void onCreate(@Nullable Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
// 1. inflate DataBinding 布局,生成强类型 Binding
|
||||
binding = (VB) DataBindingUtil.setContentView(this, getLayoutId());
|
||||
binding.setLifecycleOwner(this);
|
||||
|
||||
// 2. 实例化 ViewModel(生命周期由 Activity 持有,随销毁而 onCleared)
|
||||
ViewModelProvider.Factory factory = getViewModelFactory();
|
||||
ViewModelProvider provider = factory != null
|
||||
? new ViewModelProvider(this, factory)
|
||||
: new ViewModelProvider(this);
|
||||
viewModel = provider.get(getViewModelClass());
|
||||
|
||||
onReady(savedInstanceState);
|
||||
}
|
||||
|
||||
protected VB getBinding() {
|
||||
return binding;
|
||||
}
|
||||
|
||||
protected VM getViewModel() {
|
||||
return viewModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过泛型类型参数推断 VM 的实际 Class(兜底方案)。
|
||||
* 当子类未重写 {@link #getViewModelClass()} 时可用。
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
protected Class<VM> inferViewModelClass() {
|
||||
Type superClass = getClass().getGenericSuperclass();
|
||||
while (superClass instanceof Class<?> && superClass != Object.class) {
|
||||
superClass = ((Class<?>) superClass).getGenericSuperclass();
|
||||
}
|
||||
if (superClass instanceof ParameterizedType) {
|
||||
Type[] types = ((ParameterizedType) superClass).getActualTypeArguments();
|
||||
if (types.length >= 2) {
|
||||
return (Class<VM>) types[1];
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("无法推断 ViewModel 类型,请重写 getViewModelClass()");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.secure.demo.base.mvvm;
|
||||
|
||||
import android.app.Application;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.lifecycle.AndroidViewModel;
|
||||
|
||||
import io.reactivex.rxjava3.disposables.CompositeDisposable;
|
||||
import io.reactivex.rxjava3.disposables.Disposable;
|
||||
|
||||
/**
|
||||
* ViewModel 基类。
|
||||
*
|
||||
* 生命周期感知的网络请求容器:
|
||||
* - 继承 {@link AndroidViewModel},持有 {@link Application} 上下文(用于 DeviceCrypto、ContentResolver 等系统服务);
|
||||
* - 内部维护 {@link CompositeDisposable},所有 RxJava 订阅统一登记;
|
||||
* - 在 {@link #onCleared()}(ViewModel 生命周期结束,对应 Activity/Fragment 销毁)时
|
||||
* 自动 {@link CompositeDisposable#clear()},避免内存泄漏与后台回调空指针。
|
||||
*
|
||||
* 子类通过 {@link #getApplication()} 获取 Application,发起网络请求时统一调用
|
||||
* {@link #addDisposable(Disposable)}。
|
||||
*/
|
||||
public abstract class BaseViewModel extends AndroidViewModel {
|
||||
|
||||
/** 网络订阅统一管理器,随 ViewModel 销毁而清理 */
|
||||
protected final CompositeDisposable disposables = new CompositeDisposable();
|
||||
|
||||
public BaseViewModel(@NonNull Application application) {
|
||||
super(application);
|
||||
}
|
||||
|
||||
/**
|
||||
* 登记一个 RxJava 订阅,使其具备生命周期感知能力。
|
||||
*
|
||||
* @param disposable 由 {@code subscribe()} 返回的 Disposable
|
||||
*/
|
||||
protected void addDisposable(@NonNull Disposable disposable) {
|
||||
disposables.add(disposable);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCleared() {
|
||||
super.onCleared();
|
||||
// ViewModel 销毁(Activity/Fragment 销毁或重建)时统一回收所有网络订阅
|
||||
disposables.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.secure.demo.base.mvvm;
|
||||
|
||||
import androidx.lifecycle.LiveData;
|
||||
import androidx.lifecycle.MutableLiveData;
|
||||
|
||||
/**
|
||||
* 通用 UI 状态容器。
|
||||
*
|
||||
* 通过 LiveData 将「日志、进度、结果」三类事件暴露给视图层,
|
||||
* 视图层仅做渲染,不持有业务逻辑。
|
||||
*/
|
||||
public class UiState {
|
||||
|
||||
/** 运行日志(追加式) */
|
||||
private final MutableLiveData<String> logLines = new MutableLiveData<>("");
|
||||
|
||||
/** 是否处于忙(网络/加密进行中),用于禁用按钮、显示进度条。
|
||||
* 初始为 false:按钮默认可用;仅当某步操作进行中由 runStep/initDevice 置 true */
|
||||
private final MutableLiveData<Boolean> busy = new MutableLiveData<>(false);
|
||||
|
||||
/** 当前操作进度步骤(对应 UI 上的 ①-⑨),用于控制按钮可用性 */
|
||||
private final MutableLiveData<Integer> currentStep = new MutableLiveData<>(0);
|
||||
|
||||
/** 设备是否已注册(后端有记录) */
|
||||
private final MutableLiveData<Boolean> registered = new MutableLiveData<>(false);
|
||||
|
||||
/** 设备是否已绑定用户 */
|
||||
private final MutableLiveData<Boolean> bound = new MutableLiveData<>(false);
|
||||
|
||||
public LiveData<Integer> getCurrentStep() {
|
||||
return currentStep;
|
||||
}
|
||||
|
||||
public void setCurrentStep(int step) {
|
||||
currentStep.postValue(step);
|
||||
}
|
||||
|
||||
public LiveData<Boolean> getRegistered() {
|
||||
return registered;
|
||||
}
|
||||
|
||||
public LiveData<Boolean> getBound() {
|
||||
return bound;
|
||||
}
|
||||
|
||||
public void setRegistered(boolean value) {
|
||||
registered.postValue(value);
|
||||
}
|
||||
|
||||
public void setBound(boolean value) {
|
||||
bound.postValue(value);
|
||||
}
|
||||
|
||||
private final MutableLiveData<String> toast = new MutableLiveData<>();
|
||||
|
||||
/** 最近选中的图片 URI(供预览) */
|
||||
private final MutableLiveData<String> selectedImageUri = new MutableLiveData<>();
|
||||
|
||||
/** 下载解密后的图片明文(Base64),供预览显示 */
|
||||
private final MutableLiveData<String> decryptedImageBase64 = new MutableLiveData<>();
|
||||
|
||||
/** 当前设备/用户状态摘要(SN、公钥、注册/绑定/恢复进度) */
|
||||
private final MutableLiveData<String> status = new MutableLiveData<>("");
|
||||
|
||||
public LiveData<String> getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public LiveData<String> getLogLines() {
|
||||
return logLines;
|
||||
}
|
||||
|
||||
public LiveData<Boolean> getBusy() {
|
||||
return busy;
|
||||
}
|
||||
|
||||
public LiveData<String> getToast() {
|
||||
return toast;
|
||||
}
|
||||
|
||||
public LiveData<String> getSelectedImageUri() {
|
||||
return selectedImageUri;
|
||||
}
|
||||
|
||||
public LiveData<String> getDecryptedImageBase64() {
|
||||
return decryptedImageBase64;
|
||||
}
|
||||
|
||||
// ===== 内部写方法(仅 ViewModel 调用) =====
|
||||
|
||||
public synchronized void appendLog(String line) {
|
||||
String cur = logLines.getValue();
|
||||
logLines.postValue((cur == null ? "" : cur) + line + "\n");
|
||||
}
|
||||
|
||||
public void setBusy(boolean value) {
|
||||
busy.postValue(value);
|
||||
}
|
||||
|
||||
public void postToast(String msg) {
|
||||
toast.postValue(msg);
|
||||
}
|
||||
|
||||
public void setSelectedImageUri(String uri) {
|
||||
selectedImageUri.postValue(uri);
|
||||
}
|
||||
|
||||
public void setDecryptedImageBase64(String base64) {
|
||||
decryptedImageBase64.postValue(base64);
|
||||
}
|
||||
|
||||
public void setStatus(String status) {
|
||||
this.status.postValue(status);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.secure.demo.network;
|
||||
|
||||
import android.content.Context;
|
||||
import android.util.Log;
|
||||
|
||||
import com.secure.demo.BuildConfig;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import okhttp3.Interceptor;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.logging.HttpLoggingInterceptor;
|
||||
import retrofit2.Retrofit;
|
||||
import retrofit2.adapter.rxjava3.RxJava3CallAdapterFactory;
|
||||
import retrofit2.converter.gson.GsonConverterFactory;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
/**
|
||||
* Retrofit 客户端单例。
|
||||
* 技术栈与结构对齐 WebRTCController 的 RetrofitClient:
|
||||
* 后端基地址通过 BuildConfig.API_BASE 注入,支持 OkHttp 拦截器链(AuthInterceptor + 日志)。
|
||||
*/
|
||||
public final class ApiClient {
|
||||
|
||||
/** 请求日志 TAG,便于在 Logcat 中过滤 */
|
||||
private static final String TAG = "SecureDevice-HTTP";
|
||||
|
||||
/** 后端基地址(BuildConfig 注入,模拟器 10.0.2.2 访问宿主机) */
|
||||
// public static final String API_BASE = "http://10.0.2.2:8080";
|
||||
public static final String API_BASE = "http://192.168.100.244:8080";
|
||||
|
||||
private static final long TIMEOUT_SECONDS = 15L;
|
||||
|
||||
private static volatile Retrofit retrofit;
|
||||
private static volatile DeviceApi deviceApi;
|
||||
private static volatile AuthInterceptor authInterceptor;
|
||||
|
||||
/** 界面网络日志监听:每个 HTTP 请求的 method/url/状态码/耗时会回调到这里(可为 null) */
|
||||
private static volatile Consumer<String> httpLogListener;
|
||||
|
||||
private ApiClient() {
|
||||
}
|
||||
|
||||
/** 需在首次发起请求前调用一次(例如 MainActivity.onCreate) */
|
||||
public static synchronized void init(Context context) {
|
||||
if (retrofit != null) {
|
||||
return;
|
||||
}
|
||||
// 1) 完整请求/响应日志:BODY 级别会打印 method、url、headers 及请求/响应体
|
||||
HttpLoggingInterceptor logging = new HttpLoggingInterceptor(
|
||||
message -> Log.d(TAG, message));
|
||||
logging.setLevel(BuildConfig.DEBUG
|
||||
? HttpLoggingInterceptor.Level.BODY
|
||||
: HttpLoggingInterceptor.Level.NONE);
|
||||
|
||||
// 2) 请求摘要日志:一行打印 method、url、状态码与耗时,便于快速定位慢请求。
|
||||
// 同时转发给界面(httpLogListener),让用户在 App 日志区也能看到每次网络请求。
|
||||
Interceptor requestSummary = chain -> {
|
||||
Request request = chain.request();
|
||||
long startNanos = System.nanoTime();
|
||||
try {
|
||||
Response response = chain.proceed(request);
|
||||
long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos);
|
||||
String msg = String.format("%s %s -> %d (%dms)",
|
||||
request.method(), request.url(), response.code(), elapsedMs);
|
||||
Log.d(TAG, msg);
|
||||
notifyHttpLog(msg);
|
||||
return response;
|
||||
} catch (Exception e) {
|
||||
long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNanos);
|
||||
String msg = String.format("%s %s -> 请求失败 (%dms): %s",
|
||||
request.method(), request.url(), elapsedMs, e.getMessage());
|
||||
Log.w(TAG, msg);
|
||||
notifyHttpLog(msg);
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
authInterceptor = new AuthInterceptor(context);
|
||||
OkHttpClient client = new OkHttpClient.Builder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(logging)
|
||||
.addInterceptor(requestSummary)
|
||||
.connectTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.readTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.writeTimeout(TIMEOUT_SECONDS, TimeUnit.SECONDS)
|
||||
.build();
|
||||
|
||||
retrofit = new Retrofit.Builder()
|
||||
.baseUrl(normalizeBaseUrl(API_BASE))
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.addCallAdapterFactory(RxJava3CallAdapterFactory.create())
|
||||
.build();
|
||||
}
|
||||
|
||||
/** 获取设备安全 API 服务实例 */
|
||||
public static DeviceApi deviceApi() {
|
||||
if (retrofit == null) {
|
||||
throw new IllegalStateException("ApiClient.init(Context) 必须在使用前调用");
|
||||
}
|
||||
if (deviceApi == null) {
|
||||
deviceApi = retrofit.create(DeviceApi.class);
|
||||
}
|
||||
return deviceApi;
|
||||
}
|
||||
|
||||
/** 保存当前用户 ID(绑定成功后调用),AuthInterceptor 将自动注入 X-User-Id */
|
||||
public static void saveUserId(String userId) {
|
||||
if (authInterceptor == null) {
|
||||
throw new IllegalStateException("ApiClient.init(Context) 必须先调用");
|
||||
}
|
||||
authInterceptor.saveUserId(userId);
|
||||
}
|
||||
|
||||
/** 读取当前用户 ID(照片下载/恢复等鉴权接口使用) */
|
||||
public static String getUserId() {
|
||||
return authInterceptor != null ? authInterceptor.getUserId() : null;
|
||||
}
|
||||
|
||||
/** 注册界面网络日志监听(App 日志区实时显示每次请求),传 null 注销 */
|
||||
public static void setHttpLogListener(Consumer<String> listener) {
|
||||
httpLogListener = listener;
|
||||
}
|
||||
|
||||
private static void notifyHttpLog(String msg) {
|
||||
Consumer<String> listener = httpLogListener;
|
||||
if (listener != null) {
|
||||
try {
|
||||
listener.accept(msg);
|
||||
} catch (Exception ignored) {
|
||||
// 界面回调异常不影响网络请求本身
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static String normalizeBaseUrl(String base) {
|
||||
return base.endsWith("/") ? base : base + "/";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.secure.demo.network;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import okhttp3.Interceptor;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
||||
/**
|
||||
* 认证拦截器:从本地偏好读取用户 ID,自动注入 X-User-Id 请求头。
|
||||
* 对齐 WebRTCController 的 AuthInterceptor 模式(Token 注入);
|
||||
* 本 demo 后端以 X-User-Id 做照片归属鉴权。
|
||||
*/
|
||||
public class AuthInterceptor implements Interceptor {
|
||||
|
||||
public static final String PREFS_NAME = "secure_device_prefs";
|
||||
public static final String KEY_USER_ID = "user_id";
|
||||
|
||||
private final Context appContext;
|
||||
|
||||
public AuthInterceptor(Context context) {
|
||||
this.appContext = context.getApplicationContext();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Response intercept(Chain chain) throws IOException {
|
||||
Request.Builder builder = chain.request().newBuilder();
|
||||
String userId = getUserId();
|
||||
if (userId != null && !userId.isEmpty()) {
|
||||
builder.header("X-User-Id", userId);
|
||||
}
|
||||
return chain.proceed(builder.build());
|
||||
}
|
||||
|
||||
public void saveUserId(String userId) {
|
||||
appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit().putString(KEY_USER_ID, userId).apply();
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
SharedPreferences sp = appContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
|
||||
return sp.getString(KEY_USER_ID, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package com.secure.demo.network;
|
||||
|
||||
import com.secure.demo.network.model.ApiResponse;
|
||||
import com.secure.demo.network.model.BindRequest;
|
||||
import com.secure.demo.network.model.DownloadDecryptResponse;
|
||||
import com.secure.demo.network.model.DeviceStatusResponse;
|
||||
import com.secure.demo.network.model.HealthResponse;
|
||||
import com.secure.demo.network.model.MessageResponse;
|
||||
import com.secure.demo.network.model.PhotoRecoverRequest;
|
||||
import com.secure.demo.network.model.PhotoRecoverResponse;
|
||||
import com.secure.demo.network.model.RecoverRequest;
|
||||
import com.secure.demo.network.model.RecoverResponse;
|
||||
import com.secure.demo.network.model.RegisterRequest;
|
||||
import com.secure.demo.network.model.RegisterResponse;
|
||||
import com.secure.demo.network.model.SmsRequest;
|
||||
import com.secure.demo.network.model.UploadPhotoRequest;
|
||||
import com.secure.demo.network.model.UploadPhotoResponse;
|
||||
import com.secure.demo.network.model.UserPhotosResponse;
|
||||
|
||||
import io.reactivex.rxjava3.core.Single;
|
||||
import retrofit2.http.Body;
|
||||
import retrofit2.http.GET;
|
||||
import retrofit2.http.Header;
|
||||
import retrofit2.http.POST;
|
||||
import retrofit2.http.Path;
|
||||
import retrofit2.http.Query;
|
||||
|
||||
/**
|
||||
* 设备安全后端 API 接口定义。
|
||||
* 对应 springboot-server 的 DeviceController 全部接口。
|
||||
*
|
||||
* 前后端分离后所有接口统一返回 {@link ApiResponse}{code, message, data},
|
||||
* 业务数据通过 {@link ApiResponse#getData()} 获取。
|
||||
*/
|
||||
public interface DeviceApi {
|
||||
|
||||
/** 0. 查询设备注册/绑定状态(设备端无登录,App 启动时复用已有状态) */
|
||||
@GET("api/device/status")
|
||||
Single<ApiResponse<DeviceStatusResponse>> deviceStatus(@Query("sn") String sn);
|
||||
|
||||
/** 1. 设备注册(SN + 公钥,设备端无登录) */
|
||||
@POST("api/device/register")
|
||||
Single<ApiResponse<RegisterResponse>> registerDevice(@Body RegisterRequest body);
|
||||
|
||||
/** 2. 用户绑定设备(用户端,Bearer Token / X-User-Id 鉴权) */
|
||||
@POST("api/device/bind")
|
||||
Single<ApiResponse<MessageResponse>> bindDevice(@Body BindRequest body);
|
||||
|
||||
/** 3. 发送短信验证码(用户端,登录态防轰炸) */
|
||||
@POST("api/device/sms/send")
|
||||
Single<ApiResponse<MessageResponse>> sendSms(@Body SmsRequest body);
|
||||
|
||||
/** 4. 上传加密照片(信封加密,DEK 明文经 HTTPS 传输,设备端无登录) */
|
||||
@POST("api/photo/upload")
|
||||
Single<ApiResponse<UploadPhotoResponse>> uploadPhoto(@Body UploadPhotoRequest body);
|
||||
|
||||
/** 5. 恢复设备(短信验证 + 新公钥,换机/恢复出厂后调用,用户端) */
|
||||
@POST("api/device/recover")
|
||||
Single<ApiResponse<RecoverResponse>> recoverDevice(@Body RecoverRequest body);
|
||||
|
||||
/** 6. 恢复后获取照片 DEK 列表(服务端用设备公钥逐一加密 DEK 下发,设备端无登录) */
|
||||
@POST("api/photo/recover")
|
||||
Single<ApiResponse<PhotoRecoverResponse>> recoverPhotos(@Body PhotoRecoverRequest body);
|
||||
|
||||
/** 7. 用户下载并解密照片(Bearer Token / X-User-Id 鉴权,返回明文照片 Base64 的 JSON) */
|
||||
@GET("api/photo/{photoId}/decrypt")
|
||||
Single<ApiResponse<DownloadDecryptResponse>> downloadAndDecrypt(@Path("photoId") String photoId,
|
||||
@Header("X-User-Id") String userId);
|
||||
|
||||
/** 7b. 用户照片列表(用户端,X-User-Id 鉴权,返回 photoId 列表) */
|
||||
@GET("api/user/photos")
|
||||
Single<ApiResponse<UserPhotosResponse>> userPhotos(@Header("X-User-Id") String userId);
|
||||
|
||||
/** 8. 健康检查(公开) */
|
||||
@GET("api/health")
|
||||
Single<ApiResponse<HealthResponse>> health();
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
/**
|
||||
* 后端统一响应包装(与 springboot-server 的 ApiResponse 对应)。
|
||||
*
|
||||
* 前后端分离后,所有接口返回:
|
||||
* <pre>
|
||||
* { "code": 0, "message": "ok", "data": { ... } }
|
||||
* </pre>
|
||||
* code == 0 表示成功,业务数据在 data 中。
|
||||
*/
|
||||
public class ApiResponse<T> {
|
||||
|
||||
public static final int CODE_OK = 0;
|
||||
|
||||
private int code;
|
||||
private String message;
|
||||
private T data;
|
||||
|
||||
public ApiResponse() {
|
||||
}
|
||||
|
||||
public ApiResponse(int code, String message, T data) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
/** 是否业务成功 */
|
||||
public boolean isOk() {
|
||||
return code == CODE_OK;
|
||||
}
|
||||
|
||||
// ===== Getters & Setters =====
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public void setCode(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public T getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
public void setData(T data) {
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 用户绑定设备请求:{ userId, sn, phone }(phone 可选,未传则由服务端使用默认值) */
|
||||
public class BindRequest {
|
||||
|
||||
@SerializedName("userId")
|
||||
private final String userId;
|
||||
|
||||
@SerializedName("sn")
|
||||
private final String sn;
|
||||
|
||||
@SerializedName("phone")
|
||||
private final String phone;
|
||||
|
||||
public BindRequest(String userId, String sn, String phone) {
|
||||
this.userId = userId;
|
||||
this.sn = sn;
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public String getSn() {
|
||||
return sn;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* 设备注册/绑定状态查询响应。
|
||||
*
|
||||
* 对应后端 GET /api/device/status?sn=xxx 的 data 字段:
|
||||
* { registered, bound, active, deviceId, userId, publicKeyBase64 }
|
||||
*
|
||||
* 用途:App 重启后先查询该 SN 是否已注册/已绑定,避免重复注册与绑定。
|
||||
*/
|
||||
public class DeviceStatusResponse {
|
||||
|
||||
@SerializedName("registered")
|
||||
private boolean registered;
|
||||
|
||||
@SerializedName("bound")
|
||||
private boolean bound;
|
||||
|
||||
@SerializedName("active")
|
||||
private boolean active;
|
||||
|
||||
@SerializedName("deviceId")
|
||||
private String deviceId;
|
||||
|
||||
@SerializedName("userId")
|
||||
private String userId;
|
||||
|
||||
@SerializedName("publicKeyBase64")
|
||||
private String publicKeyBase64;
|
||||
|
||||
public boolean isRegistered() {
|
||||
return registered;
|
||||
}
|
||||
|
||||
public boolean isBound() {
|
||||
return bound;
|
||||
}
|
||||
|
||||
public boolean isActive() {
|
||||
return active;
|
||||
}
|
||||
|
||||
public String getDeviceId() {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public String getPublicKeyBase64() {
|
||||
return publicKeyBase64;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 下载并解密照片响应:{ photoId, plaintextBase64 }(明文照片 Base64) */
|
||||
public class DownloadDecryptResponse {
|
||||
|
||||
@SerializedName("photoId")
|
||||
private String photoId;
|
||||
|
||||
@SerializedName("plaintextBase64")
|
||||
private String plaintextBase64;
|
||||
|
||||
public String getPhotoId() {
|
||||
return photoId;
|
||||
}
|
||||
|
||||
public String getPlaintextBase64() {
|
||||
return plaintextBase64;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** DEK 条目:{ photoId, encryptedDekBase64 } */
|
||||
public class EncryptedDek {
|
||||
|
||||
@SerializedName("photoId")
|
||||
private String photoId;
|
||||
|
||||
@SerializedName("encryptedDekBase64")
|
||||
private String encryptedDekBase64;
|
||||
|
||||
public String getPhotoId() {
|
||||
return photoId;
|
||||
}
|
||||
|
||||
public String getEncryptedDekBase64() {
|
||||
return encryptedDekBase64;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 健康检查响应:{ status, service } */
|
||||
public class HealthResponse {
|
||||
|
||||
@SerializedName("status")
|
||||
private String status;
|
||||
|
||||
@SerializedName("service")
|
||||
private String service;
|
||||
|
||||
public String getStatus() {
|
||||
return status;
|
||||
}
|
||||
|
||||
public String getService() {
|
||||
return service;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 通用消息响应:{ message }(绑定、发短信等接口返回) */
|
||||
public class MessageResponse {
|
||||
|
||||
@SerializedName("message")
|
||||
private String message;
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 恢复后获取照片 DEK 列表请求:{ deviceId, recoveryToken, userId } */
|
||||
public class PhotoRecoverRequest {
|
||||
|
||||
@SerializedName("deviceId")
|
||||
private final String deviceId;
|
||||
|
||||
@SerializedName("recoveryToken")
|
||||
private final String recoveryToken;
|
||||
|
||||
@SerializedName("userId")
|
||||
private final String userId;
|
||||
|
||||
public PhotoRecoverRequest(String deviceId, String recoveryToken, String userId) {
|
||||
this.deviceId = deviceId;
|
||||
this.recoveryToken = recoveryToken;
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
public String getDeviceId() {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
public String getRecoveryToken() {
|
||||
return recoveryToken;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 恢复后获取照片 DEK 列表响应。
|
||||
* { deviceId, photoCount, deks: [{ photoId, encryptedDekBase64 }] }
|
||||
* 每个 encryptedDekBase64 均由服务端用设备当前公钥加密,仅设备私钥可解。
|
||||
*/
|
||||
public class PhotoRecoverResponse {
|
||||
|
||||
@SerializedName("deviceId")
|
||||
private String deviceId;
|
||||
|
||||
@SerializedName("photoCount")
|
||||
private int photoCount;
|
||||
|
||||
@SerializedName("deks")
|
||||
private List<EncryptedDek> deks;
|
||||
|
||||
public String getDeviceId() {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
public int getPhotoCount() {
|
||||
return photoCount;
|
||||
}
|
||||
|
||||
public List<EncryptedDek> getDeks() {
|
||||
return deks;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* 恢复设备请求(恢复出厂/换机后重新授权)。
|
||||
* { userId, sn, smsCode, newPublicKeyBase64 }
|
||||
*/
|
||||
public class RecoverRequest {
|
||||
|
||||
@SerializedName("userId")
|
||||
private final String userId;
|
||||
|
||||
@SerializedName("sn")
|
||||
private final String sn;
|
||||
|
||||
@SerializedName("smsCode")
|
||||
private final String smsCode;
|
||||
|
||||
@SerializedName("newPublicKeyBase64")
|
||||
private final String newPublicKeyBase64;
|
||||
|
||||
public RecoverRequest(String userId, String sn, String smsCode, String newPublicKeyBase64) {
|
||||
this.userId = userId;
|
||||
this.sn = sn;
|
||||
this.smsCode = smsCode;
|
||||
this.newPublicKeyBase64 = newPublicKeyBase64;
|
||||
}
|
||||
|
||||
public String getUserId() {
|
||||
return userId;
|
||||
}
|
||||
|
||||
public String getSn() {
|
||||
return sn;
|
||||
}
|
||||
|
||||
public String getSmsCode() {
|
||||
return smsCode;
|
||||
}
|
||||
|
||||
public String getNewPublicKeyBase64() {
|
||||
return newPublicKeyBase64;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* 恢复设备响应。
|
||||
* { deviceId, encryptedRecoveryToken, nonce, message }
|
||||
* encryptedRecoveryToken 由服务端用新设备公钥加密,仅新设备私钥可解。
|
||||
*/
|
||||
public class RecoverResponse {
|
||||
|
||||
@SerializedName("deviceId")
|
||||
private String deviceId;
|
||||
|
||||
@SerializedName("encryptedRecoveryToken")
|
||||
private String encryptedRecoveryToken;
|
||||
|
||||
@SerializedName("nonce")
|
||||
private String nonce;
|
||||
|
||||
@SerializedName("message")
|
||||
private String message;
|
||||
|
||||
public String getDeviceId() {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
public String getEncryptedRecoveryToken() {
|
||||
return encryptedRecoveryToken;
|
||||
}
|
||||
|
||||
public String getNonce() {
|
||||
return nonce;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 设备注册请求:{ sn, publicKeyBase64 } */
|
||||
public class RegisterRequest {
|
||||
|
||||
@SerializedName("sn")
|
||||
private final String sn;
|
||||
|
||||
@SerializedName("publicKeyBase64")
|
||||
private final String publicKeyBase64;
|
||||
|
||||
public RegisterRequest(String sn, String publicKeyBase64) {
|
||||
this.sn = sn;
|
||||
this.publicKeyBase64 = publicKeyBase64;
|
||||
}
|
||||
|
||||
public String getSn() {
|
||||
return sn;
|
||||
}
|
||||
|
||||
public String getPublicKeyBase64() {
|
||||
return publicKeyBase64;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 设备注册响应:{ deviceId, sn, message } */
|
||||
public class RegisterResponse {
|
||||
|
||||
@SerializedName("deviceId")
|
||||
private String deviceId;
|
||||
|
||||
@SerializedName("sn")
|
||||
private String sn;
|
||||
|
||||
@SerializedName("message")
|
||||
private String message;
|
||||
|
||||
public String getDeviceId() {
|
||||
return deviceId;
|
||||
}
|
||||
|
||||
public String getSn() {
|
||||
return sn;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 发送短信验证码请求:{ phone } */
|
||||
public class SmsRequest {
|
||||
|
||||
@SerializedName("phone")
|
||||
private final String phone;
|
||||
|
||||
public SmsRequest(String phone) {
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
public String getPhone() {
|
||||
return phone;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/**
|
||||
* 上传加密照片请求(信封加密)。
|
||||
* { sn, photoId, ciphertextBase64, ivBase64, dekBase64, metadataSignature, metadata }
|
||||
* 服务端用用户密钥 UK 加密 DEK 后存储,永不保存明文 DEK。
|
||||
*/
|
||||
public class UploadPhotoRequest {
|
||||
|
||||
@SerializedName("sn")
|
||||
private final String sn;
|
||||
|
||||
@SerializedName("photoId")
|
||||
private final String photoId;
|
||||
|
||||
@SerializedName("ciphertextBase64")
|
||||
private final String ciphertextBase64;
|
||||
|
||||
@SerializedName("ivBase64")
|
||||
private final String ivBase64;
|
||||
|
||||
@SerializedName("dekBase64")
|
||||
private final String dekBase64;
|
||||
|
||||
@SerializedName("metadataSignature")
|
||||
private final String metadataSignature;
|
||||
|
||||
@SerializedName("metadata")
|
||||
private final String metadata;
|
||||
|
||||
public UploadPhotoRequest(String sn, String photoId, String ciphertextBase64,
|
||||
String ivBase64, String dekBase64,
|
||||
String metadataSignature, String metadata) {
|
||||
this.sn = sn;
|
||||
this.photoId = photoId;
|
||||
this.ciphertextBase64 = ciphertextBase64;
|
||||
this.ivBase64 = ivBase64;
|
||||
this.dekBase64 = dekBase64;
|
||||
this.metadataSignature = metadataSignature;
|
||||
this.metadata = metadata;
|
||||
}
|
||||
|
||||
public String getSn() {
|
||||
return sn;
|
||||
}
|
||||
|
||||
public String getPhotoId() {
|
||||
return photoId;
|
||||
}
|
||||
|
||||
public String getCiphertextBase64() {
|
||||
return ciphertextBase64;
|
||||
}
|
||||
|
||||
public String getIvBase64() {
|
||||
return ivBase64;
|
||||
}
|
||||
|
||||
public String getDekBase64() {
|
||||
return dekBase64;
|
||||
}
|
||||
|
||||
public String getMetadataSignature() {
|
||||
return metadataSignature;
|
||||
}
|
||||
|
||||
public String getMetadata() {
|
||||
return metadata;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
/** 上传加密照片响应:{ photoId, message } */
|
||||
public class UploadPhotoResponse {
|
||||
|
||||
@SerializedName("photoId")
|
||||
private String photoId;
|
||||
|
||||
@SerializedName("message")
|
||||
private String message;
|
||||
|
||||
public String getPhotoId() {
|
||||
return photoId;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.secure.demo.network.model;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户照片列表响应。
|
||||
*
|
||||
* 对应后端 GET /api/user/photos 的 data 字段:
|
||||
* { "photos": ["photo-001", "photo-002", ...] }
|
||||
*/
|
||||
public class UserPhotosResponse {
|
||||
|
||||
@SerializedName("photos")
|
||||
private List<String> photos;
|
||||
|
||||
public List<String> getPhotos() {
|
||||
return photos;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.secure.demo.util;
|
||||
|
||||
import android.Manifest;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.os.Build;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.core.content.ContextCompat;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 文件读写权限适配工具。
|
||||
*
|
||||
* 适配策略:
|
||||
* - Android 9 (API 28) 及以下:需 {@link Manifest.permission#READ_EXTERNAL_STORAGE} / WRITE_EXTERNAL_STORAGE
|
||||
* - Android 10 (API 29) 及以上:分区存储(Scoped Storage),普通应用无需存储权限即可访问
|
||||
* 自身 {@code MediaStore} / 专属目录;访问其它位置请走 SAF(见 {@link SafPicker})
|
||||
* - Android 13 (API 33) 及以上:细粒度媒体权限 READ_MEDIA_IMAGES 等
|
||||
*
|
||||
* 注意:本 demo 选择图片优先使用 SAF(ACTION_OPEN_DOCUMENT),无需任何存储权限,
|
||||
* 此工具用于「明确需要直接访问文件」的场景(如兼容旧设备)。
|
||||
*/
|
||||
public final class PermissionHelper {
|
||||
|
||||
private PermissionHelper() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回当前设备缺失的、选择/读取图片所需权限列表。
|
||||
* Android 10+ 通常返回空列表(不需要权限)。
|
||||
*/
|
||||
@NonNull
|
||||
public static List<String> getMissingImagePermissions(@NonNull Context context) {
|
||||
List<String> missing = new ArrayList<>();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
// Android 13+:细粒度图片权限
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_IMAGES)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
missing.add(Manifest.permission.READ_MEDIA_IMAGES);
|
||||
}
|
||||
} else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
// Android 9-:旧式存储权限
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
missing.add(Manifest.permission.READ_EXTERNAL_STORAGE);
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(context, Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
missing.add(Manifest.permission.WRITE_EXTERNAL_STORAGE);
|
||||
}
|
||||
}
|
||||
// Android 10/11/12 (Q/R/S):分区存储,无需权限
|
||||
return missing;
|
||||
}
|
||||
|
||||
/** 是否有读取/选择图片所需的权限(SAF 路径下恒为 true) */
|
||||
public static boolean hasImagePermission(@NonNull Context context) {
|
||||
return getMissingImagePermissions(context).isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否需要向用户申请权限(Android 10+ 不需要,返回 false)。
|
||||
*/
|
||||
public static boolean needsRequestPermission(@NonNull Context context) {
|
||||
return !getMissingImagePermissions(context).isEmpty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.secure.demo.util;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.Intent;
|
||||
import android.net.Uri;
|
||||
import android.provider.DocumentsContract;
|
||||
|
||||
import androidx.activity.result.ActivityResultLauncher;
|
||||
import androidx.activity.result.contract.ActivityResultContracts;
|
||||
import androidx.annotation.NonNull;
|
||||
import androidx.appcompat.app.AppCompatActivity;
|
||||
|
||||
/**
|
||||
* SAF(Storage Access Framework)选择器封装。
|
||||
*
|
||||
* 通过系统 {@link Intent#ACTION_OPEN_DOCUMENT} 调起系统文档选择器,
|
||||
* 用户选择后获得一个持久化 URI({@link DocumentsContract#EXTRA_PERSISTABLE_URI_PERMISSIONS}),
|
||||
* 应用可长期、免权限地通过 ContentResolver 读取该文件。
|
||||
*
|
||||
* 兼容点:
|
||||
* - 不需要 READ_EXTERNAL_STORAGE 等危险权限;
|
||||
* - 适配 Android 10+ 分区存储,也可访问外部 SD / 云端文档(Drive 等);
|
||||
* - 通过 {@link ActivityResultLauncher}(新 API)回调,天然具备生命周期安全。
|
||||
*/
|
||||
public final class SafPicker {
|
||||
|
||||
/** 选择回调:返回用户所选文件的 URI(已取持久化读权限) */
|
||||
public interface OnPicked {
|
||||
void onPicked(@NonNull Uri uri);
|
||||
}
|
||||
|
||||
private final ActivityResultLauncher<Intent> launcher;
|
||||
private OnPicked callback;
|
||||
|
||||
public SafPicker(@NonNull AppCompatActivity activity, @NonNull OnPicked callback) {
|
||||
this.callback = callback;
|
||||
this.launcher = activity.registerForActivityResult(
|
||||
new ActivityResultContracts.StartActivityForResult(),
|
||||
result -> {
|
||||
if (result.getResultCode() == Activity.RESULT_OK
|
||||
&& result.getData() != null) {
|
||||
Uri uri = result.getData().getData();
|
||||
if (uri != null) {
|
||||
// 持久化读权限,使后续启动无需再次选择(部分 Provider 不支持,忽略异常)
|
||||
try {
|
||||
activity.getContentResolver().takePersistableUriPermission(
|
||||
uri, Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
} catch (Exception e) {
|
||||
android.util.Log.w("SafPicker", "持久化授权失败(本次会话仍可读): " + e.getMessage());
|
||||
}
|
||||
this.callback.onPicked(uri);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** 打开系统图片选择器(仅图片类型) */
|
||||
public void openImagePicker() {
|
||||
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT);
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE);
|
||||
intent.setType("image/*");
|
||||
// 仅展示图片类型,兼容部分文件管理器
|
||||
intent.putExtra(Intent.EXTRA_MIME_TYPES, new String[]{"image/*"});
|
||||
// 请求持久化读权限
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
| Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION);
|
||||
launcher.launch(intent);
|
||||
}
|
||||
}
|
||||
@@ -125,6 +125,22 @@ public class DeviceCrypto {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 密钥状态 ====================
|
||||
|
||||
/**
|
||||
* 判断 TEE 密钥对是否已存在(是否首次生成)。
|
||||
*
|
||||
* 密钥对一旦生成即持久化于 AndroidKeyStore,App 重启、进程销毁都不会丢失;
|
||||
* 仅在「恢复出厂 resetKeyPair()」或系统清除 Keystore 时才会真正重新生成。
|
||||
*/
|
||||
public boolean hasKeyPair() {
|
||||
try {
|
||||
return keyStore.containsAlias(KEY_ALIAS);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 公钥导出 ====================
|
||||
|
||||
/**
|
||||
@@ -219,6 +235,26 @@ public class DeviceCrypto {
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 密钥生命周期(恢复出厂) ====================
|
||||
|
||||
/**
|
||||
* 模拟恢复出厂:销毁 TEE 中的密钥对并重新生成。
|
||||
*
|
||||
* 真实场景中,恢复出厂会擦除整个 TEE 密钥存储区(出厂重置):
|
||||
* - 旧私钥彻底销毁(服务端随即停用旧设备)
|
||||
* - 生成全新密钥对,仅新私钥可解密后续下发的 Recovery Token / DEK
|
||||
*/
|
||||
public void resetKeyPair() {
|
||||
try {
|
||||
if (keyStore.containsAlias(KEY_ALIAS)) {
|
||||
keyStore.deleteEntry(KEY_ALIAS);
|
||||
}
|
||||
generateTeeKeyPair();
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Reset key pair failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 恢复流程 ====================
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,27 +1,254 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<layout xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
|
||||
<data>
|
||||
<variable
|
||||
name="click"
|
||||
type="com.secure.demo.activity.main.MainActivity.ViewClick" />
|
||||
</data>
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true"
|
||||
android:background="#F5F6FA">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<!-- 标题 -->
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="SecureDevice Demo"
|
||||
android:text="安全设备演示 · 模拟真实操作"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold"
|
||||
android:layout_marginBottom="12dp" />
|
||||
android:textColor="#1A1A2E" />
|
||||
|
||||
<ScrollView
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:text="按真实使用流程逐步手动操作(注册 → 绑定 → 上传 → 下载 → 恢复出厂 → 重新注册 → 短信 → 恢复)"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#888888" />
|
||||
|
||||
<!-- 设备状态摘要 -->
|
||||
<TextView
|
||||
android:id="@+id/status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:background="#FFFFFF"
|
||||
android:padding="12dp"
|
||||
android:text="设备未初始化..."
|
||||
android:textSize="13sp"
|
||||
android:typeface="monospace"
|
||||
android:textColor="#333333"
|
||||
android:elevation="1dp" />
|
||||
|
||||
<!-- 用户 ID -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="用户 ID(绑定/恢复身份)"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#555555" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/user_id"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="例如 user-001"
|
||||
android:text="user-001"
|
||||
android:inputType="text"
|
||||
android:background="@android:drawable/editbox_background" />
|
||||
|
||||
<!-- 手机号 -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="手机号(绑定与短信必须一致)"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#555555" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/phone"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="例如 13800138000"
|
||||
android:text="13800138000"
|
||||
android:inputType="phone"
|
||||
android:background="@android:drawable/editbox_background" />
|
||||
|
||||
<!-- ① 注册设备 | ② 绑定用户 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_register"
|
||||
android:layout_width="0dp"
|
||||
android:onClick="@{click::registerDevice}"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="① 注册设备"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_bind"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
android:onClick="@{click::bindUser}"
|
||||
android:text="② 绑定用户"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ③ 上传图片 | ④ 下载解密 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_upload_image"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:onClick="@{click::uploadImage}"
|
||||
android:text="③ 选择并上传图片"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_download"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
android:onClick="@{click::downloadPhoto}"
|
||||
android:text="④ 下载解密照片"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ⑤ 恢复出厂 | ⑥ 重新注册 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_reset"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:onClick="@{click::resetDevice}"
|
||||
android:text="⑤ 恢复出厂"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_reregister"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
android:onClick="@{click::reRegisterDevice}"
|
||||
android:text="⑥ 重新注册"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- ⑦ 发送短信 -->
|
||||
<Button
|
||||
android:id="@+id/btn_sms"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:onClick="@{click::sendSms}"
|
||||
android:text="⑦ 发送短信验证码"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<!-- 短信验证码 -->
|
||||
<EditText
|
||||
android:id="@+id/sms_code"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:hint="短信验证码(demo 固定 000000)"
|
||||
android:text="000000"
|
||||
android:inputType="number"
|
||||
android:background="@android:drawable/editbox_background" />
|
||||
|
||||
<!-- ⑧ 恢复授权 | ⑨ 恢复照片 -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_recover"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:onClick="@{click::recoverDevice}"
|
||||
android:text="⑧ 恢复授权"
|
||||
android:textSize="13sp" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_recover_photos"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:layout_marginStart="8dp"
|
||||
android:onClick="@{click::recoverPhotos}"
|
||||
android:text="⑨ 恢复照片"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- 图片预览 -->
|
||||
<ImageView
|
||||
android:id="@+id/img_preview"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="180dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:scaleType="centerInside"
|
||||
android:background="#EEEEEE"
|
||||
android:contentDescription="选中图片预览" />
|
||||
|
||||
<!-- 日志区 -->
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="运行日志"
|
||||
android:textSize="12sp"
|
||||
android:textColor="#555555" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/log"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_height="220dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:background="#1E1E2E"
|
||||
android:padding="10dp"
|
||||
android:textColor="#D0E8D0"
|
||||
android:textSize="12sp"
|
||||
android:typeface="monospace" />
|
||||
</ScrollView>
|
||||
android:typeface="monospace"
|
||||
android:scrollbars="vertical"
|
||||
android:fadeScrollbars="false"
|
||||
android:focusable="true"
|
||||
android:clickable="true" />
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</layout>
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
## This file is automatically generated by Android Studio.
|
||||
# Do not modify this file -- YOUR CHANGES WILL BE ERASED!
|
||||
#
|
||||
# This file should *NOT* be checked into Version Control Systems,
|
||||
## This file must *NOT* be checked into Version Control Systems,
|
||||
# as it contains information specific to your local configuration.
|
||||
#
|
||||
# Location of the SDK. This is only used by Gradle.
|
||||
# For customization when using a Version Control System, please read the
|
||||
# header note.
|
||||
sdk.dir=F\:\\AndroidSDK
|
||||
#Thu Aug 20 17:37:36 CST 2026
|
||||
sdk.dir=/mnt/sdk/AndroidSdk
|
||||
|
||||
21
springboot-server/.gitignore
vendored
Normal file
21
springboot-server/.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
# Created by .ignore support plugin (hsz.mobi)
|
||||
### Example sysUserDetails template template
|
||||
### Example sysUserDetails template
|
||||
|
||||
# IntelliJ project files
|
||||
.idea
|
||||
*.iml
|
||||
out
|
||||
gen
|
||||
target
|
||||
*.log
|
||||
logs
|
||||
.history
|
||||
|
||||
|
||||
docker/*/data/
|
||||
docker/minio/config
|
||||
docker/xxljob/logs
|
||||
application-youlai.yml
|
||||
/uploadFile/
|
||||
/uploads/
|
||||
@@ -34,11 +34,24 @@
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Lombok (可选) -->
|
||||
<!-- MySQL 驱动 -->
|
||||
<dependency>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
<groupId>com.mysql</groupId>
|
||||
<artifactId>mysql-connector-j</artifactId>
|
||||
<scope>runtime</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring Data JPA(照片元数据落库) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 测试(JUnit 5 + TestRestTemplate) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
@@ -48,12 +61,7 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||
<configuration>
|
||||
<excludes>
|
||||
<exclude>
|
||||
<groupId>org.projectlombok</groupId>
|
||||
<artifactId>lombok</artifactId>
|
||||
</exclude>
|
||||
</excludes>
|
||||
<jvmArguments>--enable-native-access=ALL-UNNAMED</jvmArguments>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
|
||||
74
springboot-server/sql/schema.sql
Normal file
74
springboot-server/sql/schema.sql
Normal file
@@ -0,0 +1,74 @@
|
||||
-- ============================================================
|
||||
-- 安全设备 Demo - 数据库初始化脚本
|
||||
-- 库名: secure_device (与 application.properties 中配置一致)
|
||||
-- 说明: 存储照片元数据、设备、用户、用户照片索引
|
||||
-- 密文本身落盘于 uploads/ 目录
|
||||
-- 服务端永远不存明文照片和明文 DEK
|
||||
-- ============================================================
|
||||
|
||||
-- 1. 创建数据库(如已存在可跳过)
|
||||
CREATE DATABASE IF NOT EXISTS `secure_device`
|
||||
DEFAULT CHARACTER SET utf8mb4
|
||||
DEFAULT COLLATE utf8mb4_general_ci;
|
||||
|
||||
USE `secure_device`;
|
||||
|
||||
-- 2. 加密照片表(对应 EncryptedPhoto 实体)
|
||||
CREATE TABLE IF NOT EXISTS `encrypted_photo` (
|
||||
`photo_id` VARCHAR(64) NOT NULL COMMENT '照片唯一 ID (PK)',
|
||||
`device_id` VARCHAR(64) DEFAULT NULL COMMENT '来源设备 ID',
|
||||
`user_id` VARCHAR(64) DEFAULT NULL COMMENT '所属用户 ID',
|
||||
`file_path` VARCHAR(512) DEFAULT NULL COMMENT '密文文件落盘路径',
|
||||
`iv_base64` VARCHAR(256) DEFAULT NULL COMMENT 'GCM IV (Base64)',
|
||||
`encrypted_dek_base64` VARCHAR(1024) DEFAULT NULL COMMENT 'DEK 被 UK 加密后的密文 (Base64)',
|
||||
`metadata_signature` VARCHAR(1024) DEFAULT NULL COMMENT '设备私钥签名 (Base64)',
|
||||
`upload_time` BIGINT DEFAULT NULL COMMENT '上传时间戳 (ms)',
|
||||
PRIMARY KEY (`photo_id`),
|
||||
KEY `idx_user_id` (`user_id`),
|
||||
KEY `idx_device_id` (`device_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='加密照片元数据表';
|
||||
|
||||
-- 3. 设备表(对应 Device 实体)
|
||||
CREATE TABLE IF NOT EXISTS `device` (
|
||||
`device_id` VARCHAR(64) NOT NULL COMMENT '设备唯一 ID (UUID, PK)',
|
||||
`sn` VARCHAR(64) NOT NULL COMMENT '设备序列号 (唯一)',
|
||||
`user_id` VARCHAR(64) DEFAULT NULL COMMENT '绑定的用户 ID',
|
||||
`public_key_base64` TEXT DEFAULT NULL COMMENT '设备 TEE 公钥 (Base64)',
|
||||
`bind_time` BIGINT DEFAULT NULL COMMENT '绑定时间戳 (ms)',
|
||||
`last_recovery_time` BIGINT DEFAULT NULL COMMENT '最近一次恢复时间戳 (ms)',
|
||||
`active` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否激活 (1 激活 / 0 停用)',
|
||||
PRIMARY KEY (`device_id`),
|
||||
KEY `idx_device_sn` (`sn`),
|
||||
KEY `idx_device_user` (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='设备注册与绑定状态表(一个 SN 可有多条历史记录,仅一条 active)';
|
||||
|
||||
-- 4. 用户表(对应 User 实体)
|
||||
CREATE TABLE IF NOT EXISTS `app_user` (
|
||||
`user_id` VARCHAR(64) NOT NULL COMMENT '用户 ID (PK)',
|
||||
`phone` VARCHAR(32) DEFAULT NULL COMMENT '手机号(用于短信验证)',
|
||||
`uk_encrypted_base64` TEXT DEFAULT NULL COMMENT '用户主密钥 UK (Base64)',
|
||||
`password` VARCHAR(128) DEFAULT NULL COMMENT '登录密码 (Demo 明文,生产用 BCrypt)',
|
||||
`phone_verified` VARCHAR(8) DEFAULT NULL COMMENT '手机号是否已验证',
|
||||
PRIMARY KEY (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='用户与主密钥表';
|
||||
|
||||
-- 5. 用户-照片关联表(对应 UserPhoto 实体)
|
||||
CREATE TABLE IF NOT EXISTS `user_photo` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
|
||||
`user_id` VARCHAR(64) NOT NULL COMMENT '用户 ID',
|
||||
`photo_id` VARCHAR(64) NOT NULL COMMENT '照片 ID',
|
||||
`created_time` BIGINT DEFAULT NULL COMMENT '创建时间戳 (ms)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_photo_user` (`user_id`),
|
||||
KEY `idx_user_photo_photo` (`photo_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='用户照片索引表';
|
||||
|
||||
-- ============================================================
|
||||
-- 已有数据库迁移(仅当旧库的 device 表带 SN 唯一索引时手动执行)
|
||||
-- 说明:早期版本 device.sn 建为 UNIQUE 索引,导致「恢复出厂重新注册」时
|
||||
-- 报 Duplicate entry。现将 SN 改为普通索引(一个 SN 可有多条历史设备记录,
|
||||
-- 仅一条 active)。spring.jpa.hibernate.ddl-auto=update 不会自动删除已存在
|
||||
-- 的唯一索引,故需手动执行下面两句:
|
||||
-- ============================================================
|
||||
-- ALTER TABLE `device` DROP INDEX `idx_device_sn`;
|
||||
-- ALTER TABLE `device` ADD INDEX `idx_device_sn` (`sn`);
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.secure.demo.auth;
|
||||
|
||||
import com.secure.demo.common.ApiResponse;
|
||||
import com.secure.demo.common.UnauthorizedException;
|
||||
import com.secure.demo.model.User;
|
||||
import com.secure.demo.service.KeyManagementService;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 用户端认证接口(前后端分离后的用户登录体系)。
|
||||
*
|
||||
* 模拟真实场景:
|
||||
* - 用户端(Web / App)先注册/登录获取 Bearer Token
|
||||
* - 后续所有用户侧接口(绑定、短信、恢复、下载解密)携带
|
||||
* {@code Authorization: Bearer <token>}
|
||||
* - 设备端(Android)无登录体系,不调用本接口
|
||||
*
|
||||
* 安全说明(Demo 简化):
|
||||
* - 密码明文存数据库(app_user.password),仅用于演示流程
|
||||
* - 生产环境应使用 Spring Security + BCrypt + JWT/Redis 会话
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
public class AuthController {
|
||||
|
||||
private final KeyManagementService keyManagementService;
|
||||
private final TokenService tokenService;
|
||||
|
||||
public AuthController(KeyManagementService keyManagementService, TokenService tokenService) {
|
||||
this.keyManagementService = keyManagementService;
|
||||
this.tokenService = tokenService;
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户注册
|
||||
*
|
||||
* Request: { "userId": "alice", "phone": "13800138000", "password": "123456" }
|
||||
* Response: data = { "token": "...", "userId": "alice" }
|
||||
*/
|
||||
@PostMapping("/register")
|
||||
public ApiResponse<Map<String, String>> register(@RequestBody Map<String, String> req) {
|
||||
String userId = req.get("userId");
|
||||
String phone = req.get("phone");
|
||||
String password = req.getOrDefault("password", "123456");
|
||||
|
||||
if (userId == null || userId.isBlank()) {
|
||||
throw new IllegalArgumentException("userId required");
|
||||
}
|
||||
if (phone == null || phone.isBlank()) {
|
||||
throw new IllegalArgumentException("phone required");
|
||||
}
|
||||
if (keyManagementService.userExists(userId)) {
|
||||
throw new IllegalArgumentException("User already exists: " + userId);
|
||||
}
|
||||
|
||||
keyManagementService.registerUser(userId, phone);
|
||||
keyManagementService.setPassword(userId, password);
|
||||
|
||||
String token = tokenService.createToken(userId);
|
||||
return ApiResponse.ok(Map.of(
|
||||
"token", token,
|
||||
"userId", userId
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户登录
|
||||
*
|
||||
* Request: { "userId": "alice", "password": "123456" }
|
||||
* Response: data = { "token": "...", "userId": "alice" }
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<Map<String, String>> login(@RequestBody Map<String, String> req) {
|
||||
String userId = req.get("userId");
|
||||
String password = req.get("password");
|
||||
|
||||
if (userId == null || userId.isBlank() || password == null) {
|
||||
throw new IllegalArgumentException("userId and password required");
|
||||
}
|
||||
User user = keyManagementService.getUser(userId);
|
||||
if (user == null) {
|
||||
throw new UnauthorizedException("User not found: " + userId);
|
||||
}
|
||||
String storedPassword = user.getPassword() != null ? user.getPassword() : "123456";
|
||||
if (!password.equals(storedPassword)) {
|
||||
throw new UnauthorizedException("Invalid password");
|
||||
}
|
||||
|
||||
String token = tokenService.createToken(userId);
|
||||
return ApiResponse.ok(Map.of(
|
||||
"token", token,
|
||||
"userId", userId
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出(注销 Token)
|
||||
*
|
||||
* Request: { "token": "..." }
|
||||
*/
|
||||
@PostMapping("/logout")
|
||||
public ApiResponse<Void> logout(@RequestBody Map<String, String> req) {
|
||||
String token = req.get("token");
|
||||
if (token != null) {
|
||||
tokenService.revoke(token);
|
||||
}
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package com.secure.demo.auth;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 登录 Token 服务(内存实现,模拟真实登录体系)。
|
||||
*
|
||||
* 真实生产:Token 应是无状态 JWT(RSA 签名)+ Redis 黑名单,或服务端会话存 Redis。
|
||||
* Demo 用内存 Map 即可,重启后 Token 失效(符合预期)。
|
||||
*/
|
||||
@Service
|
||||
public class TokenService {
|
||||
|
||||
/** Token 有效期(毫秒),默认 24 小时 */
|
||||
private static final long TTL_MS = 24 * 60 * 60 * 1000L;
|
||||
|
||||
/** token -> 过期时间戳 */
|
||||
private final Map<String, Long> tokenExpiry = new ConcurrentHashMap<>();
|
||||
|
||||
/** token -> userId */
|
||||
private final Map<String, String> tokenUser = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 为指定用户签发 Token
|
||||
*/
|
||||
public String createToken(String userId) {
|
||||
String token = UUID.randomUUID().toString().replace("-", "") + UUID.randomUUID().toString().replace("-", "");
|
||||
tokenExpiry.put(token, System.currentTimeMillis() + TTL_MS);
|
||||
tokenUser.put(token, userId);
|
||||
return token;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 Token 并返回 userId;无效/过期返回 null
|
||||
*/
|
||||
public String getUserId(String token) {
|
||||
if (token == null || token.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
Long expiry = tokenExpiry.get(token);
|
||||
if (expiry == null) {
|
||||
return null;
|
||||
}
|
||||
if (expiry < System.currentTimeMillis()) {
|
||||
tokenExpiry.remove(token);
|
||||
tokenUser.remove(token);
|
||||
return null;
|
||||
}
|
||||
return tokenUser.get(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 注销 Token(登出)
|
||||
*/
|
||||
public void revoke(String token) {
|
||||
tokenExpiry.remove(token);
|
||||
tokenUser.remove(token);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.secure.demo.common;
|
||||
|
||||
/**
|
||||
* 携带 HTTP 状态码的业务异常。
|
||||
* 由 GlobalExceptionHandler 统一转换为 ApiResponse 返回。
|
||||
*/
|
||||
public class ApiException extends RuntimeException {
|
||||
|
||||
private final int status;
|
||||
|
||||
public ApiException(int status, String message) {
|
||||
super(message);
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public int getStatus() {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.secure.demo.common;
|
||||
|
||||
/**
|
||||
* 统一 API 响应包装(前后端分离约定的标准响应格式)。
|
||||
*
|
||||
* <pre>
|
||||
* {
|
||||
* "code": 0, // 0 = 成功,非 0 = 业务失败
|
||||
* "message": "ok", // 人类可读信息
|
||||
* "data": { ... } // 业务数据(成功时存在)
|
||||
* }
|
||||
* </pre>
|
||||
*
|
||||
* Web 用户端与 Android 设备端都按此格式解析,不再各自约定裸 Map 结构。
|
||||
*/
|
||||
public class ApiResponse<T> {
|
||||
|
||||
public static final int CODE_OK = 0;
|
||||
|
||||
private int code;
|
||||
private String message;
|
||||
private T data;
|
||||
|
||||
public ApiResponse() {
|
||||
}
|
||||
|
||||
public ApiResponse(int code, String message, T data) {
|
||||
this.code = code;
|
||||
this.message = message;
|
||||
this.data = data;
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> ok(T data) {
|
||||
return new ApiResponse<>(CODE_OK, "ok", data);
|
||||
}
|
||||
|
||||
public static ApiResponse<Void> ok() {
|
||||
return new ApiResponse<>(CODE_OK, "ok", null);
|
||||
}
|
||||
|
||||
public static <T> ApiResponse<T> fail(int code, String message) {
|
||||
return new ApiResponse<>(code, message, null);
|
||||
}
|
||||
|
||||
public boolean isOk() {
|
||||
return code == CODE_OK;
|
||||
}
|
||||
|
||||
// ===== Getters & Setters =====
|
||||
|
||||
public int getCode() {
|
||||
return code;
|
||||
}
|
||||
|
||||
public void setCode(int code) {
|
||||
this.code = code;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return message;
|
||||
}
|
||||
|
||||
public void setMessage(String message) {
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public T getData() {
|
||||
return data;
|
||||
}
|
||||
|
||||
public void setData(T data) {
|
||||
this.data = data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.secure.demo.common;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
|
||||
/**
|
||||
* 全局异常处理:所有异常统一收敛为 ApiResponse 格式,
|
||||
* 前端(Web 用户端 / Android 设备端)只解析 code/message/data 三种字段。
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
/** 业务异常:状态码由异常自带(400/401/403/409/500 等) */
|
||||
@ExceptionHandler(ApiException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleApiException(ApiException e) {
|
||||
return ResponseEntity.status(e.getStatus())
|
||||
.body(ApiResponse.fail(e.getStatus(), e.getMessage()));
|
||||
}
|
||||
|
||||
/** 未登录 / 凭证无效 */
|
||||
@ExceptionHandler(UnauthorizedException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleUnauthorized(UnauthorizedException e) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(ApiResponse.fail(401, e.getMessage()));
|
||||
}
|
||||
|
||||
/** 无权限 / 校验失败 */
|
||||
@ExceptionHandler(SecurityException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleSecurity(SecurityException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.fail(403, e.getMessage()));
|
||||
}
|
||||
|
||||
/** 参数错误 */
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleIllegalArgument(IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.fail(400, e.getMessage()));
|
||||
}
|
||||
|
||||
/** 其余未捕获异常 */
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleOther(Exception e) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.fail(500, "Internal server error: " + e.getMessage()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.secure.demo.common;
|
||||
|
||||
/**
|
||||
* 未登录 / 凭证缺失异常(HTTP 401)。
|
||||
* 与 SecurityException(403,已登录但无权限)区分开。
|
||||
*/
|
||||
public class UnauthorizedException extends RuntimeException {
|
||||
|
||||
public UnauthorizedException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.secure.demo.config;
|
||||
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
/**
|
||||
* Web 层配置。
|
||||
*
|
||||
* 前后端分离后,Web 用户端(web-client,如 http://localhost:3000)
|
||||
* 与后端(http://localhost:8080)跨域通信,必须放开 CORS。
|
||||
* 生产环境请将 allowedOriginPatterns 收紧为具体的域名白名单。
|
||||
*/
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/api/**")
|
||||
.allowedOriginPatterns("*")
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true)
|
||||
.maxAge(3600);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,62 @@
|
||||
package com.secure.demo.controller;
|
||||
|
||||
import com.secure.demo.auth.TokenService;
|
||||
import com.secure.demo.common.ApiException;
|
||||
import com.secure.demo.common.ApiResponse;
|
||||
import com.secure.demo.common.UnauthorizedException;
|
||||
import com.secure.demo.crypto.AesGcmUtil;
|
||||
import com.secure.demo.crypto.RsaUtil;
|
||||
import com.secure.demo.model.Device;
|
||||
import com.secure.demo.model.EncryptedPhoto;
|
||||
import com.secure.demo.repository.EncryptedPhotoRepository;
|
||||
import com.secure.demo.service.DeviceBindingService;
|
||||
import com.secure.demo.service.DeviceBindingService.RecoveryResponse;
|
||||
import com.secure.demo.service.KeyManagementService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.PublicKey;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 设备安全 API 控制器
|
||||
* 设备安全 API 控制器(前后端分离版)
|
||||
*
|
||||
* ┌─────────────────────────────────────────────────────────────┐
|
||||
* │ 接口清单 │
|
||||
* ├─────────────────────────────────────────────────────────────┤
|
||||
* ┌────────────────────────────────────────────────────────────────┐
|
||||
* │ 接口分组(模拟真实场景) │
|
||||
* ├────────────────────────────────────────────────────────────────┤
|
||||
* │ 公开: │
|
||||
* │ POST /api/auth/register 用户注册(返回 Token) │
|
||||
* │ POST /api/auth/login 用户登录(返回 Token) │
|
||||
* │ GET /api/health 健康检查 │
|
||||
* ├────────────────────────────────────────────────────────────────┤
|
||||
* │ 设备端(无登录体系,信任边界=TEE 密钥/SN/Recovery Token): │
|
||||
* │ POST /api/device/register 设备注册(SN + 公钥) │
|
||||
* │ POST /api/device/bind 用户绑定设备 │
|
||||
* │ POST /api/device/sms/send 发送短信验证码 │
|
||||
* │ POST /api/photo/upload 上传加密照片 │
|
||||
* │ POST /api/device/recover 恢复设备(短信验证) │
|
||||
* │ POST /api/photo/upload 上传加密照片(设备签名) │
|
||||
* │ POST /api/photo/recover 恢复后获取照片 DEK │
|
||||
* │ GET /api/photo/{id}/decrypt 用户下载并解密照片 │
|
||||
* └─────────────────────────────────────────────────────────────┘
|
||||
* ├────────────────────────────────────────────────────────────────┤
|
||||
* │ 用户端(需要 Authorization: Bearer <token>): │
|
||||
* │ POST /api/device/bind 绑定设备(SN) │
|
||||
* │ POST /api/device/sms/send 发送短信验证码 │
|
||||
* │ POST /api/device/recover 恢复授权(短信验证) │
|
||||
* │ GET /api/photo/{id}/decrypt 下载并解密照片 │
|
||||
* │ GET /api/user/photos 我的照片列表(新增) │
|
||||
* │ GET /api/user/devices 我的设备列表(新增) │
|
||||
* └────────────────────────────────────────────────────────────────┘
|
||||
*
|
||||
* 鉴权优先级(用户端接口):Bearer Token > X-User-Id Header(Android
|
||||
* demo 兼容)> body.userId(集成测试兼容)。生产环境只保留 Bearer Token。
|
||||
*
|
||||
* 所有响应统一为 ApiResponse{code, message, data},前端只依赖该契约。
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
@@ -41,102 +64,148 @@ public class DeviceController {
|
||||
|
||||
private final KeyManagementService keyManagementService;
|
||||
private final DeviceBindingService deviceBindingService;
|
||||
private final EncryptedPhotoRepository photoRepository;
|
||||
private final TokenService tokenService;
|
||||
|
||||
// 照片数据库(生产环境替换为 JPA/MinIO)
|
||||
private final Map<String, EncryptedPhoto> photoDB = new ConcurrentHashMap<>();
|
||||
// 密文文件落盘根目录(来自配置 app.upload.dir,默认 ./uploads)
|
||||
private final Path uploadRoot;
|
||||
|
||||
public DeviceController(KeyManagementService keyManagementService,
|
||||
DeviceBindingService deviceBindingService) {
|
||||
DeviceBindingService deviceBindingService,
|
||||
EncryptedPhotoRepository photoRepository,
|
||||
TokenService tokenService,
|
||||
@Value("${app.upload.dir:./uploads}") String uploadDir) {
|
||||
this.keyManagementService = keyManagementService;
|
||||
this.deviceBindingService = deviceBindingService;
|
||||
this.photoRepository = photoRepository;
|
||||
this.tokenService = tokenService;
|
||||
this.uploadRoot = Paths.get(uploadDir);
|
||||
// 启动时确保上传目录存在
|
||||
try {
|
||||
Files.createDirectories(this.uploadRoot);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("无法创建上传目录: " + this.uploadRoot, e);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 1. 设备注册 ====================
|
||||
// ==================== 0. 健康检查(公开) ====================
|
||||
|
||||
@GetMapping("/health")
|
||||
public ApiResponse<Map<String, String>> health() {
|
||||
return ApiResponse.ok(Map.of("status", "ok", "service", "secure-device-demo"));
|
||||
}
|
||||
|
||||
// ==================== 1. 设备注册(设备端,无登录) ====================
|
||||
|
||||
/**
|
||||
* 设备首次启动 / 恢复出厂后调用
|
||||
*
|
||||
* Request:
|
||||
* {
|
||||
* "sn": "SN-DEMO-001",
|
||||
* "publicKeyBase64": "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE..."
|
||||
* }
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* "deviceId": "...",
|
||||
* "sn": "SN-DEMO-001",
|
||||
* "message": "Device registered successfully"
|
||||
* }
|
||||
* Request: { "sn": "SN-DEMO-001", "publicKeyBase64": "..." }
|
||||
* Response: data = { "deviceId": "...", "sn": "SN-DEMO-001", "message": "..." }
|
||||
*/
|
||||
@PostMapping("/device/register")
|
||||
public ResponseEntity<?> registerDevice(@RequestBody Map<String, String> req) {
|
||||
public ApiResponse<Map<String, String>> registerDevice(@RequestBody Map<String, String> req) {
|
||||
String sn = req.get("sn");
|
||||
String publicKeyBase64 = req.get("publicKeyBase64");
|
||||
|
||||
if (sn == null || publicKeyBase64 == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "sn and publicKeyBase64 required"));
|
||||
throw new IllegalArgumentException("sn and publicKeyBase64 required");
|
||||
}
|
||||
|
||||
Device device = deviceBindingService.registerDevice(sn, publicKeyBase64);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
return ApiResponse.ok(Map.of(
|
||||
"deviceId", device.getDeviceId(),
|
||||
"sn", device.getSn(),
|
||||
"message", "Device registered successfully"
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 2. 用户绑定设备 ====================
|
||||
/**
|
||||
* 查询设备注册/绑定状态(设备端,无登录,App 启动时调用以复用已有状态)。
|
||||
*
|
||||
* 用途:App 每次重启后,先查询「该 SN 是否已注册/已绑定」,避免重复注册与绑定。
|
||||
*
|
||||
* Request: GET /api/device/status?sn=SN-DEMO-001
|
||||
* Response: data = {
|
||||
* "registered": true, // 该 SN 是否已注册
|
||||
* "bound": true, // 是否已绑定用户
|
||||
* "active": true, // 是否激活
|
||||
* "deviceId": "...", // 已注册时返回
|
||||
* "userId": "...", // 已绑定时返回
|
||||
* "publicKeyBase64": "..." // 已注册时返回(供 App 判断公钥是否轮换)
|
||||
* }
|
||||
*/
|
||||
@GetMapping("/device/status")
|
||||
public ApiResponse<Map<String, Object>> deviceStatus(@RequestParam("sn") String sn) {
|
||||
if (sn == null || sn.isBlank()) {
|
||||
throw new IllegalArgumentException("sn required");
|
||||
}
|
||||
Device device = deviceBindingService.findDeviceBySn(sn);
|
||||
boolean registered = device != null;
|
||||
boolean bound = registered && device.getUserId() != null && !device.getUserId().isBlank();
|
||||
boolean active = registered && device.isActive();
|
||||
|
||||
return ApiResponse.ok(Map.of(
|
||||
"registered", registered,
|
||||
"bound", bound,
|
||||
"active", active,
|
||||
"deviceId", registered ? device.getDeviceId() : "",
|
||||
"userId", bound ? device.getUserId() : "",
|
||||
"publicKeyBase64", registered ? device.getPublicKeyBase64() : ""
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 2. 用户绑定设备(用户端,登录) ====================
|
||||
|
||||
/**
|
||||
* 用户登录后绑定 SN
|
||||
*
|
||||
* Request:
|
||||
* {
|
||||
* "userId": "user-001",
|
||||
* "sn": "SN-DEMO-001"
|
||||
* }
|
||||
* Request: { "sn": "SN-DEMO-001" } (userId 由 Token / Header 解析)
|
||||
* Response: data = { "message": "..." }
|
||||
*/
|
||||
@PostMapping("/device/bind")
|
||||
public ResponseEntity<?> bindDevice(@RequestBody Map<String, String> req) {
|
||||
String userId = req.get("userId");
|
||||
public ApiResponse<Map<String, String>> bindDevice(@RequestBody Map<String, String> req,
|
||||
HttpServletRequest httpRequest) {
|
||||
String userId = resolveUserId(httpRequest, req);
|
||||
String sn = req.get("sn");
|
||||
|
||||
if (userId == null || sn == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "userId and sn required"));
|
||||
if (sn == null) {
|
||||
throw new IllegalArgumentException("sn required");
|
||||
}
|
||||
|
||||
// Demo:用户不存在则自动注册(生产环境从用户体系获取)
|
||||
// 已登录用户不存在时自动注册(生产环境用户一定已存在,此分支仅为兼容旧 demo)
|
||||
if (!keyManagementService.userExists(userId)) {
|
||||
String phone = req.getOrDefault("phone", "13800138000");
|
||||
keyManagementService.registerUser(userId, phone);
|
||||
}
|
||||
|
||||
deviceBindingService.bindDeviceToUser(userId, sn);
|
||||
return ResponseEntity.ok(Map.of("message", "Device bound to user successfully"));
|
||||
return ApiResponse.ok(Map.of("message", "Device bound to user successfully"));
|
||||
}
|
||||
|
||||
// ==================== 3. 短信验证码 ====================
|
||||
// ==================== 3. 短信验证码(用户端,登录) ====================
|
||||
|
||||
/**
|
||||
* 发送短信验证码
|
||||
* 发送短信验证码(登录态防短信轰炸)
|
||||
*
|
||||
* Request: { "phone": "13800138000" }
|
||||
* Response: data = { "message": "..." }
|
||||
*/
|
||||
@PostMapping("/device/sms/send")
|
||||
public ResponseEntity<?> sendSms(@RequestBody Map<String, String> req) {
|
||||
public ApiResponse<Map<String, String>> sendSms(@RequestBody Map<String, String> req,
|
||||
HttpServletRequest httpRequest) {
|
||||
// 必须登录才能发短信(防轰炸)
|
||||
resolveUserId(httpRequest, req);
|
||||
String phone = req.get("phone");
|
||||
if (phone == null) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "phone required"));
|
||||
throw new IllegalArgumentException("phone required");
|
||||
}
|
||||
deviceBindingService.sendSmsCode(phone);
|
||||
return ResponseEntity.ok(Map.of("message", "SMS code sent (check server logs for demo code)"));
|
||||
return ApiResponse.ok(Map.of("message", "SMS code sent (check server logs for demo code)"));
|
||||
}
|
||||
|
||||
// ==================== 4. 上传加密照片 ====================
|
||||
// ==================== 4. 上传加密照片(设备端,无登录) ====================
|
||||
|
||||
/**
|
||||
* 设备上传加密照片(信封加密)
|
||||
* 设备上传加密照片(信封加密,设备私钥签名防伪)
|
||||
*
|
||||
* Request:
|
||||
* {
|
||||
@@ -148,15 +217,9 @@ public class DeviceController {
|
||||
* "metadataSignature": "...", <-- 设备私钥签名
|
||||
* "metadata": "SN-DEMO-001|ts|photo-001"
|
||||
* }
|
||||
*
|
||||
* 服务端处理:
|
||||
* 1. 查找设备 & 验证归属
|
||||
* 2. 验签(TODO:用设备公钥验证 metadataSignature)
|
||||
* 3. 用 UK 加密 DEK(信封加密)
|
||||
* 4. 存储密文 + 加密后的 DEK
|
||||
*/
|
||||
@PostMapping("/photo/upload")
|
||||
public ResponseEntity<?> uploadPhoto(@RequestBody Map<String, String> req) {
|
||||
public ApiResponse<Map<String, String>> uploadPhoto(@RequestBody Map<String, String> req) {
|
||||
String sn = req.get("sn");
|
||||
String photoId = req.getOrDefault("photoId", UUID.randomUUID().toString().replace("-", ""));
|
||||
String ciphertextBase64 = req.get("ciphertextBase64");
|
||||
@@ -168,148 +231,118 @@ public class DeviceController {
|
||||
// 1. 查找设备
|
||||
Device device = deviceBindingService.findDeviceBySn(sn);
|
||||
if (device == null) {
|
||||
return ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(Map.of("error", "Device not registered for SN: " + sn));
|
||||
throw new ApiException(404, "Device not registered for SN: " + sn);
|
||||
}
|
||||
if (!device.isActive()) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("error", "Device not active. Please complete recovery."));
|
||||
throw new SecurityException("Device not active. Please complete recovery.");
|
||||
}
|
||||
|
||||
String userId = device.getUserId();
|
||||
if (userId == null) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("error", "Device not bound to any user"));
|
||||
throw new SecurityException("Device not bound to any user");
|
||||
}
|
||||
|
||||
// 2. TODO: 验签
|
||||
// PublicKey devicePubKey = RsaUtil.publicKeyFromBase64(device.getPublicKeyBase64());
|
||||
// boolean valid = SignatureUtil.verify(metadata, metadataSignature, devicePubKey);
|
||||
// if (!valid) return 403;
|
||||
// 2. 验签:用设备公钥验证 metadataSignature(SHA256withRSA),防伪/防篡改
|
||||
if (metadata == null || metadataSignature == null || metadataSignature.isEmpty()) {
|
||||
throw new IllegalArgumentException("metadata and metadataSignature required");
|
||||
}
|
||||
// 元数据必须以设备 SN 开头,防止跨设备重放他人签名
|
||||
if (!metadata.startsWith(sn + "|")) {
|
||||
throw new SecurityException("metadata must be bound to this device SN");
|
||||
}
|
||||
PublicKey devicePubKey = RsaUtil.publicKeyFromBase64(device.getPublicKeyBase64());
|
||||
if (!RsaUtil.verifySignature(metadata, metadataSignature, devicePubKey)) {
|
||||
throw new SecurityException("Metadata signature verification failed");
|
||||
}
|
||||
|
||||
// 3. 用 UK 加密 DEK(服务端永远只存 "UK 加密后的 DEK")
|
||||
String encryptedDek = keyManagementService.wrapDEK(dekBase64, userId);
|
||||
|
||||
// 4. 存储
|
||||
// 4. 密文落盘
|
||||
String filePath;
|
||||
try {
|
||||
filePath = writeCiphertextToFile(photoId, ciphertextBase64);
|
||||
} catch (IOException e) {
|
||||
throw new ApiException(500, "Failed to save ciphertext file: " + e.getMessage());
|
||||
}
|
||||
|
||||
// 5. 元数据存数据库
|
||||
EncryptedPhoto photo = new EncryptedPhoto(
|
||||
photoId, device.getDeviceId(), userId,
|
||||
ciphertextBase64, ivBase64,
|
||||
filePath, ivBase64,
|
||||
encryptedDek, metadataSignature
|
||||
);
|
||||
photoDB.put(photoId, photo);
|
||||
photoRepository.save(photo);
|
||||
deviceBindingService.addPhotoToUser(userId, photoId);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
return ApiResponse.ok(Map.of(
|
||||
"photoId", photoId,
|
||||
"message", "Photo uploaded and encrypted successfully"
|
||||
"filePath", filePath,
|
||||
"message", "Photo uploaded, encrypted and saved successfully"
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 5. 恢复设备(核心) ====================
|
||||
// ==================== 5. 恢复授权(用户端,登录) ====================
|
||||
|
||||
/**
|
||||
* 恢复出厂后重新绑定 + 授权
|
||||
*
|
||||
* Request:
|
||||
* {
|
||||
* "userId": "user-001",
|
||||
* "sn": "SN-DEMO-001",
|
||||
* "smsCode": "123456",
|
||||
* "newPublicKeyBase64": "MFkwEwYHKoZIzj0CAQY..."
|
||||
* }
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* "deviceId": "...",
|
||||
* "encryptedRecoveryToken": "...", <-- 用新设备公钥加密
|
||||
* "nonce": "...",
|
||||
* "message": "..."
|
||||
* }
|
||||
*
|
||||
* 安全链路:
|
||||
* 1. 验证短信 → 确认是用户本人
|
||||
* 2. 确认 SN 归属 → 确认设备所有权
|
||||
* 3. 更新公钥 → 恢复出厂后密钥对已变
|
||||
* 4. 生成 Token → 用新公钥加密下发
|
||||
* 5. 旧设备自动失效(TEE 私钥已销毁)
|
||||
* Request: { "sn": "...", "smsCode": "000000", "newPublicKeyBase64": "..." }
|
||||
* Response: data = { "deviceId": "...", "encryptedRecoveryToken": "...", "nonce": "...", "message": "..." }
|
||||
*/
|
||||
@PostMapping("/device/recover")
|
||||
public ResponseEntity<?> recoverDevice(@RequestBody Map<String, String> req) {
|
||||
String userId = req.get("userId");
|
||||
public ApiResponse<Map<String, String>> recoverDevice(@RequestBody Map<String, String> req,
|
||||
HttpServletRequest httpRequest) {
|
||||
String userId = resolveUserId(httpRequest, req);
|
||||
String sn = req.get("sn");
|
||||
String smsCode = req.get("smsCode");
|
||||
String newPublicKeyBase64 = req.get("newPublicKeyBase64");
|
||||
|
||||
if (userId == null || sn == null || smsCode == null || newPublicKeyBase64 == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "userId, sn, smsCode, newPublicKeyBase64 all required"));
|
||||
if (sn == null || smsCode == null || newPublicKeyBase64 == null) {
|
||||
throw new IllegalArgumentException("sn, smsCode, newPublicKeyBase64 all required");
|
||||
}
|
||||
|
||||
try {
|
||||
RecoveryResponse resp = deviceBindingService.recoverDevice(
|
||||
userId, sn, smsCode, newPublicKeyBase64
|
||||
);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
return ApiResponse.ok(Map.of(
|
||||
"deviceId", resp.deviceId,
|
||||
"encryptedRecoveryToken", resp.encryptedRecoveryToken,
|
||||
"nonce", resp.nonce,
|
||||
"message", "Recovery authorized. Device can now fetch DEKs."
|
||||
));
|
||||
} catch (SecurityException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(Map.of("error", e.getMessage()));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 6. 恢复后获取照片 DEK ====================
|
||||
// ==================== 6. 恢复后获取照片 DEK(设备端,无登录) ====================
|
||||
|
||||
/**
|
||||
* 设备用 Recovery Token 获取该用户所有照片的 DEK
|
||||
* 服务端用设备当前公钥逐一加密 DEK 后下发
|
||||
*
|
||||
* Request:
|
||||
* {
|
||||
* "deviceId": "...",
|
||||
* "recoveryToken": "...",
|
||||
* "userId": "user-001"
|
||||
* }
|
||||
*
|
||||
* Response:
|
||||
* {
|
||||
* "deviceId": "...",
|
||||
* "photoCount": 3,
|
||||
* "deks": [
|
||||
* {"photoId": "p1", "encryptedDekBase64": "..."},
|
||||
* ...
|
||||
* ]
|
||||
* }
|
||||
* Request: { "deviceId": "...", "recoveryToken": "...", "userId": "user-001" }
|
||||
* Response: data = { "deviceId": "...", "photoCount": 3, "deks": [{"photoId","encryptedDekBase64"}] }
|
||||
*/
|
||||
@PostMapping("/photo/recover")
|
||||
public ResponseEntity<?> recoverPhotos(@RequestBody Map<String, String> req) {
|
||||
public ApiResponse<Map<String, Object>> recoverPhotos(@RequestBody Map<String, String> req) {
|
||||
String deviceId = req.get("deviceId");
|
||||
String recoveryToken = req.get("recoveryToken");
|
||||
String userId = req.get("userId");
|
||||
|
||||
if (deviceId == null || recoveryToken == null || userId == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "deviceId, recoveryToken, userId all required"));
|
||||
throw new IllegalArgumentException("deviceId, recoveryToken, userId all required");
|
||||
}
|
||||
|
||||
// 1. 验证设备
|
||||
Device device = deviceBindingService.getDevice(deviceId);
|
||||
if (device == null || !device.isActive()) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("error", "Device not active"));
|
||||
throw new SecurityException("Device not active");
|
||||
}
|
||||
if (!userId.equals(device.getUserId())) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("error", "Token does not match device owner"));
|
||||
throw new SecurityException("Token does not match device owner");
|
||||
}
|
||||
|
||||
// 2. TODO: 解析并验证 recoveryToken 中的 nonce + 时间窗口
|
||||
// Token 格式: userId|deviceId|timestamp|nonce
|
||||
// 验证 deviceId 匹配、时间在窗口内、nonce 未重放
|
||||
// 2. 验证 recoveryToken:deviceId 匹配 + 5 分钟时间窗口 + nonce 一次性防重放
|
||||
// Token 明文格式: userId|deviceId|timestamp|nonce(设备用 TEE 私钥解密后回传)
|
||||
if (!deviceBindingService.validateRecoveryToken(deviceId, recoveryToken)) {
|
||||
throw new SecurityException("Invalid, expired or replayed recovery token");
|
||||
}
|
||||
|
||||
// 3. 获取设备公钥,用于包裹 DEK 下发
|
||||
PublicKey devicePubKey = RsaUtil.publicKeyFromBase64(device.getPublicKeyBase64());
|
||||
@@ -317,7 +350,7 @@ public class DeviceController {
|
||||
// 4. 遍历照片:UK 解 DEK → 设备公钥加密 DEK → 下发
|
||||
List<Map<String, String>> dekList = new ArrayList<>();
|
||||
for (String photoId : deviceBindingService.getUserPhotoIds(userId)) {
|
||||
EncryptedPhoto photo = photoDB.get(photoId);
|
||||
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
|
||||
if (photo == null) continue;
|
||||
|
||||
// 用 UK 解出明文 DEK
|
||||
@@ -332,66 +365,150 @@ public class DeviceController {
|
||||
));
|
||||
}
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
return ApiResponse.ok(Map.of(
|
||||
"deviceId", deviceId,
|
||||
"photoCount", dekList.size(),
|
||||
"deks", dekList
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 7. 用户下载照片(解密) ====================
|
||||
// ==================== 7. 下载解密照片(用户端,登录) ====================
|
||||
|
||||
/**
|
||||
* 用户端(已登录)下载并解密照片
|
||||
*
|
||||
* 流程:
|
||||
* 1. 鉴权(X-User-Id Header)
|
||||
* 2. 检查 photo 归属
|
||||
* 3. UK 解密 DEK → DEK 解密照片
|
||||
* 4. 明文通过 HTTPS 返回
|
||||
* 流程:鉴权(Bearer Token / X-User-Id) → 归属检查 → UK 解 DEK → DEK 解照片
|
||||
* Response: data = { "photoId": "...", "plaintextBase64": "..." }
|
||||
*/
|
||||
@GetMapping("/photo/{photoId}/decrypt")
|
||||
public ResponseEntity<?> downloadAndDecrypt(@PathVariable String photoId,
|
||||
@RequestHeader("X-User-Id") String userId) {
|
||||
EncryptedPhoto photo = photoDB.get(photoId);
|
||||
public ApiResponse<Map<String, String>> downloadAndDecrypt(@PathVariable String photoId,
|
||||
HttpServletRequest httpRequest) {
|
||||
String userId = resolveUserId(httpRequest, null);
|
||||
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
|
||||
if (photo == null) {
|
||||
return ResponseEntity.notFound().build();
|
||||
throw new ApiException(404, "Photo not found: " + photoId);
|
||||
}
|
||||
|
||||
// 归属检查
|
||||
if (!userId.equals(photo.getUserId())) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(Map.of("error", "Not your photo"));
|
||||
throw new SecurityException("Not your photo");
|
||||
}
|
||||
|
||||
try {
|
||||
// 从文件读取密文并转 Base64 字符串
|
||||
String ciphertextBase64 = readCiphertextFromFile(photo.getFilePath());
|
||||
|
||||
// 1. UK 解密 DEK
|
||||
SecretKey dek = keyManagementService.unwrapDEK(photo.getEncryptedDekBase64(), userId);
|
||||
|
||||
// 2. DEK 解密照片
|
||||
byte[] plaintext = AesGcmUtil.decrypt(
|
||||
photo.getCiphertextBase64(),
|
||||
ciphertextBase64,
|
||||
photo.getIvBase64(),
|
||||
dek
|
||||
);
|
||||
|
||||
// 3. 返回明文(生产环境用 StreamingResponseBody 大文件流式传输)
|
||||
return ResponseEntity.ok()
|
||||
.header("Content-Type", "application/octet-stream")
|
||||
.header("Content-Disposition",
|
||||
"attachment; filename=\"" + photoId + ".bin\"")
|
||||
.body(Base64.getEncoder().encodeToString(plaintext));
|
||||
// 3. 返回明文(JSON Base64,生产环境可用 StreamingResponseBody 流式传输)
|
||||
return ApiResponse.ok(Map.of(
|
||||
"photoId", photoId,
|
||||
"plaintextBase64", Base64.getEncoder().encodeToString(plaintext)
|
||||
));
|
||||
|
||||
} catch (Exception e) {
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(Map.of("error", "Decryption failed: " + e.getMessage()));
|
||||
throw new ApiException(500, "Decryption failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 8. 健康检查 ====================
|
||||
// ==================== 8. 我的照片列表(用户端,登录) ====================
|
||||
|
||||
@GetMapping("/health")
|
||||
public ResponseEntity<?> health() {
|
||||
return ResponseEntity.ok(Map.of("status", "ok", "service", "secure-device-demo"));
|
||||
/**
|
||||
* 当前登录用户的照片列表(仅 photoId,不含密文/DEK)
|
||||
*
|
||||
* Response: data = { "photos": ["photo-001", "photo-002", ...] }
|
||||
*/
|
||||
@GetMapping("/user/photos")
|
||||
public ApiResponse<Map<String, Object>> getUserPhotos(HttpServletRequest httpRequest) {
|
||||
String userId = resolveUserId(httpRequest, null);
|
||||
List<String> photoIds = deviceBindingService.getUserPhotoIds(userId);
|
||||
return ApiResponse.ok(Map.of("photos", photoIds));
|
||||
}
|
||||
|
||||
// ==================== 9. 我的设备列表(用户端,登录) ====================
|
||||
|
||||
/**
|
||||
* 当前登录用户的设备列表
|
||||
*
|
||||
* Response: data = { "devices": [ { "deviceId","sn","active","bindTime","lastRecoveryTime" } ] }
|
||||
*/
|
||||
@GetMapping("/user/devices")
|
||||
public ApiResponse<Map<String, Object>> getUserDevices(HttpServletRequest httpRequest) {
|
||||
String userId = resolveUserId(httpRequest, null);
|
||||
List<Device> devices = deviceBindingService.getDevicesByUser(userId);
|
||||
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
for (Device d : devices) {
|
||||
list.add(Map.of(
|
||||
"deviceId", d.getDeviceId(),
|
||||
"sn", d.getSn(),
|
||||
"active", d.isActive(),
|
||||
"bindTime", d.getBindTime(),
|
||||
"lastRecoveryTime", d.getLastRecoveryTime()
|
||||
));
|
||||
}
|
||||
return ApiResponse.ok(Map.of("devices", list));
|
||||
}
|
||||
|
||||
// ==================== 鉴权辅助 ====================
|
||||
|
||||
/**
|
||||
* 解析当前请求的用户 ID。
|
||||
*
|
||||
* 优先级:
|
||||
* 1. Authorization: Bearer <token>(Web 用户端登录后携带,真实场景唯一方式)
|
||||
* 2. X-User-Id Header(Android 设备端 demo 兼容,AuthInterceptor 自动注入)
|
||||
* 3. body.userId(集成测试 / 旧调用兼容)
|
||||
*
|
||||
* 均无 → 401 Unauthorized
|
||||
*/
|
||||
private String resolveUserId(HttpServletRequest request, Map<String, String> body) {
|
||||
// 1. Bearer Token(真实场景)
|
||||
String auth = request.getHeader("Authorization");
|
||||
if (auth != null && auth.startsWith("Bearer ")) {
|
||||
String userId = tokenService.getUserId(auth.substring(7).trim());
|
||||
if (userId != null) {
|
||||
return userId;
|
||||
}
|
||||
throw new UnauthorizedException("Invalid or expired token");
|
||||
}
|
||||
// 2. X-User-Id Header(Android demo 兼容)
|
||||
String headerUserId = request.getHeader("X-User-Id");
|
||||
if (headerUserId != null && !headerUserId.isBlank()) {
|
||||
return headerUserId;
|
||||
}
|
||||
// 3. body.userId(集成测试 / 旧调用兼容)
|
||||
if (body != null && body.get("userId") != null && !body.get("userId").isBlank()) {
|
||||
return body.get("userId");
|
||||
}
|
||||
throw new UnauthorizedException("Login required: missing Authorization Bearer token");
|
||||
}
|
||||
|
||||
// ==================== 文件落盘辅助方法 ====================
|
||||
|
||||
/**
|
||||
* 将 Base64 密文解码后写入上传目录,文件名 = {photoId}.enc
|
||||
*
|
||||
* @return 文件绝对路径
|
||||
*/
|
||||
private String writeCiphertextToFile(String photoId, String ciphertextBase64) throws IOException {
|
||||
Path target = uploadRoot.resolve(photoId + ".enc");
|
||||
byte[] raw = Base64.getDecoder().decode(ciphertextBase64);
|
||||
Files.write(target, raw);
|
||||
return target.toAbsolutePath().toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从落盘文件读取密文并转为 Base64 字符串(供 AesGcmUtil.decrypt 使用)
|
||||
*/
|
||||
private String readCiphertextFromFile(String filePath) throws IOException {
|
||||
byte[] raw = Files.readAllBytes(Paths.get(filePath));
|
||||
return Base64.getEncoder().encodeToString(raw);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,6 +83,13 @@ public class AesGcmUtil {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 从原始字节构建 AES 密钥(用于恢复流程:解密 DEK 后还原 SecretKey)
|
||||
*/
|
||||
public static SecretKey secretKeyFromBytes(byte[] keyBytes) {
|
||||
return new javax.crypto.spec.SecretKeySpec(keyBytes, ALGO);
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密结果
|
||||
*/
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.secure.demo.crypto;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PublicKey;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import javax.crypto.Cipher;
|
||||
@@ -12,16 +14,18 @@ import javax.crypto.Cipher;
|
||||
* 与 Android 端 DeviceCrypto 配套:
|
||||
* - Android Keystore 生成 RSA-2048 密钥对
|
||||
* - 私钥在 TEE 中不可导出
|
||||
* - 公钥上传服务端,用于加密下发数据
|
||||
* - 公钥上传服务端,用于加密下发数据 / 验证设备签名
|
||||
*
|
||||
* 用途:
|
||||
* - 加密 Recovery Token(设备恢复时下发)
|
||||
* - 加密 DEK(恢复后逐张照片下发)
|
||||
* - 验证设备对照片元数据的签名(SHA256withRSA)
|
||||
*/
|
||||
public class RsaUtil {
|
||||
|
||||
private static final String KEY_ALGO = "RSA";
|
||||
private static final String TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding";
|
||||
private static final String SIGN_ALGO = "SHA256withRSA";
|
||||
|
||||
private RsaUtil() {}
|
||||
|
||||
@@ -58,4 +62,31 @@ public class RsaUtil {
|
||||
public static String encryptBase64(byte[] data, PublicKey publicKey) {
|
||||
return Base64.getEncoder().encodeToString(encryptWithPublicKey(data, publicKey));
|
||||
}
|
||||
|
||||
/**
|
||||
* 用设备 RSA 私钥解密(恢复流程:设备在 TEE 内解密 Recovery Token / DEK)
|
||||
*/
|
||||
public static byte[] decryptWithPrivateKey(byte[] data, java.security.PrivateKey privateKey) {
|
||||
try {
|
||||
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||
cipher.init(Cipher.DECRYPT_MODE, privateKey);
|
||||
return cipher.doFinal(data);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("RSA decrypt failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 用设备 RSA 公钥验证 SHA256withRSA 签名(照片元数据防伪/防篡改)
|
||||
*/
|
||||
public static boolean verifySignature(String data, String signatureBase64, PublicKey publicKey) {
|
||||
try {
|
||||
Signature signature = Signature.getInstance(SIGN_ALGO);
|
||||
signature.initVerify(publicKey);
|
||||
signature.update(data.getBytes(StandardCharsets.UTF_8));
|
||||
return signature.verify(Base64.getDecoder().decode(signatureBase64));
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,44 @@
|
||||
package com.secure.demo.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
/**
|
||||
* 设备实体
|
||||
* 设备实体(JPA 持久化,替换原内存 ConcurrentHashMap)
|
||||
*
|
||||
* 安全要点:
|
||||
* - publicKeyBase64:设备 TEE 公钥(用于加密下发数据)
|
||||
* - 每次恢复出厂会生成新密钥对,需要更新此字段
|
||||
* - sn 仅做身份标识,不参与加密
|
||||
* - 旧设备在恢复出厂 / 重新注册时通过 active=false 停用,而非物理删除
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "device",
|
||||
indexes = {
|
||||
@Index(name = "idx_device_sn", columnList = "sn"),
|
||||
@Index(name = "idx_device_user", columnList = "userId")
|
||||
})
|
||||
public class Device {
|
||||
|
||||
private String deviceId; // 设备唯一ID(可用 SN 或 UUID)
|
||||
@Id
|
||||
@Column(name = "device_id", length = 64)
|
||||
private String deviceId; // 设备唯一ID(UUID)
|
||||
|
||||
@Column(name = "sn", length = 64, nullable = false)
|
||||
private String sn; // 设备序列号
|
||||
|
||||
@Column(name = "user_id", length = 64)
|
||||
private String userId; // 绑定的用户ID
|
||||
|
||||
@Column(name = "public_key_base64", columnDefinition = "TEXT")
|
||||
private String publicKeyBase64; // 设备 TEE 公钥(Base64)
|
||||
|
||||
@Column(name = "bind_time")
|
||||
private long bindTime; // 绑定时间
|
||||
|
||||
@Column(name = "last_recovery_time")
|
||||
private long lastRecoveryTime; // 最近一次恢复时间
|
||||
|
||||
@Column(name = "active")
|
||||
private boolean active; // 是否激活
|
||||
|
||||
// 构造器
|
||||
|
||||
@@ -1,37 +1,57 @@
|
||||
package com.secure.demo.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
/**
|
||||
* 加密照片实体
|
||||
*
|
||||
* 安全要点:
|
||||
* - ciphertextBase64:AES-GCM 密文
|
||||
* - filePath:密文文件落盘路径(AES-GCM 密文,非 Base64 内联)
|
||||
* - ivBase64:GCM IV(每次随机)
|
||||
* - encryptedDekBase64:DEK 被用户主密钥(UK)加密后的密文
|
||||
* - metadataSignature:设备私钥签名(防伪造)
|
||||
*
|
||||
* 服务端永远不存明文照片和明文 DEK
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "encrypted_photo")
|
||||
public class EncryptedPhoto {
|
||||
|
||||
@Id
|
||||
@Column(name = "photo_id", length = 64)
|
||||
private String photoId;
|
||||
|
||||
@Column(name = "device_id", length = 64)
|
||||
private String deviceId; // 来源设备
|
||||
|
||||
@Column(name = "user_id", length = 64)
|
||||
private String userId; // 所属用户
|
||||
private String ciphertextBase64; // 照片密文
|
||||
|
||||
@Column(name = "file_path", length = 512)
|
||||
private String filePath; // 密文文件落盘路径
|
||||
|
||||
@Column(name = "iv_base64", length = 256)
|
||||
private String ivBase64; // GCM IV
|
||||
|
||||
@Column(name = "encrypted_dek_base64", length = 1024)
|
||||
private String encryptedDekBase64; // DEK 被 UK 加密后的密文
|
||||
|
||||
@Column(name = "metadata_signature", length = 1024)
|
||||
private String metadataSignature; // 设备签名
|
||||
|
||||
@Column(name = "upload_time")
|
||||
private long uploadTime;
|
||||
|
||||
// 构造器
|
||||
public EncryptedPhoto() {}
|
||||
|
||||
public EncryptedPhoto(String photoId, String deviceId, String userId,
|
||||
String ciphertextBase64, String ivBase64,
|
||||
String filePath, String ivBase64,
|
||||
String encryptedDekBase64, String metadataSignature) {
|
||||
this.photoId = photoId;
|
||||
this.deviceId = deviceId;
|
||||
this.userId = userId;
|
||||
this.ciphertextBase64 = ciphertextBase64;
|
||||
this.filePath = filePath;
|
||||
this.ivBase64 = ivBase64;
|
||||
this.encryptedDekBase64 = encryptedDekBase64;
|
||||
this.metadataSignature = metadataSignature;
|
||||
@@ -49,8 +69,8 @@ public class EncryptedPhoto {
|
||||
public String getUserId() { return userId; }
|
||||
public void setUserId(String userId) { this.userId = userId; }
|
||||
|
||||
public String getCiphertextBase64() { return ciphertextBase64; }
|
||||
public void setCiphertextBase64(String ciphertextBase64) { this.ciphertextBase64 = ciphertextBase64; }
|
||||
public String getFilePath() { return filePath; }
|
||||
public void setFilePath(String filePath) { this.filePath = filePath; }
|
||||
|
||||
public String getIvBase64() { return ivBase64; }
|
||||
public void setIvBase64(String ivBase64) { this.ivBase64 = ivBase64; }
|
||||
|
||||
@@ -1,21 +1,37 @@
|
||||
package com.secure.demo.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.util.Base64;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
/**
|
||||
* 用户实体
|
||||
* 用户实体(JPA 持久化,替换原内存 ConcurrentHashMap)
|
||||
*
|
||||
* 安全要点:
|
||||
* - ukEncryptedBase64:用户主密钥(UK),由用户口令或 KMS 保护
|
||||
* - 实际生产环境 UK 不应直接存数据库,应由 KMS 托管
|
||||
* - password 为 Demo 简化(明文),生产环境应使用 BCrypt 哈希
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "app_user")
|
||||
public class User {
|
||||
|
||||
@Id
|
||||
@Column(name = "user_id", length = 64)
|
||||
private String userId;
|
||||
|
||||
@Column(name = "phone", length = 32)
|
||||
private String phone; // 手机号(用于短信验证)
|
||||
|
||||
@Column(name = "uk_encrypted_base64", columnDefinition = "TEXT")
|
||||
private String ukEncryptedBase64; // 加密后的用户主密钥(UK)
|
||||
|
||||
@Column(name = "password", length = 128)
|
||||
private String password; // 登录密码(Demo 明文,生产用 BCrypt)
|
||||
|
||||
@Column(name = "phone_verified", length = 8)
|
||||
private String phoneVerified; // 手机号是否已验证
|
||||
|
||||
// 构造器
|
||||
@@ -55,6 +71,9 @@ public class User {
|
||||
public String getUkEncryptedBase64() { return ukEncryptedBase64; }
|
||||
public void setUkEncryptedBase64(String ukEncryptedBase64) { this.ukEncryptedBase64 = ukEncryptedBase64; }
|
||||
|
||||
public String getPassword() { return password; }
|
||||
public void setPassword(String password) { this.password = password; }
|
||||
|
||||
public String getPhoneVerified() { return phoneVerified; }
|
||||
public void setPhoneVerified(String phoneVerified) { this.phoneVerified = phoneVerified; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.secure.demo.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
/**
|
||||
* 用户-照片 关联实体(JPA 持久化,替换原内存 userPhotos Map)。
|
||||
*
|
||||
* 用途:记录用户上传的照片索引(userId -> photoId 列表),
|
||||
* 用于「我的照片列表」「恢复照片遍历」等场景。
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "user_photo",
|
||||
indexes = {
|
||||
@Index(name = "idx_user_photo_user", columnList = "user_id")
|
||||
})
|
||||
public class UserPhoto {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "id")
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_id", length = 64, nullable = false)
|
||||
private String userId;
|
||||
|
||||
@Column(name = "photo_id", length = 64, nullable = false)
|
||||
private String photoId;
|
||||
|
||||
@Column(name = "created_time")
|
||||
private long createdTime;
|
||||
|
||||
public UserPhoto() {}
|
||||
|
||||
public UserPhoto(String userId, String photoId) {
|
||||
this.userId = userId;
|
||||
this.photoId = photoId;
|
||||
this.createdTime = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
// ===== Getters & Setters =====
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public String getUserId() { return userId; }
|
||||
public void setUserId(String userId) { this.userId = userId; }
|
||||
|
||||
public String getPhotoId() { return photoId; }
|
||||
public void setPhotoId(String photoId) { this.photoId = photoId; }
|
||||
|
||||
public long getCreatedTime() { return createdTime; }
|
||||
public void setCreatedTime(long createdTime) { this.createdTime = createdTime; }
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.secure.demo.repository;
|
||||
|
||||
import com.secure.demo.model.Device;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 设备仓库(JPA 持久化)。
|
||||
*/
|
||||
@Repository
|
||||
public interface DeviceRepository extends JpaRepository<Device, String> {
|
||||
|
||||
/** 按 SN 查找「当前激活」的设备(一个 SN 可能有多条历史记录,仅一条 active) */
|
||||
Optional<Device> findBySnAndActiveTrue(String sn);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.secure.demo.repository;
|
||||
|
||||
import com.secure.demo.model.EncryptedPhoto;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface EncryptedPhotoRepository extends JpaRepository<EncryptedPhoto, String> {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.secure.demo.repository;
|
||||
|
||||
import com.secure.demo.model.UserPhoto;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户-照片关联仓库(JPA 持久化)。
|
||||
*/
|
||||
@Repository
|
||||
public interface UserPhotoRepository extends JpaRepository<UserPhoto, Long> {
|
||||
|
||||
/** 按用户查询其所有照片 ID(按创建时间升序) */
|
||||
List<UserPhoto> findByUserIdOrderByCreatedTimeAsc(String userId);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.secure.demo.repository;
|
||||
|
||||
import com.secure.demo.model.User;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
/**
|
||||
* 用户仓库(JPA 持久化)。
|
||||
*/
|
||||
@Repository
|
||||
public interface UserRepository extends JpaRepository<User, String> {
|
||||
}
|
||||
@@ -3,20 +3,22 @@ package com.secure.demo.service;
|
||||
import com.secure.demo.crypto.RsaUtil;
|
||||
import com.secure.demo.model.Device;
|
||||
import com.secure.demo.model.User;
|
||||
import com.secure.demo.model.UserPhoto;
|
||||
import com.secure.demo.repository.DeviceRepository;
|
||||
import com.secure.demo.repository.UserPhotoRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import javax.crypto.SecretKey;
|
||||
import java.security.PublicKey;
|
||||
import java.util.Base64;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
/**
|
||||
* 设备绑定与恢复服务
|
||||
* 设备绑定与恢复服务(JPA 持久化版)
|
||||
*
|
||||
* 职责:
|
||||
* 1. 设备注册(上传公钥 + SN)
|
||||
@@ -30,50 +32,74 @@ import java.util.logging.Logger;
|
||||
* - 恢复时生成新 Recovery Token,用新设备公钥加密
|
||||
* - 每次恢复后轮换相关密钥
|
||||
* - 旧设备自动停用
|
||||
*
|
||||
* 持久化说明:
|
||||
* - 设备/绑定关系已落库(device 表),服务重启不丢失
|
||||
* - 短信码 / Recovery Token nonce 属短时效,仍存内存(生产换 Redis)
|
||||
*/
|
||||
@Service
|
||||
public class DeviceBindingService {
|
||||
|
||||
private static final Logger log = Logger.getLogger(DeviceBindingService.class.getName());
|
||||
|
||||
// ===== 模拟数据库(生产环境替换为 JPA/Redis) =====
|
||||
private final Map<String, Device> deviceDB = new ConcurrentHashMap<>(); // deviceId -> Device
|
||||
private final Map<String, String> snToDeviceId = new ConcurrentHashMap<>(); // sn -> deviceId
|
||||
private final Map<String, String> smsCodeDB = new ConcurrentHashMap<>(); // phone -> smsCode
|
||||
private final Map<String, ConcurrentLinkedQueue<String>> userPhotos = new ConcurrentHashMap<>(); // userId -> photoIds
|
||||
|
||||
private final DeviceRepository deviceRepository;
|
||||
private final UserPhotoRepository userPhotoRepository;
|
||||
private final KeyManagementService keyManagementService;
|
||||
|
||||
public DeviceBindingService(KeyManagementService keyManagementService) {
|
||||
// ===== 短时效状态(仍内存,生产环境替换为 Redis) =====
|
||||
private final Map<String, String> smsCodeDB = new ConcurrentHashMap<>(); // phone -> smsCode
|
||||
|
||||
/** 已消费的 Recovery Token nonce(防重放),生产环境放 Redis 并设置过期 */
|
||||
private final java.util.Set<String> usedRecoveryNonces = ConcurrentHashMap.newKeySet();
|
||||
|
||||
/** Recovery Token 有效时间窗口(毫秒) */
|
||||
private static final long TOKEN_VALID_WINDOW_MS = 5 * 60 * 1000L;
|
||||
|
||||
public DeviceBindingService(DeviceRepository deviceRepository,
|
||||
UserPhotoRepository userPhotoRepository,
|
||||
KeyManagementService keyManagementService) {
|
||||
this.deviceRepository = deviceRepository;
|
||||
this.userPhotoRepository = userPhotoRepository;
|
||||
this.keyManagementService = keyManagementService;
|
||||
}
|
||||
|
||||
// ==================== 1. 设备注册 ====================
|
||||
|
||||
/**
|
||||
* 设备首次启动 / 恢复出厂后重新注册
|
||||
* 设备首次启动 / 恢复出厂后重新注册(幂等,已持久化)
|
||||
*
|
||||
* 安全逻辑:
|
||||
* - 如果 SN 已存在旧设备 → 停用旧设备(旧 TEE 私钥已随出厂重置销毁)
|
||||
* - 生成新 deviceId
|
||||
* - 存储新公钥
|
||||
* - 同 SN + 同公钥 → 幂等返回已存在设备(App 重启重复注册不产生新 deviceId)
|
||||
* - 公钥变化(恢复出厂)→ 停用旧的 active 设备,继承 SN 归属用户,生成新 deviceId
|
||||
*
|
||||
* 说明:一个 SN 可对应多条历史设备记录(恢复出厂/换机产生新 deviceId),
|
||||
* 因此 SN 不设数据库唯一约束;通过「同一 SN 仅一个 active 设备」的应用层
|
||||
* 逻辑保证唯一激活设备,避免 Duplicate entry。
|
||||
*/
|
||||
@Transactional
|
||||
public Device registerDevice(String sn, String publicKeyBase64) {
|
||||
// 恢复出厂场景:停用旧设备
|
||||
if (snToDeviceId.containsKey(sn)) {
|
||||
String oldDeviceId = snToDeviceId.get(sn);
|
||||
Device oldDevice = deviceDB.get(oldDeviceId);
|
||||
if (oldDevice != null) {
|
||||
oldDevice.setActive(false);
|
||||
log.warning("Old device deactivated (factory reset detected): " + oldDeviceId);
|
||||
}
|
||||
Device existing = findDeviceBySn(sn);
|
||||
|
||||
// 幂等:同一设备重启重复注册
|
||||
if (existing != null && publicKeyBase64.equals(existing.getPublicKeyBase64())) {
|
||||
log.info("Device already registered (idempotent): deviceId=" + existing.getDeviceId()
|
||||
+ " SN: " + sn);
|
||||
return existing;
|
||||
}
|
||||
|
||||
// 恢复出厂场景:停用旧的 active 设备,继承 SN 归属用户
|
||||
String inheritedUserId = null;
|
||||
if (existing != null) {
|
||||
inheritedUserId = existing.getUserId();
|
||||
existing.setActive(false);
|
||||
deviceRepository.save(existing);
|
||||
log.warning("Old device deactivated (factory reset detected): " + existing.getDeviceId());
|
||||
}
|
||||
|
||||
// 生成新设备ID
|
||||
String deviceId = UUID.randomUUID().toString().replace("-", "");
|
||||
Device device = new Device(deviceId, sn, publicKeyBase64);
|
||||
deviceDB.put(deviceId, device);
|
||||
snToDeviceId.put(sn, deviceId);
|
||||
device.setUserId(inheritedUserId);
|
||||
deviceRepository.save(device);
|
||||
|
||||
log.info("Device registered: " + deviceId + " SN: " + sn);
|
||||
return device;
|
||||
@@ -82,46 +108,47 @@ public class DeviceBindingService {
|
||||
// ==================== 2. 用户绑定设备 ====================
|
||||
|
||||
/**
|
||||
* 用户登录后绑定 SN
|
||||
* 建立 userId <-> deviceId 映射
|
||||
* 用户登录后绑定 SN(建立 userId <-> deviceId 映射,已持久化)
|
||||
*/
|
||||
@Transactional
|
||||
public Device bindDeviceToUser(String userId, String sn) {
|
||||
String deviceId = snToDeviceId.get(sn);
|
||||
if (deviceId == null) {
|
||||
Device device = findDeviceBySn(sn);
|
||||
if (device == null) {
|
||||
throw new IllegalArgumentException("Device not registered for SN: " + sn);
|
||||
}
|
||||
|
||||
Device device = deviceDB.get(deviceId);
|
||||
device.setUserId(userId);
|
||||
device.setActive(true);
|
||||
device.setBindTime(System.currentTimeMillis());
|
||||
deviceRepository.save(device);
|
||||
|
||||
log.info("Device " + deviceId + " bound to user " + userId);
|
||||
log.info("Device " + device.getDeviceId() + " bound to user " + userId);
|
||||
return device;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 SN 查找设备
|
||||
* 通过 SN 查找「当前激活」的设备(一个 SN 可能有多条历史记录,仅一条 active)
|
||||
*/
|
||||
public Device findDeviceBySn(String sn) {
|
||||
String deviceId = snToDeviceId.get(sn);
|
||||
return deviceId != null ? deviceDB.get(deviceId) : null;
|
||||
return deviceRepository.findBySnAndActiveTrue(sn).orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 通过 deviceId 查找设备
|
||||
*/
|
||||
public Device getDevice(String deviceId) {
|
||||
return deviceDB.get(deviceId);
|
||||
return deviceRepository.findById(deviceId).orElse(null);
|
||||
}
|
||||
|
||||
// ==================== 3. 短信验证码(模拟) ====================
|
||||
|
||||
/**
|
||||
* 发送短信验证码(模拟)
|
||||
* 生产环境对接阿里云短信 / 腾讯云短信 / AWS SNS
|
||||
* 生产环境对接阿里云短信 / 腾讯云短信 / AWS SNS,随机 6 位码。
|
||||
* Demo 模式固定 000000,便于端到端联调(验证码仍写入 smsCodeDB 并打印日志)。
|
||||
*/
|
||||
public void sendSmsCode(String phone) {
|
||||
String code = String.format("%06d", (int)(Math.random() * 1000000));
|
||||
String code = "000000";
|
||||
smsCodeDB.put(phone, code);
|
||||
log.info("[SMS SIMULATED] Code for " + phone + ": " + code);
|
||||
}
|
||||
@@ -148,6 +175,7 @@ public class DeviceBindingService {
|
||||
* 5. 用新设备公钥加密 Token
|
||||
* 6. 一次性消费短信码
|
||||
*/
|
||||
@Transactional
|
||||
public RecoveryResponse recoverDevice(String userId, String sn, String smsCode, String newPublicKeyBase64) {
|
||||
// 1. 获取用户
|
||||
User user = keyManagementService.getUser(userId);
|
||||
@@ -161,12 +189,10 @@ public class DeviceBindingService {
|
||||
}
|
||||
|
||||
// 3. 确认 SN 归属
|
||||
String deviceId = snToDeviceId.get(sn);
|
||||
if (deviceId == null) {
|
||||
Device device = findDeviceBySn(sn);
|
||||
if (device == null) {
|
||||
throw new IllegalArgumentException("Device not found for SN: " + sn);
|
||||
}
|
||||
|
||||
Device device = deviceDB.get(deviceId);
|
||||
if (!userId.equals(device.getUserId())) {
|
||||
throw new SecurityException("SN does not belong to this user");
|
||||
}
|
||||
@@ -175,11 +201,12 @@ public class DeviceBindingService {
|
||||
device.setPublicKeyBase64(newPublicKeyBase64);
|
||||
device.setLastRecoveryTime(System.currentTimeMillis());
|
||||
device.setActive(true);
|
||||
deviceRepository.save(device);
|
||||
|
||||
// 5. 生成 Recovery Token
|
||||
// 格式:userId|deviceId|timestamp|nonce
|
||||
String nonce = UUID.randomUUID().toString().replace("-", "").substring(0, 16);
|
||||
String tokenPayload = userId + "|" + deviceId + "|" + System.currentTimeMillis() + "|" + nonce;
|
||||
String tokenPayload = userId + "|" + device.getDeviceId() + "|" + System.currentTimeMillis() + "|" + nonce;
|
||||
byte[] tokenBytes = tokenPayload.getBytes();
|
||||
|
||||
// 6. 用新设备公钥加密 Token
|
||||
@@ -189,26 +216,81 @@ public class DeviceBindingService {
|
||||
// 7. 一次性消费短信码
|
||||
smsCodeDB.remove(user.getPhone());
|
||||
|
||||
log.info("Recovery authorized: device=" + deviceId + " user=" + userId + " nonce=" + nonce);
|
||||
log.info("Recovery authorized: device=" + device.getDeviceId() + " user=" + userId + " nonce=" + nonce);
|
||||
|
||||
return new RecoveryResponse(deviceId, encryptedToken, nonce);
|
||||
return new RecoveryResponse(device.getDeviceId(), encryptedToken, nonce);
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证设备回传的 Recovery Token(一次性 + 时间窗口)。
|
||||
*
|
||||
* Token 明文格式:userId|deviceId|timestamp|nonce
|
||||
* - deviceId 必须与请求的 deviceId 一致(防跨设备滥用)
|
||||
* - timestamp 必须在 5 分钟窗口内(防重放/过期)
|
||||
* - nonce 一次性消费(防同一 Token 多次使用)
|
||||
*/
|
||||
public boolean validateRecoveryToken(String deviceId, String token) {
|
||||
if (token == null || token.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
String[] parts = token.split("\\|");
|
||||
if (parts.length != 4) {
|
||||
return false;
|
||||
}
|
||||
String tokenDeviceId = parts[1];
|
||||
long timestamp;
|
||||
try {
|
||||
timestamp = Long.parseLong(parts[2]);
|
||||
} catch (NumberFormatException e) {
|
||||
return false;
|
||||
}
|
||||
String nonce = parts[3];
|
||||
|
||||
if (!deviceId.equals(tokenDeviceId)) {
|
||||
return false;
|
||||
}
|
||||
long now = System.currentTimeMillis();
|
||||
if (now - timestamp > TOKEN_VALID_WINDOW_MS || timestamp > now + 5000L) {
|
||||
return false;
|
||||
}
|
||||
return usedRecoveryNonces.add(nonce);
|
||||
}
|
||||
|
||||
// ==================== 5. 照片索引管理 ====================
|
||||
|
||||
/**
|
||||
* 记录用户上传的照片
|
||||
* 记录用户上传的照片(已持久化到 user_photo 表)
|
||||
*/
|
||||
@Transactional
|
||||
public void addPhotoToUser(String userId, String photoId) {
|
||||
userPhotos.computeIfAbsent(userId, k -> new ConcurrentLinkedQueue<>()).add(photoId);
|
||||
userPhotoRepository.save(new UserPhoto(userId, photoId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户所有照片ID
|
||||
* 获取用户所有照片ID(按上传顺序)
|
||||
*/
|
||||
public List<String> getUserPhotoIds(String userId) {
|
||||
ConcurrentLinkedQueue<String> photos = userPhotos.get(userId);
|
||||
return photos != null ? new ArrayList<>(photos) : new ArrayList<>();
|
||||
List<UserPhoto> photos = userPhotoRepository.findByUserIdOrderByCreatedTimeAsc(userId);
|
||||
List<String> result = new ArrayList<>(photos.size());
|
||||
for (UserPhoto p : photos) {
|
||||
result.add(p.getPhotoId());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 6. 用户设备列表(用户端) ====================
|
||||
|
||||
/**
|
||||
* 获取用户绑定的所有设备(前后端分离后用户端 "我的设备" 页面使用)
|
||||
*/
|
||||
public List<Device> getDevicesByUser(String userId) {
|
||||
List<Device> result = new ArrayList<>();
|
||||
for (Device device : deviceRepository.findAll()) {
|
||||
if (userId.equals(device.getUserId())) {
|
||||
result.add(device);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 响应模型 ====================
|
||||
|
||||
@@ -2,14 +2,14 @@ package com.secure.demo.service;
|
||||
|
||||
import com.secure.demo.crypto.AesGcmUtil;
|
||||
import com.secure.demo.model.User;
|
||||
import com.secure.demo.repository.UserRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import javax.crypto.SecretKey;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 密钥管理服务
|
||||
* 密钥管理服务(JPA 持久化版)
|
||||
*
|
||||
* 职责:
|
||||
* 1. 用户主密钥(UK)生命周期管理
|
||||
@@ -19,22 +19,29 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
* - UK 在内存中仅临时存在
|
||||
* - DEK 入库前必须用 UK 加密
|
||||
* - 生产环境应替换为 KMS(阿里云KMS / AWS KMS / HashiCorp Vault)
|
||||
*
|
||||
* 持久化说明:
|
||||
* - 用户与 UK 已落库(app_user 表),服务重启不丢失
|
||||
*/
|
||||
@Service
|
||||
public class KeyManagementService {
|
||||
|
||||
// 模拟用户数据库(生产环境替换为 JPA/MyBatis)
|
||||
private final Map<String, User> userDB = new ConcurrentHashMap<>();
|
||||
private final UserRepository userRepository;
|
||||
|
||||
public KeyManagementService(UserRepository userRepository) {
|
||||
this.userRepository = userRepository;
|
||||
}
|
||||
|
||||
// ===== 用户主密钥(UK)管理 =====
|
||||
|
||||
/**
|
||||
* 用户首次注册:生成并存储 UK
|
||||
* 用户首次注册:生成并存储 UK(已持久化)
|
||||
*
|
||||
* 生产环境:
|
||||
* - UK 应由 KMS 生成并托管
|
||||
* - 或用户口令通过 Argon2id 派生 KEK 加密 UK
|
||||
*/
|
||||
@Transactional
|
||||
public User registerUser(String userId, String phone) {
|
||||
User user = new User(userId, phone);
|
||||
|
||||
@@ -42,17 +49,29 @@ public class KeyManagementService {
|
||||
SecretKey uk = AesGcmUtil.generateKey();
|
||||
user.setUK(uk);
|
||||
|
||||
userDB.put(userId, user);
|
||||
return user;
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户 UK(内存操作)
|
||||
* 用户设置登录密码(AuthController 注册时调用,已持久化)
|
||||
*/
|
||||
@Transactional
|
||||
public User setPassword(String userId, String password) {
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
if (user == null) {
|
||||
throw new IllegalArgumentException("User not found: " + userId);
|
||||
}
|
||||
user.setPassword(password);
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户 UK
|
||||
*
|
||||
* 生产环境:调 KMS.Decrypt 或用户口令解锁
|
||||
*/
|
||||
public SecretKey getUserUK(String userId) {
|
||||
User user = userDB.get(userId);
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
if (user == null) {
|
||||
throw new IllegalArgumentException("User not found: " + userId);
|
||||
}
|
||||
@@ -102,10 +121,10 @@ public class KeyManagementService {
|
||||
// ===== 工具方法 =====
|
||||
|
||||
public boolean userExists(String userId) {
|
||||
return userDB.containsKey(userId);
|
||||
return userRepository.existsById(userId);
|
||||
}
|
||||
|
||||
public User getUser(String userId) {
|
||||
return userDB.get(userId);
|
||||
return userRepository.findById(userId).orElse(null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,19 @@ server.port=8080
|
||||
# 日志级别
|
||||
logging.level.com.secure.demo=DEBUG
|
||||
|
||||
# MySQL 数据源配置
|
||||
spring.datasource.url=jdbc:mysql://127.0.0.1:13306/secure_device?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||
spring.datasource.username=root
|
||||
spring.datasource.password=fanhuitong
|
||||
spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver
|
||||
|
||||
# JPA / Hibernate 配置
|
||||
spring.jpa.hibernate.ddl-auto=update
|
||||
spring.jpa.show-sql=false
|
||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
|
||||
# 文件上传根目录(相对项目工作目录,自动创建 uploads 子目录)
|
||||
app.upload.dir=./uploads
|
||||
|
||||
# Demo 配置(生产环境替换为真实值)
|
||||
# 短信服务配置(腾讯云/阿里云)
|
||||
# sms.provider=tencent
|
||||
|
||||
@@ -12,9 +12,11 @@ import org.springframework.http.*;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.interfaces.ECPublicKey;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.Signature;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -26,11 +28,11 @@ import static org.junit.jupiter.api.Assertions.*;
|
||||
* 端到端集成测试
|
||||
*
|
||||
* 模拟完整流程:
|
||||
* 1. 设备生成 TEE 密钥对 → 注册
|
||||
* 1. 设备生成 TEE 密钥对(RSA-2048)→ 注册
|
||||
* 2. 用户绑定设备
|
||||
* 3. 设备拍照 → 信封加密 → 上传
|
||||
* 3. 设备拍照 → 信封加密 → 元数据签名 → 上传(服务端验签)
|
||||
* 4. 用户下载并解密照片
|
||||
* 5. 恢复出厂 → 新密钥对 → 短信验证 → 恢复
|
||||
* 5. 恢复出厂 → 新密钥对 → 短信验证 → 恢复 → Token 验证 → 取回 DEK
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@TestPropertySource(locations = "classpath:application.properties")
|
||||
@@ -47,16 +49,37 @@ public class IntegrationTest {
|
||||
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
/** 生成 RSA-2048 密钥对(与 Android Keystore TEE 密钥一致,支持 OAEP 加密 + SHA256withRSA 签名) */
|
||||
private KeyPair generateDeviceKeyPair() throws Exception {
|
||||
KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC");
|
||||
kpg.initialize(256);
|
||||
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
|
||||
kpg.initialize(2048);
|
||||
return kpg.generateKeyPair();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从统一响应 ApiResponse{code,message,data} 中取出 data。
|
||||
* 前后端分离后所有接口都走该包装结构,断言 code==0 成功。
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> unwrapData(ResponseEntity<Map> resp) {
|
||||
assertEquals(HttpStatus.OK, resp.getStatusCode());
|
||||
Map<String, Object> body = resp.getBody();
|
||||
assertEquals(0, ((Number) body.get("code")).intValue(), "code 应为 0(成功)");
|
||||
return (Map<String, Object>) body.get("data");
|
||||
}
|
||||
|
||||
private String pubKeyToBase64(KeyPair kp) {
|
||||
return Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
|
||||
}
|
||||
|
||||
/** 设备用 TEE 私钥签名元数据(SHA256withRSA,对应 Android DeviceCrypto.signMetadata) */
|
||||
private String signMetadata(String metadata, PrivateKey privateKey) throws Exception {
|
||||
Signature sig = Signature.getInstance("SHA256withRSA");
|
||||
sig.initSign(privateKey);
|
||||
sig.update(metadata.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(sig.sign());
|
||||
}
|
||||
|
||||
// ==================== 完整流程测试 ====================
|
||||
|
||||
@Test
|
||||
@@ -76,8 +99,8 @@ public class IntegrationTest {
|
||||
|
||||
ResponseEntity<Map> resp = restTemplate.postForEntity(
|
||||
"/api/device/register", registerReq, Map.class);
|
||||
assertEquals(HttpStatus.OK, resp.getStatusCode());
|
||||
String deviceId = (String) resp.getBody().get("deviceId");
|
||||
Map<String, Object> data = unwrapData(resp);
|
||||
String deviceId = (String) data.get("deviceId");
|
||||
assertNotNull(deviceId);
|
||||
System.out.println("[Step 1] Device registered: " + deviceId);
|
||||
|
||||
@@ -100,9 +123,9 @@ public class IntegrationTest {
|
||||
// 3b. AES-GCM 加密照片
|
||||
AesGcmUtil.EncryptedResult encResult = AesGcmUtil.encrypt(photoBytes, dek);
|
||||
|
||||
// 3c. 签名元数据
|
||||
// 3c. 设备 TEE 私钥签名元数据(服务端将用设备公钥验签)
|
||||
String metadata = sn + "|" + System.currentTimeMillis() + "|photo-001";
|
||||
// (Demo 中跳过实际签名,生产环境用 deviceKeyPair.getPrivate() 签名)
|
||||
String metadataSignature = signMetadata(metadata, deviceKeyPair.getPrivate());
|
||||
|
||||
Map<String, String> uploadReq = new HashMap<>();
|
||||
uploadReq.put("sn", sn);
|
||||
@@ -110,23 +133,31 @@ public class IntegrationTest {
|
||||
uploadReq.put("ciphertextBase64", encResult.ciphertextBase64);
|
||||
uploadReq.put("ivBase64", encResult.ivBase64);
|
||||
uploadReq.put("dekBase64", Base64.getEncoder().encodeToString(dek.getEncoded()));
|
||||
uploadReq.put("metadataSignature", "demo-signature");
|
||||
uploadReq.put("metadataSignature", metadataSignature);
|
||||
uploadReq.put("metadata", metadata);
|
||||
|
||||
resp = restTemplate.postForEntity("/api/photo/upload", uploadReq, Map.class);
|
||||
assertEquals(HttpStatus.OK, resp.getStatusCode());
|
||||
System.out.println("[Step 3] Encrypted photo uploaded");
|
||||
assertEquals(HttpStatus.OK, resp.getStatusCode(), "服务端应验签通过");
|
||||
System.out.println("[Step 3] Encrypted photo uploaded (server verified signature)");
|
||||
|
||||
// 3d. 篡改元数据签名应被拒绝(验签生效验证)
|
||||
Map<String, String> tamperedReq = new HashMap<>(uploadReq);
|
||||
tamperedReq.put("metadata", sn + "|" + System.currentTimeMillis() + "|photo-tampered");
|
||||
resp = restTemplate.postForEntity("/api/photo/upload", tamperedReq, Map.class);
|
||||
assertEquals(HttpStatus.FORBIDDEN, resp.getStatusCode(), "签名不匹配的请求应被拒绝");
|
||||
System.out.println("[Step 3-tamper] Tampered request rejected as expected");
|
||||
|
||||
// ===== Step 4: 用户下载并解密照片 =====
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("X-User-Id", userId);
|
||||
HttpEntity<?> entity = new HttpEntity<>(headers);
|
||||
|
||||
ResponseEntity<String> photoResp = restTemplate.exchange(
|
||||
"/api/photo/photo-001/decrypt", HttpMethod.GET, entity, String.class);
|
||||
assertEquals(HttpStatus.OK, photoResp.getStatusCode());
|
||||
ResponseEntity<Map> photoResp = restTemplate.exchange(
|
||||
"/api/photo/photo-001/decrypt", HttpMethod.GET, entity, Map.class);
|
||||
Map<String, Object> photoData = unwrapData(photoResp);
|
||||
|
||||
byte[] decryptedPhoto = Base64.getDecoder().decode(photoResp.getBody());
|
||||
String plaintextBase64 = (String) photoData.get("plaintextBase64");
|
||||
byte[] decryptedPhoto = Base64.getDecoder().decode(plaintextBase64);
|
||||
assertArrayEquals(photoBytes, decryptedPhoto);
|
||||
System.out.println("[Step 4] Photo decrypted successfully: " + new String(decryptedPhoto));
|
||||
|
||||
@@ -144,65 +175,106 @@ public class IntegrationTest {
|
||||
|
||||
resp = restTemplate.postForEntity(
|
||||
"/api/device/register", newRegisterReq, Map.class);
|
||||
assertEquals(HttpStatus.OK, resp.getStatusCode());
|
||||
String newDeviceId = (String) resp.getBody().get("deviceId");
|
||||
data = unwrapData(resp);
|
||||
String newDeviceId = (String) data.get("deviceId");
|
||||
assertNotEquals(deviceId, newDeviceId);
|
||||
System.out.println("[Step 5a] New device registered after factory reset: " + newDeviceId);
|
||||
|
||||
// 5c. 发送短信验证码
|
||||
// 5c. 发送短信验证码(demo 固定 000000;用户端接口需登录态)
|
||||
Map<String, String> smsReq = new HashMap<>();
|
||||
smsReq.put("phone", phone);
|
||||
resp = restTemplate.postForEntity("/api/device/sms/send", smsReq, Map.class);
|
||||
HttpHeaders smsHeaders = new HttpHeaders();
|
||||
smsHeaders.set("X-User-Id", userId);
|
||||
resp = restTemplate.exchange("/api/device/sms/send", HttpMethod.POST,
|
||||
new HttpEntity<>(smsReq, smsHeaders), Map.class);
|
||||
assertEquals(HttpStatus.OK, resp.getStatusCode());
|
||||
System.out.println("[Step 5b] SMS code sent (check server logs)");
|
||||
System.out.println("[Step 5b] SMS code sent (demo code: 000000)");
|
||||
|
||||
// 5d. 恢复设备(用验证码 "000000" 模拟 - 实际应从日志获取)
|
||||
// 注意:Demo 中短信码是随机的,这里用反射获取或直接用已知码
|
||||
// 实际测试中应该从日志解析或暴露 test endpoint
|
||||
// 5d. 恢复设备(短信 + SN 归属双因子)
|
||||
Map<String, String> recoverReq = new HashMap<>();
|
||||
recoverReq.put("userId", userId);
|
||||
recoverReq.put("sn", sn);
|
||||
recoverReq.put("smsCode", "000000"); // Demo 中跳过真实验证
|
||||
recoverReq.put("smsCode", "000000");
|
||||
recoverReq.put("newPublicKeyBase64", newPublicKeyBase64);
|
||||
|
||||
// 注意:这里可能失败因为短信码不匹配
|
||||
// 生产环境短信码通过真实通道发送
|
||||
// Demo 中我们直接调 Service 层测试
|
||||
try {
|
||||
resp = restTemplate.postForEntity("/api/device/recover", recoverReq, Map.class);
|
||||
if (resp.getStatusCode() == HttpStatus.OK) {
|
||||
String encryptedToken = (String) resp.getBody().get("encryptedRecoveryToken");
|
||||
data = unwrapData(resp);
|
||||
String encryptedToken = (String) data.get("encryptedRecoveryToken");
|
||||
assertNotNull(encryptedToken);
|
||||
System.out.println("[Step 5c] Recovery authorized, token issued");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
System.out.println("[Step 5c] SMS verification skipped in test (expected)");
|
||||
}
|
||||
System.out.println("[Step 5c] Recovery authorized, encrypted token issued");
|
||||
|
||||
// 5e. 新设备 TEE 私钥解密 Recovery Token → 回传获取 DEK
|
||||
String recoveryToken = decryptRecoveryToken(encryptedToken, newDeviceKeyPair.getPrivate());
|
||||
assertTrue(recoveryToken.contains(deviceId) || recoveryToken.contains(newDeviceId),
|
||||
"Token 应包含新 deviceId");
|
||||
|
||||
Map<String, String> photoRecoverReq = new HashMap<>();
|
||||
photoRecoverReq.put("deviceId", newDeviceId);
|
||||
photoRecoverReq.put("recoveryToken", recoveryToken);
|
||||
photoRecoverReq.put("userId", userId);
|
||||
resp = restTemplate.postForEntity("/api/photo/recover", photoRecoverReq, Map.class);
|
||||
data = unwrapData(resp);
|
||||
assertEquals(1, ((Number) data.get("photoCount")).intValue());
|
||||
System.out.println("[Step 5d] DEK list fetched with validated token");
|
||||
|
||||
// 5f. 新设备私钥解 DEK → AES-GCM 解照片,验证恢复后数据可还原
|
||||
List<Map<String, String>> deks = (List<Map<String, String>>) data.get("deks");
|
||||
String encryptedDekBase64 = deks.get(0).get("encryptedDekBase64");
|
||||
byte[] dekBytes = RsaUtil.decryptWithPrivateKey(
|
||||
Base64.getDecoder().decode(encryptedDekBase64), newDeviceKeyPair.getPrivate());
|
||||
SecretKey recoveredDek = AesGcmUtil.secretKeyFromBytes(dekBytes);
|
||||
byte[] recoveredPhoto = AesGcmUtil.decrypt(
|
||||
encResult.ciphertextBase64, encResult.ivBase64, recoveredDek);
|
||||
assertArrayEquals(photoBytes, recoveredPhoto);
|
||||
System.out.println("[Step 5e] Photo recovered after factory reset: " + new String(recoveredPhoto));
|
||||
|
||||
System.out.println("\n===== ALL TESTS PASSED =====");
|
||||
}
|
||||
|
||||
/** 模拟新设备 TEE:用新设备 RSA 私钥解密服务端下发的 Recovery Token */
|
||||
private String decryptRecoveryToken(String encryptedTokenBase64, PrivateKey privateKey) throws Exception {
|
||||
byte[] tokenBytes = RsaUtil.decryptWithPrivateKey(
|
||||
Base64.getDecoder().decode(encryptedTokenBase64), privateKey);
|
||||
return new String(tokenBytes, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接测试 Service 层恢复流程(绕过短信)
|
||||
* 直接测试 Service 层完整恢复流程(含短信发送)
|
||||
*/
|
||||
@Test
|
||||
public void testRecoveryServiceDirectly() throws Exception {
|
||||
// 准备用户
|
||||
keyManagementService.registerUser("user-002", "13900139000");
|
||||
|
||||
// 准备设备
|
||||
// 准备设备(注册 + 绑定 + 发短信,模拟真实时序)
|
||||
KeyPair kp0 = generateDeviceKeyPair();
|
||||
deviceBindingService.registerDevice("SN-TEST-002", pubKeyToBase64(kp0));
|
||||
deviceBindingService.bindDeviceToUser("user-002", "SN-TEST-002");
|
||||
deviceBindingService.sendSmsCode("13900139000");
|
||||
|
||||
// 恢复(恢复出厂 → 新密钥对)
|
||||
KeyPair kp1 = generateDeviceKeyPair();
|
||||
DeviceBindingService.RecoveryResponse recoveryResp =
|
||||
deviceBindingService.recoverDevice(
|
||||
"user-002",
|
||||
"SN-TEST-002",
|
||||
"000000", // 验证码(Demo 直接调 Service 跳过短信发送)
|
||||
"000000", // demo 固定验证码
|
||||
pubKeyToBase64(kp1)
|
||||
);
|
||||
|
||||
assertNotNull(recoveryResp.encryptedRecoveryToken);
|
||||
assertNotNull(recoveryResp.nonce);
|
||||
System.out.println("[Direct Test] Recovery token issued: " +
|
||||
|
||||
// 新设备 TEE 私钥解密 Token,并验证 deviceId 匹配
|
||||
String token = decryptRecoveryToken(recoveryResp.encryptedRecoveryToken, kp1.getPrivate());
|
||||
assertTrue(token.contains(recoveryResp.deviceId), "Token 应包含 deviceId");
|
||||
|
||||
// Token 验证通过后,同一 Token 二次使用应被拒绝(防重放)
|
||||
assertTrue(deviceBindingService.validateRecoveryToken(recoveryResp.deviceId, token));
|
||||
assertFalse(deviceBindingService.validateRecoveryToken(recoveryResp.deviceId, token),
|
||||
"一次性 Token 不可重放");
|
||||
|
||||
System.out.println("[Direct Test] Recovery token issued and validated (anti-replay ok): " +
|
||||
recoveryResp.encryptedRecoveryToken.substring(0, 20) + "...");
|
||||
}
|
||||
}
|
||||
|
||||
253
web-client/app.js
Normal file
253
web-client/app.js
Normal file
@@ -0,0 +1,253 @@
|
||||
// ============================================================
|
||||
// Secure Device 用户端(Web)—— 前后端分离演示
|
||||
//
|
||||
// 后端地址:默认 localhost:8080(CORS 已放开)。
|
||||
// 认证:登录/注册后获得 Bearer Token,存 localStorage,
|
||||
// 后续所有用户端接口自动携带 Authorization: Bearer <token>。
|
||||
// ============================================================
|
||||
|
||||
const API_BASE = 'http://localhost:8080';
|
||||
|
||||
// ---------- 状态 ----------
|
||||
let token = localStorage.getItem('sd_token') || '';
|
||||
let userId = localStorage.getItem('sd_userId') || '';
|
||||
|
||||
// ---------- DOM 引用 ----------
|
||||
const $ = (id) => document.getElementById(id);
|
||||
|
||||
const sections = {
|
||||
auth: $('authSection'),
|
||||
device: $('deviceSection'),
|
||||
recover: $('recoverSection'),
|
||||
photo: $('photoSection'),
|
||||
};
|
||||
|
||||
// 登录后才需要显示的面板(登录区 auth 始终显示,不在其中)
|
||||
const authedSections = {
|
||||
device: $('deviceSection'),
|
||||
recover: $('recoverSection'),
|
||||
photo: $('photoSection'),
|
||||
};
|
||||
|
||||
// ---------- 通用请求 ----------
|
||||
async function api(path, { method = 'GET', body = null } = {}) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||
|
||||
const resp = await fetch(API_BASE + path, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : null,
|
||||
});
|
||||
|
||||
// 统一响应格式 { code, message, data },即使 HTTP 4xx/5xx 也是该结构
|
||||
const json = await resp.json().catch(() => null);
|
||||
showRaw(method, path, resp.status, json);
|
||||
if (!resp.ok || !json || json.code !== 0) {
|
||||
throw new Error((json && json.message) || `HTTP ${resp.status}`);
|
||||
}
|
||||
return json.data;
|
||||
}
|
||||
|
||||
// ---------- 原始响应展示 ----------
|
||||
function showRaw(method, path, status, json) {
|
||||
$('rawOutput').textContent =
|
||||
`HTTP ${status} ${method} ${API_BASE}${path}\n` +
|
||||
JSON.stringify(json, null, 2);
|
||||
}
|
||||
|
||||
// ---------- 结果展示 ----------
|
||||
function showResult(elId, text, isErr = false) {
|
||||
const el = $(elId);
|
||||
el.textContent = text;
|
||||
el.className = 'result ' + (isErr ? 'err' : 'ok');
|
||||
}
|
||||
|
||||
// ---------- 登录态 UI ----------
|
||||
function refreshAuthUI() {
|
||||
const loggedIn = !!token;
|
||||
$('userInfo').classList.toggle('hidden', !loggedIn);
|
||||
$('userLabel').textContent = loggedIn ? `当前用户:${userId}` : '';
|
||||
// 登录区始终显示;仅「设备/恢复/照片」三个面板在登录后才显示
|
||||
$('authSection').classList.toggle('hidden', false);
|
||||
Object.values(authedSections).forEach((s) => s.classList.toggle('hidden', !loggedIn));
|
||||
if (!loggedIn) $('authResult').textContent = '';
|
||||
}
|
||||
|
||||
function login(user, pass) {
|
||||
token = user;
|
||||
userId = user;
|
||||
localStorage.setItem('sd_token', token);
|
||||
localStorage.setItem('sd_userId', userId);
|
||||
refreshAuthUI();
|
||||
}
|
||||
|
||||
function logout() {
|
||||
token = '';
|
||||
userId = '';
|
||||
localStorage.removeItem('sd_token');
|
||||
localStorage.removeItem('sd_userId');
|
||||
refreshAuthUI();
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 事件绑定
|
||||
// ============================================================
|
||||
|
||||
// 登录
|
||||
$('btnLogin').addEventListener('click', async () => {
|
||||
const uid = $('authUserId').value.trim();
|
||||
const pass = $('authPassword').value || '123456';
|
||||
if (!uid) return showResult('authResult', '请输入用户名', true);
|
||||
try {
|
||||
const data = await api('/api/auth/login', {
|
||||
method: 'POST',
|
||||
body: { userId: uid, password: pass },
|
||||
});
|
||||
login(data.userId, data.token);
|
||||
showResult('authResult', `登录成功,Token: ${data.token.slice(0, 16)}...`);
|
||||
} catch (e) {
|
||||
showResult('authResult', `登录失败:${e.message}`, true);
|
||||
}
|
||||
});
|
||||
|
||||
// 注册
|
||||
$('btnRegister').addEventListener('click', async () => {
|
||||
const uid = $('authUserId').value.trim();
|
||||
const pass = $('authPassword').value || '123456';
|
||||
const phone = $('authPhone').value.trim() || '13800138000';
|
||||
if (!uid) return showResult('authResult', '请输入用户名', true);
|
||||
try {
|
||||
const data = await api('/api/auth/register', {
|
||||
method: 'POST',
|
||||
body: { userId: uid, phone, password: pass },
|
||||
});
|
||||
login(data.userId, data.token);
|
||||
showResult('authResult', `注册成功并已登录,Token: ${data.token.slice(0, 16)}...`);
|
||||
} catch (e) {
|
||||
showResult('authResult', `注册失败:${e.message}`, true);
|
||||
}
|
||||
});
|
||||
|
||||
// 退出登录
|
||||
$('btnLogout').addEventListener('click', () => {
|
||||
logout();
|
||||
showResult('authResult', '已退出登录');
|
||||
});
|
||||
|
||||
// 绑定设备
|
||||
$('btnBind').addEventListener('click', async () => {
|
||||
const sn = $('bindSn').value.trim();
|
||||
if (!sn) return showResult('bindResult', '请输入设备 SN', true);
|
||||
try {
|
||||
const data = await api('/api/device/bind', {
|
||||
method: 'POST',
|
||||
body: { sn },
|
||||
});
|
||||
showResult('bindResult', data.message || '绑定成功');
|
||||
} catch (e) {
|
||||
showResult('bindResult', `绑定失败:${e.message}`, true);
|
||||
}
|
||||
});
|
||||
|
||||
// 设备列表
|
||||
$('btnListDevices').addEventListener('click', async () => {
|
||||
try {
|
||||
const data = await api('/api/user/devices');
|
||||
const devices = data.devices || [];
|
||||
if (devices.length === 0) {
|
||||
showResult('devicesResult', '暂无绑定设备');
|
||||
return;
|
||||
}
|
||||
const lines = devices.map(
|
||||
(d) => `• ${d.sn} | deviceId: ${d.deviceId} | 状态: ${d.active ? '启用' : '停用'}`
|
||||
);
|
||||
showResult('devicesResult', lines.join('\n'));
|
||||
} catch (e) {
|
||||
showResult('devicesResult', `获取设备列表失败:${e.message}`, true);
|
||||
}
|
||||
});
|
||||
|
||||
// 发送短信验证码
|
||||
$('btnSendSms').addEventListener('click', async () => {
|
||||
const phone = $('recoverPhone').value.trim() || '13800138000';
|
||||
try {
|
||||
const data = await api('/api/device/sms/send', {
|
||||
method: 'POST',
|
||||
body: { phone },
|
||||
});
|
||||
showResult('recoverResult', data.message || '验证码已发送(demo 固定 000000)');
|
||||
} catch (e) {
|
||||
showResult('recoverResult', `发送失败:${e.message}`, true);
|
||||
}
|
||||
});
|
||||
|
||||
// 恢复授权
|
||||
$('btnRecover').addEventListener('click', async () => {
|
||||
const sn = $('recoverSn').value.trim();
|
||||
const newPubKey = $('recoverNewPubKey').value.trim();
|
||||
const smsCode = $('recoverSmsCode').value.trim();
|
||||
if (!sn) return showResult('recoverResult', '请输入设备 SN', true);
|
||||
if (!newPubKey) return showResult('recoverResult', '请输入新设备公钥(从设备端复制)', true);
|
||||
if (!smsCode) return showResult('recoverResult', '请输入短信验证码', true);
|
||||
try {
|
||||
const data = await api('/api/device/recover', {
|
||||
method: 'POST',
|
||||
body: { sn, smsCode, newPublicKeyBase64: newPubKey },
|
||||
});
|
||||
showResult(
|
||||
'recoverResult',
|
||||
`恢复授权成功!deviceId: ${data.deviceId}\n` +
|
||||
`encryptedRecoveryToken: ${data.encryptedRecoveryToken.slice(0, 32)}...\n` +
|
||||
`(设备端可凭此 Token 取回照片 DEK)`
|
||||
);
|
||||
} catch (e) {
|
||||
showResult('recoverResult', `恢复授权失败:${e.message}`, true);
|
||||
}
|
||||
});
|
||||
|
||||
// 照片列表
|
||||
$('btnListPhotos').addEventListener('click', async () => {
|
||||
try {
|
||||
const data = await api('/api/user/photos');
|
||||
const photos = data.photos || [];
|
||||
if (photos.length === 0) {
|
||||
showResult('photosResult', '暂无照片(请先在设备端上传)');
|
||||
return;
|
||||
}
|
||||
showResult('photosResult', photos.map((p) => `• ${p}`).join('\n'));
|
||||
} catch (e) {
|
||||
showResult('photosResult', `获取照片列表失败:${e.message}`, true);
|
||||
}
|
||||
});
|
||||
|
||||
// 下载解密照片
|
||||
$('btnDecrypt').addEventListener('click', async () => {
|
||||
const photoId = $('decryptPhotoId').value.trim();
|
||||
if (!photoId) return showResult('decryptResult', '请输入 photoId', true);
|
||||
try {
|
||||
const data = await api(`/api/photo/${photoId}/decrypt`);
|
||||
const plaintext = decodeBase64Text(data.plaintextBase64);
|
||||
showResult(
|
||||
'decryptResult',
|
||||
`解密成功!photoId: ${data.photoId}\n明文内容:\n${plaintext}`
|
||||
);
|
||||
} catch (e) {
|
||||
showResult('decryptResult', `解密失败:${e.message}`, true);
|
||||
}
|
||||
});
|
||||
|
||||
// ---------- 工具 ----------
|
||||
function decodeBase64Text(b64) {
|
||||
try {
|
||||
const bin = atob(b64);
|
||||
const bytes = new Uint8Array(bin.length);
|
||||
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
|
||||
return new TextDecoder('utf-8').decode(bytes);
|
||||
} catch {
|
||||
return '<无法解码为文本,可能为二进制照片数据>';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 初始化 ----------
|
||||
refreshAuthUI();
|
||||
85
web-client/index.html
Normal file
85
web-client/index.html
Normal file
@@ -0,0 +1,85 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Secure Device 用户端控制台</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<h1>Secure Device 用户端控制台</h1>
|
||||
<div id="userInfo" class="user-info hidden">
|
||||
<span id="userLabel"></span>
|
||||
<button id="btnLogout" class="btn btn-sm">退出登录</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<!-- ============ 登录 / 注册 ============ -->
|
||||
<section id="authSection" class="panel">
|
||||
<h2>用户登录 / 注册</h2>
|
||||
<p class="hint">模拟真实场景:用户端(Web)先登录获取 Token,再操作设备;设备端(Android)无登录体系。</p>
|
||||
<div class="form-row">
|
||||
<input id="authUserId" placeholder="用户名,如 alice" autocomplete="username">
|
||||
<input id="authPassword" type="password" placeholder="密码,注册时可自定义(默认 123456)" autocomplete="current-password">
|
||||
<input id="authPhone" placeholder="手机号(仅注册用),如 13800138000">
|
||||
<button id="btnLogin" class="btn btn-primary">登录</button>
|
||||
<button id="btnRegister" class="btn">注册</button>
|
||||
</div>
|
||||
<div id="authResult" class="result"></div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 设备管理 ============ -->
|
||||
<section id="deviceSection" class="panel hidden">
|
||||
<h2>绑定设备</h2>
|
||||
<div class="form-row">
|
||||
<input id="bindSn" placeholder="设备 SN,如 SN-DEMO-001">
|
||||
<button id="btnBind" class="btn btn-primary">绑定设备</button>
|
||||
</div>
|
||||
<div id="bindResult" class="result"></div>
|
||||
|
||||
<h2>我的设备</h2>
|
||||
<button id="btnListDevices" class="btn">刷新设备列表</button>
|
||||
<div id="devicesResult" class="result"></div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 短信 / 恢复授权 ============ -->
|
||||
<section id="recoverSection" class="panel hidden">
|
||||
<h2>恢复授权(出厂重置后重新取回照片)</h2>
|
||||
<p class="hint">流程:设备端恢复出厂后生成 <b>新设备公钥</b> → 在此填入 SN + 新公钥 → 发送短信验证码 → 恢复授权。</p>
|
||||
<div class="form-row">
|
||||
<input id="recoverSn" placeholder="设备 SN,如 SN-DEMO-001">
|
||||
<input id="recoverPhone" placeholder="绑定手机号,如 13800138000">
|
||||
<button id="btnSendSms" class="btn">发送验证码</button>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<input id="recoverNewPubKey" placeholder="新设备公钥 Base64(从设备端复制)">
|
||||
<input id="recoverSmsCode" placeholder="短信验证码(demo 固定 000000)">
|
||||
<button id="btnRecover" class="btn btn-primary">恢复授权</button>
|
||||
</div>
|
||||
<div id="recoverResult" class="result"></div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 照片 ============ -->
|
||||
<section id="photoSection" class="panel hidden">
|
||||
<h2>我的照片(密文存储,需登录后下载解密)</h2>
|
||||
<button id="btnListPhotos" class="btn">刷新照片列表</button>
|
||||
<div id="photosResult" class="result"></div>
|
||||
<div class="form-row">
|
||||
<input id="decryptPhotoId" placeholder="photoId,如 photo-001">
|
||||
<button id="btnDecrypt" class="btn btn-primary">下载并解密</button>
|
||||
</div>
|
||||
<div id="decryptResult" class="result"></div>
|
||||
</section>
|
||||
|
||||
<!-- ============ 原始响应展示 ============ -->
|
||||
<section id="rawSection" class="panel">
|
||||
<h2>最近一次 API 响应(统一格式 code/message/data)</h2>
|
||||
<pre id="rawOutput" class="raw">尚未发起请求</pre>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
111
web-client/style.css
Normal file
111
web-client/style.css
Normal file
@@ -0,0 +1,111 @@
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
background: #f0f4f8;
|
||||
color: #1f2937;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 32px;
|
||||
background: linear-gradient(135deg, #0f766e, #115e59);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.topbar h1 { font-size: 20px; font-weight: 600; }
|
||||
|
||||
.user-info { display: flex; align-items: center; gap: 12px; }
|
||||
|
||||
main {
|
||||
max-width: 900px;
|
||||
margin: 24px auto;
|
||||
padding: 0 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 20px 24px;
|
||||
box-shadow: 0 1px 3px rgba(15, 23, 42, .08);
|
||||
}
|
||||
|
||||
.panel h2 {
|
||||
font-size: 16px;
|
||||
margin-bottom: 12px;
|
||||
color: #0f766e;
|
||||
border-left: 4px solid #0f766e;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.hint { font-size: 13px; color: #6b7280; margin-bottom: 14px; line-height: 1.6; }
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1 1 180px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
outline: none;
|
||||
transition: border-color .15s;
|
||||
}
|
||||
|
||||
input:focus { border-color: #0f766e; }
|
||||
|
||||
.btn {
|
||||
padding: 10px 18px;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
color: #374151;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition: all .15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.btn:hover { border-color: #0f766e; color: #0f766e; }
|
||||
|
||||
.btn-primary { background: #0f766e; border-color: #0f766e; color: #fff; }
|
||||
.btn-primary:hover { background: #115e59; color: #fff; }
|
||||
|
||||
.btn-sm { padding: 6px 12px; font-size: 13px; }
|
||||
|
||||
.result {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.result.ok { color: #047857; }
|
||||
.result.err { color: #b91c1c; }
|
||||
|
||||
.raw {
|
||||
background: #0f172a;
|
||||
color: #a5f3fc;
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
overflow-x: auto;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.hidden { display: none; }
|
||||
Reference in New Issue
Block a user