fix(device): 设备注册绑定接口添加 PoP 验签,照片下载改为设备端本地解密
This commit is contained in:
@@ -6,19 +6,28 @@ 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;
|
||||
@@ -63,24 +72,34 @@ import java.util.UUID;
|
||||
@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,
|
||||
@Value("${app.upload.dir:./uploads}") String uploadDir) {
|
||||
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);
|
||||
@@ -96,22 +115,40 @@ public class DeviceController {
|
||||
return ApiResponse.ok(Map.of("status", "ok", "service", "secure-device-demo"));
|
||||
}
|
||||
|
||||
// ==================== 1. 设备注册(设备端,无登录) ====================
|
||||
// ==================== 1. 设备注册(设备端,无登录,PoP 验签) ====================
|
||||
|
||||
/**
|
||||
* 设备首次启动 / 恢复出厂后调用
|
||||
*
|
||||
* Request: { "sn": "SN-DEMO-001", "publicKeyBase64": "..." }
|
||||
* 安全要求(修复 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");
|
||||
if (sn == null || publicKeyBase64 == null) {
|
||||
throw new IllegalArgumentException("sn and publicKeyBase64 required");
|
||||
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(),
|
||||
@@ -177,21 +214,47 @@ public class DeviceController {
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 2. 用户绑定设备(用户端,登录) ====================
|
||||
// ==================== 2. 绑定设备(设备签名认证) ====================
|
||||
|
||||
/**
|
||||
* 用户登录后绑定 SN
|
||||
* 绑定设备(零信任:设备签名认证 + 归属校验)。
|
||||
*
|
||||
* Request: { "sn": "SN-DEMO-001" } (userId 由 Token / Header 解析)
|
||||
* 认证方式(二选一):
|
||||
* - 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 = resolveUserId(httpRequest, req);
|
||||
String sn = req.get("sn");
|
||||
if (sn == null) {
|
||||
throw new IllegalArgumentException("sn required");
|
||||
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)
|
||||
@@ -200,23 +263,28 @@ public class DeviceController {
|
||||
keyManagementService.registerUser(userId, phone);
|
||||
}
|
||||
|
||||
deviceBindingService.bindDeviceToUser(userId, sn);
|
||||
deviceBindingService.bindDeviceToUser(userId, device.getSn());
|
||||
return ApiResponse.ok(Map.of("message", "Device bound to user successfully"));
|
||||
}
|
||||
|
||||
// ==================== 3. 短信验证码(用户端,登录) ====================
|
||||
// ==================== 3. 短信验证码(设备签名认证) ====================
|
||||
|
||||
/**
|
||||
* 发送短信验证码(登录态防短信轰炸)
|
||||
* 发送短信验证码(设备签名认证,防短信轰炸)。
|
||||
*
|
||||
* Request: { "phone": "13800138000" }
|
||||
* 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) {
|
||||
// 必须登录才能发短信(防轰炸)
|
||||
resolveUserId(httpRequest, req);
|
||||
// 设备签名认证(防轰炸 + 设备身份校验)
|
||||
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");
|
||||
@@ -225,6 +293,73 @@ public class DeviceController {
|
||||
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. 上传加密照片(设备端,无登录) ====================
|
||||
|
||||
/**
|
||||
@@ -236,21 +371,51 @@ public class DeviceController {
|
||||
* "photoId": "photo-001",
|
||||
* "ciphertextBase64": "...",
|
||||
* "ivBase64": "...",
|
||||
* "dekBase64": "...", <-- 明文 DEK(HTTPS 传输)
|
||||
* "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");
|
||||
String photoId = req.getOrDefault("photoId", UUID.randomUUID().toString().replace("-", ""));
|
||||
// photoId 净化(仅允许字母/数字/下划线/连字符),非法值回退 UUID,杜绝路径穿越
|
||||
String photoId = sanitizePhotoId(req.getOrDefault("photoId", UUID.randomUUID().toString().replace("-", "")));
|
||||
String ciphertextBase64 = req.get("ciphertextBase64");
|
||||
String ivBase64 = req.get("ivBase64");
|
||||
String dekBase64 = req.get("dekBase64");
|
||||
// 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) {
|
||||
@@ -277,7 +442,14 @@ public class DeviceController {
|
||||
throw new SecurityException("Metadata signature verification failed");
|
||||
}
|
||||
|
||||
// 3. 用 UK 加密 DEK(服务端永远只存 "UK 加密后的 DEK")
|
||||
// 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. 密文落盘
|
||||
@@ -285,7 +457,8 @@ public class DeviceController {
|
||||
try {
|
||||
filePath = writeCiphertextToFile(photoId, ciphertextBase64);
|
||||
} catch (IOException e) {
|
||||
throw new ApiException(500, "Failed to save ciphertext file: " + e.getMessage());
|
||||
log.error("Failed to save ciphertext file for photo {}", photoId, e);
|
||||
throw new ApiException(500, "Failed to save ciphertext file");
|
||||
}
|
||||
|
||||
// 5. 元数据存数据库
|
||||
@@ -295,7 +468,6 @@ public class DeviceController {
|
||||
encryptedDek, metadataSignature
|
||||
);
|
||||
photoRepository.save(photo);
|
||||
deviceBindingService.addPhotoToUser(userId, photoId);
|
||||
|
||||
return ApiResponse.ok(Map.of(
|
||||
"photoId", photoId,
|
||||
@@ -304,23 +476,46 @@ public class DeviceController {
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 5. 恢复授权(用户端,登录) ====================
|
||||
// ==================== 5. 恢复授权(设备签名认证) ====================
|
||||
|
||||
/**
|
||||
* 恢复出厂后重新绑定 + 授权
|
||||
* 恢复出厂后重新绑定 + 授权(零信任:设备签名认证)。
|
||||
*
|
||||
* Request: { "sn": "...", "smsCode": "000000", "newPublicKeyBase64": "..." }
|
||||
* 认证方式:
|
||||
* - 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 = resolveUserId(httpRequest, req);
|
||||
String sn = req.get("sn");
|
||||
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 (sn == null || smsCode == null || newPublicKeyBase64 == null) {
|
||||
throw new IllegalArgumentException("sn, smsCode, newPublicKeyBase64 all required");
|
||||
if (smsCode == null || newPublicKeyBase64 == null) {
|
||||
throw new IllegalArgumentException("smsCode, newPublicKeyBase64 all required");
|
||||
}
|
||||
|
||||
RecoveryResponse resp = deviceBindingService.recoverDevice(
|
||||
@@ -370,7 +565,7 @@ public class DeviceController {
|
||||
// 3. 获取设备公钥,用于包裹 DEK 下发
|
||||
PublicKey devicePubKey = RsaUtil.publicKeyFromBase64(device.getPublicKeyBase64());
|
||||
|
||||
// 4. 遍历照片:UK 解 DEK → 设备公钥加密 DEK → 下发
|
||||
// 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);
|
||||
@@ -382,9 +577,21 @@ public class DeviceController {
|
||||
// 用设备公钥加密 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
|
||||
"encryptedDekBase64", encryptedDek,
|
||||
"ciphertextBase64", ciphertextBase64,
|
||||
"ivBase64", photo.getIvBase64()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -395,6 +602,78 @@ public class DeviceController {
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 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. 下载解密照片(用户端,登录) ====================
|
||||
|
||||
/**
|
||||
@@ -436,10 +715,74 @@ public class DeviceController {
|
||||
));
|
||||
|
||||
} catch (Exception e) {
|
||||
throw new ApiException(500, "Decryption failed: " + e.getMessage());
|
||||
log.error("Decryption failed for photo {}", photoId, e);
|
||||
throw new ApiException(500, "Decryption failed");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 7b. 下载解密照片(流式,用户端,登录) ====================
|
||||
|
||||
/**
|
||||
* 用户端(已登录)下载并解密照片 —— 流式传输版。
|
||||
*
|
||||
* 与 {@link #downloadAndDecrypt(String, HttpServletRequest)} 功能一致,但不再把明文塞进
|
||||
* JSON(Base64 膨胀 ~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 解密 DEK(DEK 明文仅在解密循环内短暂可见)
|
||||
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. 我的照片列表(用户端,登录) ====================
|
||||
|
||||
/**
|
||||
@@ -454,6 +797,34 @@ public class DeviceController {
|
||||
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. 我的设备列表(用户端,登录) ====================
|
||||
|
||||
/**
|
||||
@@ -492,7 +863,9 @@ public class DeviceController {
|
||||
* 均无 → 401 Unauthorized
|
||||
*/
|
||||
private String resolveUserId(HttpServletRequest request, Map<String, String> body) {
|
||||
// 1. Bearer Token(真实场景)
|
||||
// 仅允许 Bearer Token(Web 用户端)。
|
||||
// 零信任原则:不再信任 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());
|
||||
@@ -501,37 +874,90 @@ public class DeviceController {
|
||||
}
|
||||
throw new UnauthorizedException("Invalid or expired token");
|
||||
}
|
||||
// 2. X-User-Id Header(Android demo 兼容)
|
||||
String headerUserId = request.getHeader("X-User-Id");
|
||||
if (headerUserId != null && !headerUserId.isBlank()) {
|
||||
return headerUserId;
|
||||
}
|
||||
// 3. body.userId(集成测试 / 旧调用兼容)
|
||||
if (body != null && body.get("userId") != null && !body.get("userId").isBlank()) {
|
||||
return body.get("userId");
|
||||
}
|
||||
throw new UnauthorizedException("Login required: missing Authorization Bearer token");
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备签名认证(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 {
|
||||
Path target = uploadRoot.resolve(photoId + ".enc");
|
||||
String safeId = sanitizePhotoId(photoId);
|
||||
Path target = resolveWithinUploadRoot(safeId + ".enc");
|
||||
byte[] raw = Base64.getDecoder().decode(ciphertextBase64);
|
||||
Files.write(target, raw);
|
||||
return target.toAbsolutePath().toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从落盘文件读取密文并转为 Base64 字符串(供 AesGcmUtil.decrypt 使用)
|
||||
* 净化照片 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 {
|
||||
byte[] raw = Files.readAllBytes(Paths.get(filePath));
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user