diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9780465 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/springboot-server/target/ +/springboot-server/target/ diff --git a/README.md b/README.md index 92f26bc..b910822 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ -# 🔐 安全设备 Demo — Android + Spring Boot +# 🔐 安全设备 Demo — 前后端分离(Web 用户端 + Android 设备端 + Spring Boot) > **场景**:高权限设备端(无登录)+ 用户端(有登录体系)+ SN 绑定 + 私密照片加密 -> **目标**:设备端 TEE 密钥不可导出、服务端零知识、恢复出厂后可安全恢复 +> **目标**:设备端 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 `,可在页面底部看到统一响应 `{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 `(真实场景)> `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` | 我的设备列表 | diff --git a/android-app/app/build.gradle b/android-app/app/build.gradle index 393c278..746eaac 100644 --- a/android-app/app/build.gradle +++ b/android-app/app/build.gradle @@ -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' } diff --git a/android-app/app/src/main/AndroidManifest.xml b/android-app/app/src/main/AndroidManifest.xml index fb057c7..7727944 100644 --- a/android-app/app/src/main/AndroidManifest.xml +++ b/android-app/app/src/main/AndroidManifest.xml @@ -1,13 +1,28 @@ + + + + + + + + + + diff --git a/android-app/app/src/main/java/com/secure/demo/MainActivity.java b/android-app/app/src/main/java/com/secure/demo/MainActivity.java deleted file mode 100644 index 953c1b3..0000000 --- a/android-app/app/src/main/java/com/secure/demo/MainActivity.java +++ /dev/null @@ -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)); - } -} diff --git a/android-app/app/src/main/java/com/secure/demo/activity/main/MainActivity.java b/android-app/app/src/main/java/com/secure/demo/activity/main/MainActivity.java new file mode 100644 index 0000000..733e20e --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/activity/main/MainActivity.java @@ -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 { + + private static final int REQ_STORAGE_PERMISSION = 1001; + + private SafPicker safPicker; + + @Override + protected int getLayoutId() { + return R.layout.activity_main; + } + + @Override + protected Class 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(); + } + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/activity/main/MainViewModel.java b/android-app/app/src/main/java/com/secure/demo/activity/main/MainViewModel.java new file mode 100644 index 0000000..81afd38 --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/activity/main/MainViewModel.java @@ -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)。 + *

+ * 模拟真实用户操作:不再一键自动跑完整链路,而是把整条安全链路拆成 + * 独立的、由用户逐步手动触发的操作(对应真实使用流程): + *

+ * ① 设备注册 → ② 绑定用户 → ③ 加密上传照片 → ④ 下载解密照片 + * → ⑤ 恢复出厂(销毁 TEE 密钥) → ⑥ 重新注册 → ⑦ 发送短信验证码 + * → ⑧ 恢复授权(短信+SN归属双因子) → ⑨ 恢复照片(取回 DEK 并还原)。 + *

