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 ): │ * │ POST /api/device/bind 绑定设备(SN) │ * │ POST /api/device/sms/send 发送短信验证码 │ * │ POST /api/device/recover 恢复授权(短信验证) │ * │ GET /api/photo/{id}/decrypt 下载并解密照片 │ * │ GET /api/user/photos 我的照片列表(新增) │ * │ GET /api/user/devices 我的设备列表(新增) │ * └────────────────────────────────────────────────────────────────┘ * * 鉴权优先级(用户端接口):Bearer Token > X-User-Id Header(Android * demo 兼容)> body.userId(集成测试兼容)。生产环境只保留 Bearer Token。 * * 所有响应统一为 ApiResponse{code, message, data},前端只依赖该契约。 */ @RestController @RequestMapping("/api") 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> 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> registerDevice(@RequestBody Map 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> deviceStatusChallenge() { String challenge = deviceBindingService.generateStatusChallenge(); Map 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> 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 对应用户。 * * 不再信任 X-User-Id / body.userId 自报身份(零信任)。 * Request: { "sn": "...", "challenge": "...", "signature": "...", "userId": "user-001", "phone": "..." } * Response: data = { "message": "..." } */ @PostMapping("/device/bind") public ApiResponse> bindDevice(@RequestBody Map 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> sendSms(@RequestBody Map 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> devicePhotos(@RequestBody Map req) { Device device = authenticateDeviceBySignature(req); String userId = device.getUserId(); if (userId == null || userId.isBlank()) { throw new SecurityException("Device not bound to any user"); } List 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> devicePhotosMetadata(@RequestBody Map req) { Device device = authenticateDeviceBySignature(req); String userId = device.getUserId(); if (userId == null || userId.isBlank()) { throw new SecurityException("Device not bound to any user"); } List> list = new ArrayList<>(); for (String photoId : deviceBindingService.getUserPhotoIds(userId)) { EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null); if (photo == null) continue; Map 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> 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> uploadPhoto(@RequestBody Map 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. 验签:用设备公钥验证 metadataSignature(SHA256withRSA),防伪/防篡改 if (metadata == null || metadataSignature == null || metadataSignature.isEmpty()) { throw new IllegalArgumentException("metadata and metadataSignature required"); } // 元数据必须以设备 SN 开头,防止跨设备重放他人签名 if (!metadata.startsWith(sn + "|")) { throw new SecurityException("metadata must be bound to this device SN"); } PublicKey devicePubKey = RsaUtil.publicKeyFromBase64(device.getPublicKeyBase64()); if (!RsaUtil.verifySignature(metadata, metadataSignature, devicePubKey)) { throw new SecurityException("Metadata signature verification failed"); } // 3. 用 UK 加密 DEK(服务端永远只存 "UK 加密后的 DEK")。 // 设备上传的是「传输公钥加密的 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 。 * * Request: { "sn": "...", "challenge": "...", "signature": "...", "smsCode": "...", "newPublicKeyBase64": "..." } * Response: data = { "deviceId": "...", "encryptedRecoveryToken": "...", "nonce": "...", "message": "..." } */ @PostMapping("/device/recover") public ApiResponse> recoverDevice(@RequestBody Map 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> recoverPhotos(@RequestBody Map 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. 验证 recoveryToken:deviceId 匹配 + 5 分钟时间窗口 + nonce 一次性防重放 // Token 明文格式: userId|deviceId|timestamp|nonce(设备用 TEE 私钥解密后回传) if (!deviceBindingService.validateRecoveryToken(deviceId, recoveryToken)) { throw new SecurityException("Invalid, expired or replayed recovery token"); } // 3. 获取设备公钥,用于包裹 DEK 下发 PublicKey devicePubKey = RsaUtil.publicKeyFromBase64(device.getPublicKeyBase64()); // 4. 遍历照片:UK 解 DEK → 设备公钥加密 DEK → 下发(同时附密文+IV,供设备端本地还原) List> 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> 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> 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)} 功能一致,但不再把明文塞进 * 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 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. 我的照片列表(用户端,登录) ==================== /** * 当前登录用户的照片列表(仅 photoId,不含密文/DEK) * * Response: data = { "photos": ["photo-001", "photo-002", ...] } */ @GetMapping("/user/photos") public ApiResponse> getUserPhotos(HttpServletRequest httpRequest) { String userId = resolveUserId(httpRequest, null); List 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> getUserPhotoMetadata(HttpServletRequest httpRequest) { String userId = resolveUserId(httpRequest, null); List> list = new ArrayList<>(); for (String photoId : deviceBindingService.getUserPhotoIds(userId)) { EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null); if (photo == null) continue; Map 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> getUserDevices(HttpServletRequest httpRequest) { String userId = resolveUserId(httpRequest, null); List devices = deviceBindingService.getDevicesByUser(userId); List> 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 (Web 用户端登录后携带,真实场景唯一方式) * 2. X-User-Id Header(Android 设备端 demo 兼容,AuthInterceptor 自动注入) * 3. body.userId(集成测试 / 旧调用兼容) * * 均无 → 401 Unauthorized */ private String resolveUserId(HttpServletRequest request, Map body) { // 仅允许 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()); 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 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); } }