Files
secure-device-demo/springboot-server/src/main/java/com/secure/demo/controller/DeviceController.java

964 lines
46 KiB
Java
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.crypto.TransportKeyService;
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 com.secure.demo.controller.model.DeviceLocalPhotoRequest;
import com.secure.demo.controller.model.StatusChallengeRequest;
import jakarta.servlet.http.HttpServletRequest;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
import javax.crypto.SecretKey;
import java.io.IOException;
import java.io.OutputStream;
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;
/**
* 设备安全 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")
public class DeviceController {
private static final Logger log = LoggerFactory.getLogger(DeviceController.class);
private final KeyManagementService keyManagementService;
private final DeviceBindingService deviceBindingService;
private final EncryptedPhotoRepository photoRepository;
private final TokenService tokenService;
private final TransportKeyService transportKeyService;
// 密文文件落盘根目录(来自配置 app.upload.dir默认 ./uploads
private final Path uploadRoot;
// 单张上传密文大小上限(字节),来自 app.upload.max-size-mb默认 20MB防 DoS
private final long maxUploadBytes;
public DeviceController(KeyManagementService keyManagementService,
DeviceBindingService deviceBindingService,
EncryptedPhotoRepository photoRepository,
TokenService tokenService,
TransportKeyService transportKeyService,
@Value("${app.upload.dir:./uploads}") String uploadDir,
@Value("${app.upload.max-size-mb:20}") int maxSizeMb) {
this.keyManagementService = keyManagementService;
this.deviceBindingService = deviceBindingService;
this.photoRepository = photoRepository;
this.tokenService = tokenService;
this.transportKeyService = transportKeyService;
this.uploadRoot = Paths.get(uploadDir);
this.maxUploadBytes = (maxSizeMb <= 0) ? (20L * 1024 * 1024) : ((long) maxSizeMb * 1024 * 1024);
// 启动时确保上传目录存在
try {
Files.createDirectories(this.uploadRoot);
} catch (IOException e) {
throw new IllegalStateException("无法创建上传目录: " + this.uploadRoot, e);
}
}
// ==================== 0. 健康检查(公开) ====================
@GetMapping("/health")
public ApiResponse<Map<String, String>> health() {
return ApiResponse.ok(Map.of("status", "ok", "service", "secure-device-demo"));
}
// ==================== 1. 设备注册设备端无登录PoP 验签) ====================
/**
* 设备首次启动 / 恢复出厂后调用
*
* 安全要求(修复 P0-1注册必须携带 PoP 证明,防止攻击者用自己公钥冒名注册受害者 SN。
* 流程:
* 1. 设备先 GET /api/device/challenge 获取一次性挑战值
* 2. 设备用 TEE 私钥对 challenge 签名
* 3. 本接口用「请求内上传的公钥」验签Proof of Possession证明私钥持有者确实拥有该公钥
* 4. 验签通过后才允许注册/停旧换新
*
* Request: {
* "sn": "SN-DEMO-001",
* "publicKeyBase64": "...",
* "challenge": "1730000000000:nonce123",
* "signature": "..."
* }
* Response: data = { "deviceId": "...", "sn": "SN-DEMO-001", "message": "..." }
*/
@PostMapping("/device/register")
public ApiResponse<Map<String, String>> registerDevice(@RequestBody Map<String, String> req) {
String sn = req.get("sn");
String publicKeyBase64 = req.get("publicKeyBase64");
String challenge = req.get("challenge");
String signature = req.get("signature");
if (sn == null || publicKeyBase64 == null || challenge == null || signature == null) {
throw new IllegalArgumentException(
"sn, publicKeyBase64, challenge, signature all required (PoP)");
}
// PoP 验签:证明请求方拥有 publicKeyBase64 对应的 TEE 私钥(一次性 + 时效)
deviceBindingService.verifyRegisterChallenge(publicKeyBase64, challenge, signature);
Device device = deviceBindingService.registerDevice(sn, publicKeyBase64);
return ApiResponse.ok(Map.of(
"deviceId", device.getDeviceId(),
"sn", device.getSn(),
"message", "Device registered successfully"
));
}
/**
* 获取设备状态查询用的挑战值Challenge-Response 第一步)。
*
* 设备端先调用本接口拿到挑战值,使用 TEE 私钥对 challenge 签名后,
* 再调用 POST /api/device/status 完成认证并返回状态。
*/
@GetMapping("/device/challenge")
public ApiResponse<Map<String, Object>> deviceStatusChallenge() {
String challenge = deviceBindingService.generateStatusChallenge();
Map<String, Object> body = new java.util.LinkedHashMap<>();
body.put("challenge", challenge);
return ApiResponse.ok(body);
}
/**
* 查询设备注册/绑定状态设备端无登录App 启动时调用以复用已有状态)。
* 本接口要求设备签名认证Challenge-Response基于 TEE 私钥)。
*
* 认证流程:
* 1. 设备先 GET /api/device/challenge 获取挑战值
* 2. 设备用 TEE 私钥对 challenge 签名
* 3. 本接口验签通过后才返回状态(认证失败前不泄露任何绑定信息)
*
* Request: POST /api/device/status
* body = { "sn": "...", "challenge": "...", "signature": "..." }
* Response: data = {
* "registered": true, // 该 SN 是否已注册
* "bound": true, // 是否已绑定用户
* "active": true, // 是否激活
* "deviceId": "...", // 已注册时返回
* "userId": "...", // 已绑定时返回
* "publicKeyBase64": "..." // 已注册时返回(供 App 判断公钥是否轮换)
* }
*/
@PostMapping("/device/status")
public ApiResponse<Map<String, Object>> deviceStatus(@RequestBody StatusChallengeRequest req) {
if (req.getSn() == null || req.getSn().isBlank()) {
throw new IllegalArgumentException("sn required");
}
// 设备签名认证(时效 + 防重放 + RSA 验签);失败直接抛 SecurityException
deviceBindingService.verifyStatusChallenge(req.getSn(), req.getChallenge(), req.getSignature());
Device device = deviceBindingService.findDeviceBySn(req.getSn());
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. 绑定设备(设备签名认证) ====================
/**
* 绑定设备(零信任:设备签名认证 + 归属校验)。
*
* 认证方式(二选一):
* - Android 设备端:请求体携带 { sn, challenge, signature, userId },服务端用该 SN 对应
* 设备公钥验签Challenge-Response证明请求方持有该设备 TEE 私钥;绑定目标 userId 取自 body。
* - Web 用户端Authorization: Bearer <token>,绑定目标为 Token 对应用户。
*
* 不再信任 X-User-Id / body.userId 自报身份(零信任)。
* Request: { "sn": "...", "challenge": "...", "signature": "...", "userId": "user-001", "phone": "..." }
* Response: data = { "message": "..." }
*/
@PostMapping("/device/bind")
public ApiResponse<Map<String, String>> bindDevice(@RequestBody Map<String, String> req,
HttpServletRequest httpRequest) {
String userId;
Device device;
// 1) Web 用户端Bearer Token
String auth = httpRequest.getHeader("Authorization");
if (auth != null && auth.startsWith("Bearer ")) {
userId = tokenService.getUserId(auth.substring(7).trim());
if (userId == null) {
throw new UnauthorizedException("Invalid or expired token");
}
String sn = req.get("sn");
if (sn == null) {
throw new IllegalArgumentException("sn required");
}
device = deviceBindingService.findDeviceBySn(sn);
if (device == null) {
throw new ApiException(404, "Device not registered for SN: " + sn);
}
} else {
// 2) Android 设备端设备签名认证SN + challenge + signature
device = authenticateDeviceBySignature(req);
userId = req.get("userId");
if (userId == null || userId.isBlank()) {
throw new IllegalArgumentException("userId required for device-signature bind");
}
}
// 已登录用户不存在时自动注册(生产环境用户一定已存在,此分支仅为兼容旧 demo
if (!keyManagementService.userExists(userId)) {
String phone = req.getOrDefault("phone", "13800138000");
keyManagementService.registerUser(userId, phone);
}
deviceBindingService.bindDeviceToUser(userId, device.getSn());
return ApiResponse.ok(Map.of("message", "Device bound to user successfully"));
}
// ==================== 3. 短信验证码(设备签名认证) ====================
/**
* 发送短信验证码(设备签名认证,防短信轰炸)。
*
* Android 设备端携带 { sn, challenge, signature, phone },服务端验签确认设备身份后,
* 校验该设备已绑定用户,再发送短信。不再依赖 X-User-Id 自报身份。
* Request: { "sn": "...", "challenge": "...", "signature": "...", "phone": "13800138000" }
* Response: data = { "message": "..." }
*/
@PostMapping("/device/sms/send")
public ApiResponse<Map<String, String>> sendSms(@RequestBody Map<String, String> req,
HttpServletRequest httpRequest) {
// 设备签名认证(防轰炸 + 设备身份校验)
Device device = authenticateDeviceBySignature(req);
if (device.getUserId() == null || device.getUserId().isBlank()) {
throw new SecurityException("Device not bound to any user");
}
String phone = req.get("phone");
if (phone == null) {
throw new IllegalArgumentException("phone required");
}
deviceBindingService.sendSmsCode(phone);
return ApiResponse.ok(Map.of("message", "SMS code sent (check server logs for demo code)"));
}
// ==================== 6c. 设备照片列表(设备签名认证) ====================
/**
* 设备端拉取「本设备绑定用户」的照片 ID 列表(零信任,设备签名认证)。
*
* Request: { "sn": "...", "challenge": "...", "signature": "..." }
* Response: data = { "photos": ["photo-001", ...] }
*
* 服务端用 SN 对应设备公钥验签,再返回该设备归属用户的照片,杜绝 X-User-Id 伪造越权。
*/
@PostMapping("/device/photos")
public ApiResponse<Map<String, Object>> devicePhotos(@RequestBody Map<String, String> req) {
Device device = authenticateDeviceBySignature(req);
String userId = device.getUserId();
if (userId == null || userId.isBlank()) {
throw new SecurityException("Device not bound to any user");
}
List<String> photoIds = deviceBindingService.getUserPhotoIds(userId);
return ApiResponse.ok(Map.of("photos", photoIds));
}
/**
* 设备照片元数据列表(零信任:设备签名认证,供 Android 展示)。
*
* Request: { "sn": "...", "challenge": "...", "signature": "..." }
* Response: data = { "photos": [ { "photoId","uploadTime","deviceId","sn","activeDevice" }, ... ] }
*
* 服务端用 SN 对应设备公钥验签,再返回该设备归属用户的照片元数据,杜绝 X-User-Id 伪造越权。
*/
@PostMapping("/device/photos/metadata")
public ApiResponse<Map<String, Object>> devicePhotosMetadata(@RequestBody Map<String, String> req) {
Device device = authenticateDeviceBySignature(req);
String userId = device.getUserId();
if (userId == null || userId.isBlank()) {
throw new SecurityException("Device not bound to any user");
}
List<Map<String, Object>> list = new ArrayList<>();
for (String photoId : deviceBindingService.getUserPhotoIds(userId)) {
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
if (photo == null) continue;
Map<String, Object> item = new java.util.LinkedHashMap<>();
item.put("photoId", photoId);
item.put("uploadTime", photo.getUploadTime());
item.put("deviceId", photo.getDeviceId());
Device dev = photo.getDeviceId() == null ? null : deviceBindingService.getDevice(photo.getDeviceId());
item.put("sn", dev != null ? dev.getSn() : "");
item.put("activeDevice", dev != null && dev.isActive());
list.add(item);
}
return ApiResponse.ok(Map.of("photos", list));
}
// ==================== 3b. 获取服务端传输公钥(设备上传 DEK 加密用) ====================
/**
* 下发服务端传输公钥Base64。设备端用它加密本次上传的 DEK
* 使 DEK 在网络上永不明文(纵深防御:即使传输层被截获也无法还原 DEK
*
* Response: data = { "publicKeyBase64": "..." }
*/
@GetMapping("/device/transport-key")
public ApiResponse<Map<String, String>> transportPublicKey() {
return ApiResponse.ok(Map.of(
"publicKeyBase64", transportKeyService.getPublicKeyBase64()
));
}
// ==================== 4. 上传加密照片(设备端,无登录) ====================
/**
* 设备上传加密照片(信封加密,设备私钥签名防伪)
*
* Request:
* {
* "sn": "SN-DEMO-001",
* "photoId": "photo-001",
* "ciphertextBase64": "...",
* "ivBase64": "...",
* "encryptedDekBase64": "...", <-- 传输公钥加密的 DEK不再上传明文 DEK
* "metadataSignature": "...", <-- 设备私钥签名
* "metadata": "SN-DEMO-001|ts|photo-001"
* }
*
* 安全限制:密文 Base64 换算后不得超过 app.upload.max-size-mb默认 20MB
* 并对 ivBase64 / encryptedDekBase64 / metadataSignature / metadata 限长,防 DoS。
*/
@PostMapping("/photo/upload")
public ApiResponse<Map<String, String>> uploadPhoto(@RequestBody Map<String, String> req) {
String sn = req.get("sn");
// photoId 净化(仅允许字母/数字/下划线/连字符),非法值回退 UUID杜绝路径穿越
String photoId = sanitizePhotoId(req.getOrDefault("photoId", UUID.randomUUID().toString().replace("-", "")));
String ciphertextBase64 = req.get("ciphertextBase64");
String ivBase64 = req.get("ivBase64");
// DEK 不再明文传输:设备用「服务端传输公钥」加密 DEK 后上传 encryptedDekBase64
String encryptedDekBase64 = req.get("encryptedDekBase64");
String metadataSignature = req.get("metadataSignature");
String metadata = req.get("metadata");
// 0. 输入大小限制(防 DoS
// - 密文 Base64 长度换算回字节后不得超过 maxUploadBytes
// - 其他元数据字段也限长,防止超大 body 消耗内存/存储
if (ciphertextBase64 == null || ciphertextBase64.isBlank()) {
throw new IllegalArgumentException("ciphertextBase64 required");
}
long approxBytes = (long) ciphertextBase64.length() / 4 * 3;
if (approxBytes > maxUploadBytes) {
throw new IllegalArgumentException("Ciphertext too large (max "
+ (maxUploadBytes / 1024 / 1024) + " MB)");
}
if (ivBase64 != null && ivBase64.length() > 256) {
throw new IllegalArgumentException("ivBase64 too long");
}
if (encryptedDekBase64 == null || encryptedDekBase64.isBlank()
|| encryptedDekBase64.length() > 1024) {
throw new IllegalArgumentException("encryptedDekBase64 missing or too long");
}
if (metadataSignature != null && metadataSignature.length() > 1024) {
throw new IllegalArgumentException("metadataSignature too long");
}
if (metadata != null && metadata.length() > 512) {
throw new IllegalArgumentException("metadata too long");
}
// 1. 查找设备
Device device = deviceBindingService.findDeviceBySn(sn);
if (device == null) {
throw new ApiException(404, "Device not registered for SN: " + sn);
}
if (!device.isActive()) {
throw new SecurityException("Device not active. Please complete recovery.");
}
String userId = device.getUserId();
if (userId == null) {
throw new SecurityException("Device not bound to any user");
}
// 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")。
// 设备上传的是「传输公钥加密的 DEK」先解出明文 DEK再交给 UK 包裹存储,
// 整个链路 DEK 永不明文出现在网络上。
if (encryptedDekBase64 == null || encryptedDekBase64.isBlank()) {
throw new IllegalArgumentException("encryptedDekBase64 required (DEK must be transport-encrypted)");
}
byte[] plainDek = transportKeyService.decryptWithPrivateKey(encryptedDekBase64);
String dekBase64 = Base64.getEncoder().encodeToString(plainDek);
String encryptedDek = keyManagementService.wrapDEK(dekBase64, userId);
// 4. 密文落盘
String filePath;
try {
filePath = writeCiphertextToFile(photoId, ciphertextBase64);
} catch (IOException e) {
log.error("Failed to save ciphertext file for photo {}", photoId, e);
throw new ApiException(500, "Failed to save ciphertext file");
}
// 5. 元数据存数据库
EncryptedPhoto photo = new EncryptedPhoto(
photoId, device.getDeviceId(), userId,
filePath, ivBase64,
encryptedDek, metadataSignature
);
photoRepository.save(photo);
return ApiResponse.ok(Map.of(
"photoId", photoId,
"filePath", filePath,
"message", "Photo uploaded, encrypted and saved successfully"
));
}
// ==================== 5. 恢复授权(设备签名认证) ====================
/**
* 恢复出厂后重新绑定 + 授权(零信任:设备签名认证)。
*
* 认证方式:
* - Android 设备端:请求体携带 { sn, challenge, signature, smsCode, newPublicKeyBase64 }
* 服务端用该 SN 对应设备公钥验签Challenge-Response证明请求方持有设备 TEE 私钥;
* 用户身份由设备绑定关系解析,不信任 X-User-Id 自报。
* - Web 用户端Authorization: Bearer <token>。
*
* Request: { "sn": "...", "challenge": "...", "signature": "...", "smsCode": "...", "newPublicKeyBase64": "..." }
* Response: data = { "deviceId": "...", "encryptedRecoveryToken": "...", "nonce": "...", "message": "..." }
*/
@PostMapping("/device/recover")
public ApiResponse<Map<String, String>> recoverDevice(@RequestBody Map<String, String> req,
HttpServletRequest httpRequest) {
String userId;
String sn;
String auth = httpRequest.getHeader("Authorization");
if (auth != null && auth.startsWith("Bearer ")) {
// Web 用户端
userId = tokenService.getUserId(auth.substring(7).trim());
if (userId == null) {
throw new UnauthorizedException("Invalid or expired token");
}
sn = req.get("sn");
} else {
// Android 设备端:设备签名认证
Device device = authenticateDeviceBySignature(req);
sn = device.getSn();
userId = device.getUserId();
}
if (sn == null) {
throw new IllegalArgumentException("sn required");
}
String smsCode = req.get("smsCode");
String newPublicKeyBase64 = req.get("newPublicKeyBase64");
if (smsCode == null || newPublicKeyBase64 == null) {
throw new IllegalArgumentException("smsCode, newPublicKeyBase64 all required");
}
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设备端无登录 ====================
/**
* 设备用 Recovery Token 获取该用户所有照片的 DEK
* 服务端用设备当前公钥逐一加密 DEK 后下发
*
* Request: { "deviceId": "...", "recoveryToken": "...", "userId": "user-001" }
* Response: data = { "deviceId": "...", "photoCount": 3, "deks": [{"photoId","encryptedDekBase64"}] }
*/
@PostMapping("/photo/recover")
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) {
throw new IllegalArgumentException("deviceId, recoveryToken, userId all required");
}
// 1. 验证设备
Device device = deviceBindingService.getDevice(deviceId);
if (device == null || !device.isActive()) {
throw new SecurityException("Device not active");
}
if (!userId.equals(device.getUserId())) {
throw new SecurityException("Token does not match device owner");
}
// 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());
// 4. 遍历照片UK 解 DEK → 设备公钥加密 DEK → 下发(同时附密文+IV供设备端本地还原
List<Map<String, String>> dekList = new ArrayList<>();
for (String photoId : deviceBindingService.getUserPhotoIds(userId)) {
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
if (photo == null) continue;
// 用 UK 解出明文 DEK
SecretKey dek = keyManagementService.unwrapDEK(photo.getEncryptedDekBase64(), userId);
// 用设备公钥加密 DEK设备私钥才能解
String encryptedDek = RsaUtil.encryptBase64(dek.getEncoded(), devicePubKey);
// 密文落盘于 filePath读文件 -> Base64供设备端在本地用 DEK 还原内容
String ciphertextBase64;
try {
ciphertextBase64 = Base64.getEncoder()
.encodeToString(Files.readAllBytes(Paths.get(photo.getFilePath())));
} catch (IOException e) {
log.error("Failed to read ciphertext file", e);
throw new ApiException(500, "Failed to read ciphertext file");
}
dekList.add(Map.of(
"photoId", photoId,
"encryptedDekBase64", encryptedDek,
"ciphertextBase64", ciphertextBase64,
"ivBase64", photo.getIvBase64()
));
}
return ApiResponse.ok(Map.of(
"deviceId", deviceId,
"photoCount", dekList.size(),
"deks", dekList
));
}
// ==================== 6b. 设备本地下载解密单张照片设备端Challenge-Response 签名认证) ====================
/**
* 设备端本地解密:服务端仅下发「密文 + 设备公钥加密的 DEK + IV」由设备在 TEE 内用私钥
* 本地解密,服务端全程不接触明文照片(端到端加密,零知识服务端)。
*
* 认证方式(区别于用户端下载解密的 Bearer Token
* 1. 设备先 GET /api/device/challenge 获取一次性挑战值
* 2. 设备用 TEE 私钥对 challenge 签名,随 {sn, challenge, signature} 上报
* 3. 服务端用该 SN 对应设备公钥验签Challenge-Response证明请求方确为该设备
* —— 即「SN ↔ TEE 私钥」对应关系校验,防止用他人公钥/冒名 SN 越权下载
* 4. 校验照片归属与设备激活状态后,用设备公钥加密 DEK 下发
*
* Request: { "sn": "...", "challenge": "...", "signature": "..." } + path photoId
* Response: data = { "photoId", "encryptedDekBase64", "ciphertextBase64", "ivBase64" }
*/
@PostMapping("/device/photo/{photoId}/local")
public ApiResponse<Map<String, String>> deviceDownloadLocal(
@PathVariable String photoId,
@RequestBody DeviceLocalPhotoRequest req) {
if (photoId == null || photoId.isBlank()) {
throw new IllegalArgumentException("photoId required");
}
if (req.getSn() == null || req.getSn().isBlank()
|| req.getChallenge() == null || req.getChallenge().isBlank()
|| req.getSignature() == null || req.getSignature().isBlank()) {
throw new IllegalArgumentException("sn, challenge, signature required (device signature auth)");
}
// 1. Challenge-Response 设备签名认证:用该 SN 对应设备公钥验签,证明持有对应 TEE 私钥
deviceBindingService.verifyStatusChallenge(req.getSn(), req.getChallenge(), req.getSignature());
// 2. 照片存在性
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
if (photo == null) {
throw new ApiException(404, "Photo not found: " + photoId);
}
// 3. 归属校验:照片所属用户 == 设备当前绑定用户(设备必须属于照片所有者)
Device device = deviceBindingService.findDeviceBySn(req.getSn());
if (device == null) {
throw new SecurityException("Device not registered for SN: " + req.getSn());
}
if (!device.isActive()) {
throw new SecurityException("Device not active");
}
if (photo.getUserId() == null || !photo.getUserId().equals(device.getUserId())) {
throw new SecurityException("Photo does not belong to this device's user");
}
// 4. 用 UK 解出 DEK → 用设备公钥加密 DEK → 连同密文 + IV 下发(设备本地解密)
PublicKey devicePubKey = RsaUtil.publicKeyFromBase64(device.getPublicKeyBase64());
SecretKey dek = keyManagementService.unwrapDEK(photo.getEncryptedDekBase64(), photo.getUserId());
String encryptedDek = RsaUtil.encryptBase64(dek.getEncoded(), devicePubKey);
String ciphertextBase64;
try {
ciphertextBase64 = Base64.getEncoder()
.encodeToString(Files.readAllBytes(Paths.get(photo.getFilePath())));
} catch (IOException e) {
log.error("Failed to read ciphertext file for photo {}", photoId, e);
throw new ApiException(500, "Failed to read ciphertext file");
}
return ApiResponse.ok(Map.of(
"photoId", photoId,
"encryptedDekBase64", encryptedDek,
"ciphertextBase64", ciphertextBase64,
"ivBase64", photo.getIvBase64()
));
}
// ==================== 7. 下载解密照片(用户端,登录) ====================
/**
* 用户端(已登录)下载并解密照片
*
* 流程:鉴权(Bearer Token / X-User-Id) → 归属检查 → UK 解 DEK → DEK 解照片
* Response: data = { "photoId": "...", "plaintextBase64": "..." }
*/
@GetMapping("/photo/{photoId}/decrypt")
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) {
throw new ApiException(404, "Photo not found: " + photoId);
}
if (!userId.equals(photo.getUserId())) {
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(
ciphertextBase64,
photo.getIvBase64(),
dek
);
// 3. 返回明文JSON Base64生产环境可用 StreamingResponseBody 流式传输)
return ApiResponse.ok(Map.of(
"photoId", photoId,
"plaintextBase64", Base64.getEncoder().encodeToString(plaintext)
));
} catch (Exception e) {
log.error("Decryption failed for photo {}", photoId, e);
throw new ApiException(500, "Decryption failed");
}
}
// ==================== 7b. 下载解密照片(流式,用户端,登录) ====================
/**
* 用户端(已登录)下载并解密照片 —— 流式传输版。
*
* 与 {@link #downloadAndDecrypt(String, HttpServletRequest)} 功能一致,但不再把明文塞进
* JSONBase64 膨胀 ~33% 且需整段驻留内存),而是用 {@link StreamingResponseBody}
* 直接以原始二进制流写出,边解密边向网络写出,内存占用 O(分块) 而非 O(整张照片)。
* 大图 / 视频等场景收益明显。
*
* 鉴权(Bearer Token / X-User-Id) → 归属检查 → UK 解 DEK → DEK 流式解密 → 写出二进制。
* Content-Type: application/octet-stream建议前端按原文件扩展名消费。
*/
@GetMapping("/photo/{photoId}/decrypt/stream")
public ResponseEntity<StreamingResponseBody> downloadAndDecryptStreaming(
@PathVariable String photoId,
HttpServletRequest httpRequest) {
String userId = resolveUserId(httpRequest, null);
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
if (photo == null) {
throw new ApiException(404, "Photo not found: " + photoId);
}
if (!userId.equals(photo.getUserId())) {
throw new SecurityException("Not your photo");
}
// 1. UK 解密 DEKDEK 明文仅在解密循环内短暂可见)
SecretKey dek = keyManagementService.unwrapDEK(photo.getEncryptedDekBase64(), userId);
// 2. 密文落盘于 filePath与 downloadAndDecrypt 一致):读文件 -> Base64 字符串,
// 因 AesGcmUtil.decrypt 入参为 Base64 字符串。
final String ciphertextBase64;
try {
ciphertextBase64 = Base64.getEncoder()
.encodeToString(Files.readAllBytes(Paths.get(photo.getFilePath())));
} catch (IOException e) {
log.error("Failed to read ciphertext file for photo {}", photoId, e);
throw new ApiException(500, "Failed to read ciphertext file");
}
final String ivBase64 = photo.getIvBase64();
StreamingResponseBody stream = outputStream -> {
try (OutputStream out = outputStream) {
// GCM 为 AEAD需整段解密后由 AesGcmUtil.decrypt 返回完整明文,再写出;
// 此处 StreamingResponseBody 的价值在于「传输层」流式直出(不经 JSON/Base64 包装),
// 降低网络层内存峰值。若需「解密层」逐块流式,需改用 CTR/CFB 等分块模式。
byte[] plaintext = AesGcmUtil.decrypt(ciphertextBase64, ivBase64, dek);
out.write(plaintext);
out.flush();
} catch (Exception e) {
// 写入过程中异常:底层连接会断开,记录日志便于排查
log.error("Streaming decryption failed for photo {}", photoId, e);
throw new ApiException(500, "Streaming decryption failed");
}
};
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + photoId + ".bin\"")
.body(stream);
}
// ==================== 8. 我的照片列表(用户端,登录) ====================
/**
* 当前登录用户的照片列表(仅 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));
}
/**
* 当前登录用户的照片元数据列表(供 Android / Web 展示用)。
*
* 每个条目含photoId、上传时间、来源设备、所属用户、是否可被设备本地解密active 设备)等,
* 不含密文 / DEK / 明文,仅供列表展示。
*
* Response: data = { "photos": [ { "photoId","uploadTime","deviceId","sn","activeDevice" }, ... ] }
*/
@GetMapping("/user/photos/metadata")
public ApiResponse<Map<String, Object>> getUserPhotoMetadata(HttpServletRequest httpRequest) {
String userId = resolveUserId(httpRequest, null);
List<Map<String, Object>> list = new ArrayList<>();
for (String photoId : deviceBindingService.getUserPhotoIds(userId)) {
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
if (photo == null) continue;
Map<String, Object> item = new java.util.LinkedHashMap<>();
item.put("photoId", photoId);
item.put("uploadTime", photo.getUploadTime());
item.put("deviceId", photo.getDeviceId());
// 附加来源设备 SN 及是否仍为 active 设备(决定设备端能否本地解密)
Device dev = photo.getDeviceId() == null ? null : deviceBindingService.getDevice(photo.getDeviceId());
item.put("sn", dev != null ? dev.getSn() : "");
item.put("activeDevice", dev != null && dev.isActive());
list.add(item);
}
return ApiResponse.ok(Map.of("photos", list));
}
// ==================== 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) {
// 仅允许 Bearer TokenWeb 用户端)。
// 零信任原则:不再信任 X-User-Id / body.userId 自报身份(可伪造)。
// Android 设备端一律走 authenticateDeviceBySignature() 设备签名认证。
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");
}
throw new UnauthorizedException("Login required: missing Authorization Bearer token");
}
/**
* 设备签名认证Challenge-Response用请求体中的 { sn, challenge, signature }
* 服务端以该 SN 对应设备公钥验签,证明请求方持有该设备的 TEE 私钥SN ↔ TEE 绑定)。
*
* @return 认证通过后对应的设备
*/
private Device authenticateDeviceBySignature(Map<String, String> req) {
String sn = req.get("sn");
String challenge = req.get("challenge");
String signature = req.get("signature");
if (sn == null || challenge == null || signature == null) {
throw new IllegalArgumentException("sn, challenge, signature required (device signature auth)");
}
// verifyStatusChallenge 内部用 findDeviceBySn(sn) 取设备公钥验签,并校验 active
deviceBindingService.verifyStatusChallenge(sn, challenge, signature);
Device device = deviceBindingService.findDeviceBySn(sn);
if (device == null) {
throw new SecurityException("Device not registered for SN: " + sn);
}
return device;
}
// ==================== 文件落盘辅助方法 ====================
/**
* 将 Base64 密文解码后写入上传目录,文件名 = {photoId}.enc
*
* 安全:对 photoId 先做净化(仅允许字母/数字/下划线/连字符),并校验规范化后的
* 文件路径仍位于上传根目录内,杜绝「../」、绝对路径、空字节等路径穿越。
*
* @return 文件绝对路径
*/
private String writeCiphertextToFile(String photoId, String ciphertextBase64) throws IOException {
String safeId = sanitizePhotoId(photoId);
Path target = resolveWithinUploadRoot(safeId + ".enc");
byte[] raw = Base64.getDecoder().decode(ciphertextBase64);
Files.write(target, raw);
return target.toAbsolutePath().toString();
}
/**
* 净化照片 ID只允许 [A-Za-z0-9_-],长度 1~64。
* 非法(含 ../、绝对路径、空格、特殊字符等)时回退为随机 UUID
* 既防御路径穿越,又不破坏正常上传流程。
*/
private String sanitizePhotoId(String photoId) {
if (photoId == null || photoId.isBlank()) {
return UUID.randomUUID().toString().replace("-", "");
}
String trimmed = photoId.trim();
if (trimmed.matches("[A-Za-z0-9_-]{1,64}")) {
return trimmed;
}
return UUID.randomUUID().toString().replace("-", "");
}
/**
* 在 uploadRoot 内解析目标路径(纵深防御):解析后必须仍位于 uploadRoot 之下,
* 否则视为越界拒绝。防止 photoId或其拼接结果通过符号链接 / .. / 绝对路径逃出目录。
*/
private Path resolveWithinUploadRoot(String relativeName) {
Path rootAbs = uploadRoot.toAbsolutePath().normalize();
Path target = rootAbs.resolve(relativeName).normalize();
if (!target.startsWith(rootAbs)) {
throw new SecurityException("Invalid upload path (path traversal blocked): " + relativeName);
}
return target;
}
/**
* 从落盘文件读取密文并转为 Base64 字符串(供 AesGcmUtil.decrypt 使用)。
*
* 安全:读取前校验文件必须位于上传根目录内(纵深防御,防止存储路径被篡改后读取目录外文件)。
*/
private String readCiphertextFromFile(String filePath) throws IOException {
Path p = Paths.get(filePath).toAbsolutePath().normalize();
Path rootAbs = uploadRoot.toAbsolutePath().normalize();
if (!p.startsWith(rootAbs)) {
throw new SecurityException("Refusing to read file outside upload root: " + filePath);
}
byte[] raw = Files.readAllBytes(p);
return Base64.getEncoder().encodeToString(raw);
}
}