+ * 每一步都维护设备状态(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 void runStep(String step, Single single, Consumer 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 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 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 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 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)); + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/base/mvvm/BaseMvvmActivity.java b/android-app/app/src/main/java/com/secure/demo/base/mvvm/BaseMvvmActivity.java new file mode 100644 index 0000000..93cddd6 --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/base/mvvm/BaseMvvmActivity.java @@ -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 + extends AppCompatActivity { + + protected VB binding; + protected VM viewModel; + + /** 布局资源 id(对应 DataBinding 生成的 Binding) */ + @LayoutRes + protected abstract int getLayoutId(); + + /** ViewModel 的具体 Class(用于 ViewModelProvider 实例化) */ + protected abstract Class 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 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) types[1]; + } + } + throw new IllegalStateException("无法推断 ViewModel 类型,请重写 getViewModelClass()"); + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/base/mvvm/BaseViewModel.java b/android-app/app/src/main/java/com/secure/demo/base/mvvm/BaseViewModel.java new file mode 100644 index 0000000..27570c9 --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/base/mvvm/BaseViewModel.java @@ -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(); + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/base/mvvm/UiState.java b/android-app/app/src/main/java/com/secure/demo/base/mvvm/UiState.java new file mode 100644 index 0000000..86594c1 --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/base/mvvm/UiState.java @@ -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 logLines = new MutableLiveData<>(""); + + /** 是否处于忙(网络/加密进行中),用于禁用按钮、显示进度条。 + * 初始为 false:按钮默认可用;仅当某步操作进行中由 runStep/initDevice 置 true */ + private final MutableLiveData busy = new MutableLiveData<>(false); + + /** 当前操作进度步骤(对应 UI 上的 ①-⑨),用于控制按钮可用性 */ + private final MutableLiveData currentStep = new MutableLiveData<>(0); + + /** 设备是否已注册(后端有记录) */ + private final MutableLiveData registered = new MutableLiveData<>(false); + + /** 设备是否已绑定用户 */ + private final MutableLiveData bound = new MutableLiveData<>(false); + + public LiveData getCurrentStep() { + return currentStep; + } + + public void setCurrentStep(int step) { + currentStep.postValue(step); + } + + public LiveData getRegistered() { + return registered; + } + + public LiveData getBound() { + return bound; + } + + public void setRegistered(boolean value) { + registered.postValue(value); + } + + public void setBound(boolean value) { + bound.postValue(value); + } + + private final MutableLiveData toast = new MutableLiveData<>(); + + /** 最近选中的图片 URI(供预览) */ + private final MutableLiveData selectedImageUri = new MutableLiveData<>(); + + /** 下载解密后的图片明文(Base64),供预览显示 */ + private final MutableLiveData decryptedImageBase64 = new MutableLiveData<>(); + + /** 当前设备/用户状态摘要(SN、公钥、注册/绑定/恢复进度) */ + private final MutableLiveData status = new MutableLiveData<>(""); + + public LiveData getStatus() { + return status; + } + + public LiveData getLogLines() { + return logLines; + } + + public LiveData getBusy() { + return busy; + } + + public LiveData getToast() { + return toast; + } + + public LiveData getSelectedImageUri() { + return selectedImageUri; + } + + public LiveData 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); + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/ApiClient.java b/android-app/app/src/main/java/com/secure/demo/network/ApiClient.java new file mode 100644 index 0000000..ea19b4b --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/ApiClient.java @@ -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 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 listener) { + httpLogListener = listener; + } + + private static void notifyHttpLog(String msg) { + Consumer listener = httpLogListener; + if (listener != null) { + try { + listener.accept(msg); + } catch (Exception ignored) { + // 界面回调异常不影响网络请求本身 + } + } + } + + private static String normalizeBaseUrl(String base) { + return base.endsWith("/") ? base : base + "/"; + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/AuthInterceptor.java b/android-app/app/src/main/java/com/secure/demo/network/AuthInterceptor.java new file mode 100644 index 0000000..72af977 --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/AuthInterceptor.java @@ -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); + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/DeviceApi.java b/android-app/app/src/main/java/com/secure/demo/network/DeviceApi.java new file mode 100644 index 0000000..b71dbe4 --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/DeviceApi.java @@ -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> deviceStatus(@Query("sn") String sn); + + /** 1. 设备注册(SN + 公钥,设备端无登录) */ + @POST("api/device/register") + Single> registerDevice(@Body RegisterRequest body); + + /** 2. 用户绑定设备(用户端,Bearer Token / X-User-Id 鉴权) */ + @POST("api/device/bind") + Single> bindDevice(@Body BindRequest body); + + /** 3. 发送短信验证码(用户端,登录态防轰炸) */ + @POST("api/device/sms/send") + Single> sendSms(@Body SmsRequest body); + + /** 4. 上传加密照片(信封加密,DEK 明文经 HTTPS 传输,设备端无登录) */ + @POST("api/photo/upload") + Single> uploadPhoto(@Body UploadPhotoRequest body); + + /** 5. 恢复设备(短信验证 + 新公钥,换机/恢复出厂后调用,用户端) */ + @POST("api/device/recover") + Single> recoverDevice(@Body RecoverRequest body); + + /** 6. 恢复后获取照片 DEK 列表(服务端用设备公钥逐一加密 DEK 下发,设备端无登录) */ + @POST("api/photo/recover") + Single> recoverPhotos(@Body PhotoRecoverRequest body); + + /** 7. 用户下载并解密照片(Bearer Token / X-User-Id 鉴权,返回明文照片 Base64 的 JSON) */ + @GET("api/photo/{photoId}/decrypt") + Single> downloadAndDecrypt(@Path("photoId") String photoId, + @Header("X-User-Id") String userId); + + /** 7b. 用户照片列表(用户端,X-User-Id 鉴权,返回 photoId 列表) */ + @GET("api/user/photos") + Single> userPhotos(@Header("X-User-Id") String userId); + + /** 8. 健康检查(公开) */ + @GET("api/health") + Single> health(); +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/ApiResponse.java b/android-app/app/src/main/java/com/secure/demo/network/model/ApiResponse.java new file mode 100644 index 0000000..6f44d3a --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/model/ApiResponse.java @@ -0,0 +1,59 @@ +package com.secure.demo.network.model; + +/** + * 后端统一响应包装(与 springboot-server 的 ApiResponse 对应)。 + * + * 前后端分离后,所有接口返回: + *

+ * { "code": 0, "message": "ok", "data": { ... } }
+ * 
+ * code == 0 表示成功,业务数据在 data 中。 + */ +public class ApiResponse { + + 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; + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/BindRequest.java b/android-app/app/src/main/java/com/secure/demo/network/model/BindRequest.java new file mode 100644 index 0000000..144cb2e --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/model/BindRequest.java @@ -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; + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/DeviceStatusResponse.java b/android-app/app/src/main/java/com/secure/demo/network/model/DeviceStatusResponse.java new file mode 100644 index 0000000..9f6bc49 --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/model/DeviceStatusResponse.java @@ -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; + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/DownloadDecryptResponse.java b/android-app/app/src/main/java/com/secure/demo/network/model/DownloadDecryptResponse.java new file mode 100644 index 0000000..5956399 --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/model/DownloadDecryptResponse.java @@ -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; + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/EncryptedDek.java b/android-app/app/src/main/java/com/secure/demo/network/model/EncryptedDek.java new file mode 100644 index 0000000..7f12b04 --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/model/EncryptedDek.java @@ -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; + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/HealthResponse.java b/android-app/app/src/main/java/com/secure/demo/network/model/HealthResponse.java new file mode 100644 index 0000000..13ae69f --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/model/HealthResponse.java @@ -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; + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/MessageResponse.java b/android-app/app/src/main/java/com/secure/demo/network/model/MessageResponse.java new file mode 100644 index 0000000..e05a9c1 --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/model/MessageResponse.java @@ -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; + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/PhotoRecoverRequest.java b/android-app/app/src/main/java/com/secure/demo/network/model/PhotoRecoverRequest.java new file mode 100644 index 0000000..d3f3ecc --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/model/PhotoRecoverRequest.java @@ -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; + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/PhotoRecoverResponse.java b/android-app/app/src/main/java/com/secure/demo/network/model/PhotoRecoverResponse.java new file mode 100644 index 0000000..55d3fd4 --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/model/PhotoRecoverResponse.java @@ -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 deks; + + public String getDeviceId() { + return deviceId; + } + + public int getPhotoCount() { + return photoCount; + } + + public List getDeks() { + return deks; + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/RecoverRequest.java b/android-app/app/src/main/java/com/secure/demo/network/model/RecoverRequest.java new file mode 100644 index 0000000..1d37377 --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/model/RecoverRequest.java @@ -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; + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/RecoverResponse.java b/android-app/app/src/main/java/com/secure/demo/network/model/RecoverResponse.java new file mode 100644 index 0000000..7b3a7a2 --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/model/RecoverResponse.java @@ -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; + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/RegisterRequest.java b/android-app/app/src/main/java/com/secure/demo/network/model/RegisterRequest.java new file mode 100644 index 0000000..dc4a559 --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/model/RegisterRequest.java @@ -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; + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/RegisterResponse.java b/android-app/app/src/main/java/com/secure/demo/network/model/RegisterResponse.java new file mode 100644 index 0000000..d81973b --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/model/RegisterResponse.java @@ -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; + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/SmsRequest.java b/android-app/app/src/main/java/com/secure/demo/network/model/SmsRequest.java new file mode 100644 index 0000000..8c56982 --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/model/SmsRequest.java @@ -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; + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/UploadPhotoRequest.java b/android-app/app/src/main/java/com/secure/demo/network/model/UploadPhotoRequest.java new file mode 100644 index 0000000..574416e --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/model/UploadPhotoRequest.java @@ -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; + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/UploadPhotoResponse.java b/android-app/app/src/main/java/com/secure/demo/network/model/UploadPhotoResponse.java new file mode 100644 index 0000000..d968493 --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/model/UploadPhotoResponse.java @@ -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; + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/UserPhotosResponse.java b/android-app/app/src/main/java/com/secure/demo/network/model/UserPhotosResponse.java new file mode 100644 index 0000000..ed34b03 --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/model/UserPhotosResponse.java @@ -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 photos; + + public List getPhotos() { + return photos; + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/util/PermissionHelper.java b/android-app/app/src/main/java/com/secure/demo/util/PermissionHelper.java new file mode 100644 index 0000000..cb90abe --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/util/PermissionHelper.java @@ -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 getMissingImagePermissions(@NonNull Context context) { + List 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(); + } +} diff --git a/android-app/app/src/main/java/com/secure/demo/util/SafPicker.java b/android-app/app/src/main/java/com/secure/demo/util/SafPicker.java new file mode 100644 index 0000000..fb03b0d --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/util/SafPicker.java @@ -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 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); + } +} diff --git a/android-app/app/src/main/java/com/secure/device/DeviceCrypto.java b/android-app/app/src/main/java/com/secure/device/DeviceCrypto.java index 5a7e507..93e05c2 100644 --- a/android-app/app/src/main/java/com/secure/device/DeviceCrypto.java +++ b/android-app/app/src/main/java/com/secure/device/DeviceCrypto.java @@ -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); + } + } + // ==================== 恢复流程 ==================== /** diff --git a/android-app/app/src/main/res/layout/activity_main.xml b/android-app/app/src/main/res/layout/activity_main.xml index b914cbb..3e53fce 100644 --- a/android-app/app/src/main/res/layout/activity_main.xml +++ b/android-app/app/src/main/res/layout/activity_main.xml @@ -1,27 +1,254 @@ - + - - + + + + + android:layout_height="match_parent" + android:fillViewport="true" + android:background="#F5F6FA"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+

用户登录 / 注册

+

模拟真实场景:用户端(Web)先登录获取 Token,再操作设备;设备端(Android)无登录体系。

+
+ + + + + +
+
+
+ + + + + + + + + + + +
+

最近一次 API 响应(统一格式 code/message/data)

+
尚未发起请求
+
+
+ + + + diff --git a/web-client/style.css b/web-client/style.css new file mode 100644 index 0000000..38eca9d --- /dev/null +++ b/web-client/style.css @@ -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; }