feat: 重构为前后端分离架构并完善设备端演示

- 新增 Web 用户端(登录体系 + 统一 REST API 调用)
- 后端增加用户认证、统一 ApiResponse、CORS 支持
- Android 设备端迁移至 MVVM + DataBinding + Retrofit 网络层
- 完善 README 架构说明与密码学原理文档
- 新增 .gitignore 与持久化数据表说明
This commit is contained in:
TongTongStudio
2026-08-21 04:34:58 +08:00
parent eda03d241d
commit 93499fa189
63 changed files with 4398 additions and 455 deletions

View File

@@ -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();
}
}

View File

@@ -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 应是无状态 JWTRSA 签名)+ 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);
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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()));
}
}

View File

@@ -0,0 +1,12 @@
package com.secure.demo.common;
/**
* 未登录 / 凭证缺失异常HTTP 401
* 与 SecurityException403已登录但无权限区分开。
*/
public class UnauthorizedException extends RuntimeException {
public UnauthorizedException(String message) {
super(message);
}
}

View File

@@ -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);
}
}

View File

@@ -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 控制器
*
* ┌─────────────────────────────────────────────────────────────┐
* │ 接口清单
* ├─────────────────────────────────────────────────────────────┤
* │ 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 用户下载并解密照片
* └─────────────────────────────────────────────────────────────┘
* 设备安全 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/photo/upload 上传加密照片(设备签名) │
* │ POST /api/photo/recover 恢复后获取照片 DEK │
* ├────────────────────────────────────────────────────────────────┤
* │ 用户端(需要 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 HeaderAndroid
* 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" }
* 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. 验签:用设备公钥验证 metadataSignatureSHA256withRSA防伪/防篡改
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(
"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()));
}
RecoveryResponse resp = deviceBindingService.recoverDevice(
userId, sn, smsCode, newPublicKeyBase64
);
return ApiResponse.ok(Map.of(
"deviceId", resp.deviceId,
"encryptedRecoveryToken", resp.encryptedRecoveryToken,
"nonce", resp.nonce,
"message", "Recovery authorized. Device can now fetch DEKs."
));
}
// ==================== 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. 验证 recoveryTokendeviceId 匹配 + 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 HeaderAndroid 设备端 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 HeaderAndroid 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);
}
}

View File

@@ -83,6 +83,13 @@ public class AesGcmUtil {
}
}
/**
* 从原始字节构建 AES 密钥(用于恢复流程:解密 DEK 后还原 SecretKey
*/
public static SecretKey secretKeyFromBytes(byte[] keyBytes) {
return new javax.crypto.spec.SecretKeySpec(keyBytes, ALGO);
}
/**
* 加密结果
*/

View File

@@ -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;
}
}
}

View File

@@ -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; // 设备唯一IDUUID
@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; // 是否激活
// 构造器

View File

@@ -1,37 +1,57 @@
package com.secure.demo.model;
import jakarta.persistence.*;
/**
* 加密照片实体
*
* 安全要点:
* - ciphertextBase64AES-GCM 密文
* - filePath密文文件落盘路径AES-GCM 密文,非 Base64 内联)
* - ivBase64GCM IV每次随机
* - encryptedDekBase64DEK 被用户主密钥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; }

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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> {
}

View File

@@ -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);
}

View File

@@ -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> {
}

View File

@@ -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;
}
// ==================== 响应模型 ====================

View File

@@ -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);
}
}

View File

@@ -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

View File

@@ -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());
byte[] decryptedPhoto = Base64.getDecoder().decode(photoResp.getBody());
ResponseEntity<Map> photoResp = restTemplate.exchange(
"/api/photo/photo-001/decrypt", HttpMethod.GET, entity, Map.class);
Map<String, Object> photoData = unwrapData(photoResp);
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");
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)");
}
resp = restTemplate.postForEntity("/api/device/recover", recoverReq, Map.class);
data = unwrapData(resp);
String encryptedToken = (String) data.get("encryptedRecoveryToken");
assertNotNull(encryptedToken);
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.RecoveryResponse recoveryResp =
deviceBindingService.recoverDevice(
"user-002",
"SN-TEST-002",
"000000", // 验证码Demo 直接调 Service 跳过短信发送)
"user-002",
"SN-TEST-002",
"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) + "...");
}
}