refactor(android): 改用设备签名拦截器认证,移除 challenge 往返
设备接口统一由 AuthInterceptor 注入 Device-Sig 头完成认证,删除手动 challenge 签名流程与 transport-key 接口,注册改用本地生成 ts:nonce 做 PoP 验签。
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
package com.secure.demo.config;
|
||||
|
||||
import com.secure.demo.common.UnauthorizedException;
|
||||
import com.secure.demo.service.DeviceBindingService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.servlet.HandlerInterceptor;
|
||||
|
||||
/**
|
||||
* 设备签名认证拦截器(零信任,方案 A:公钥签名 + 时间戳/nonce 自校验 + Header 承载)。
|
||||
*
|
||||
* <p>替代旧的「先 GET /challenge 再签名」两段式流程,设备本地生成时间戳与随机 nonce,
|
||||
* 用 TEE 私钥对 {@code METHOD|path|ts|nonce} 签名后放入请求头,服务端在拦截器内
|
||||
* 统一完成验签,业务接口零额外认证代码、零额外往返。</p>
|
||||
*
|
||||
* <p>请求头格式:</p>
|
||||
* <pre>
|
||||
* Authorization: Device-Sig sn=SN-001,ts=1730000000000,nonce=abc123,sig=<base64>
|
||||
* </pre>
|
||||
*
|
||||
* <p>验签通过后,认证通过的 {@link com.secure.demo.model.Device} 会被放入 request attribute
|
||||
* {@link #ATTR_DEVICE},业务接口直接取出使用。</p>
|
||||
*
|
||||
* <p>仅对需要设备认证的接口生效({@link #isDevicePath}),注册/登录/健康检查等公开接口由
|
||||
* {@link WebConfig} 显式排除。</p>
|
||||
*/
|
||||
@Component
|
||||
public class DeviceAuthInterceptor implements HandlerInterceptor {
|
||||
|
||||
/** 认证通过后存放 Device 的 request attribute 名 */
|
||||
public static final String ATTR_DEVICE = "authenticatedDevice";
|
||||
|
||||
private static final String HEADER_PREFIX = "Device-Sig ";
|
||||
|
||||
private final DeviceBindingService deviceBindingService;
|
||||
|
||||
public DeviceAuthInterceptor(DeviceBindingService deviceBindingService) {
|
||||
this.deviceBindingService = deviceBindingService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
|
||||
String header = request.getHeader("Authorization");
|
||||
if (header == null || !header.startsWith(HEADER_PREFIX)) {
|
||||
throw new UnauthorizedException("Missing device signature header");
|
||||
}
|
||||
// 解析 sn,ts,nonce,sig
|
||||
String[] parts = header.substring(HEADER_PREFIX.length()).split(",");
|
||||
String sn = null, ts = null, nonce = null, sig = null;
|
||||
for (String p : parts) {
|
||||
String[] kv = p.trim().split("=", 2);
|
||||
if (kv.length != 2) continue;
|
||||
switch (kv[0]) {
|
||||
case "sn": sn = kv[1]; break;
|
||||
case "ts": ts = kv[1]; break;
|
||||
case "nonce": nonce = kv[1]; break;
|
||||
case "sig": sig = kv[1]; break;
|
||||
default: break;
|
||||
}
|
||||
}
|
||||
if (sn == null || ts == null || nonce == null || sig == null) {
|
||||
throw new UnauthorizedException("Device signature header missing fields (sn,ts,nonce,sig)");
|
||||
}
|
||||
|
||||
// 构造签名原文:METHOD|path|ts|nonce(与设备端 AuthInterceptor 完全一致)
|
||||
String canonical = request.getMethod() + "|" + request.getRequestURI() + "|" + ts + "|" + nonce;
|
||||
|
||||
// 统一验签(查库取公钥 + 时效 + nonce 防重放 + RSA-PSS 验签)
|
||||
com.secure.demo.model.Device device = deviceBindingService.authenticateByHeader(
|
||||
sn, ts, nonce, sig, canonical);
|
||||
|
||||
// 认证通过:把设备放入 request attribute,供业务接口使用
|
||||
request.setAttribute(ATTR_DEVICE, device);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断某路径是否需要设备签名认证(供 WebConfig 注册时按需放行)。
|
||||
* 公开接口(注册/登录/健康检查/传输公钥已移除)不需要拦截。
|
||||
*/
|
||||
public static boolean isDevicePath(String path) {
|
||||
return path.startsWith("/api/device/")
|
||||
|| path.startsWith("/api/photo/");
|
||||
}
|
||||
|
||||
/** 便捷方法:是否 GET(无 body,签名原文不含 bodyHash 的占位) */
|
||||
public static boolean isGet(HttpServletRequest request) {
|
||||
return HttpMethod.GET.matches(request.getMethod());
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.secure.demo.config;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.web.servlet.config.annotation.CorsRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||
|
||||
import java.util.Arrays;
|
||||
@@ -40,10 +41,12 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
+ "http://localhost:5173,http://127.0.0.1:5173";
|
||||
|
||||
private final List<String> allowedOrigins;
|
||||
private final DeviceAuthInterceptor deviceAuthInterceptor;
|
||||
|
||||
public WebConfig(
|
||||
@Value("${app.cors.allowed-origins:}") String configuredOrigins,
|
||||
@Value("${APP_CORS_ALLOWED_ORIGINS:}") String envOrigins) {
|
||||
@Value("${APP_CORS_ALLOWED_ORIGINS:}") String envOrigins,
|
||||
DeviceAuthInterceptor deviceAuthInterceptor) {
|
||||
String raw = (envOrigins != null && !envOrigins.isBlank()) ? envOrigins : configuredOrigins;
|
||||
if (raw == null || raw.isBlank()) {
|
||||
raw = DEFAULT_ALLOWED_ORIGINS;
|
||||
@@ -52,6 +55,20 @@ public class WebConfig implements WebMvcConfigurer {
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
this.deviceAuthInterceptor = deviceAuthInterceptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addInterceptors(InterceptorRegistry registry) {
|
||||
// 设备签名认证拦截器:仅拦截需要设备认证的路径(/api/device/**、/api/photo/**),
|
||||
// 显式排除公开接口(注册、登录、健康检查、CORS 预检)。
|
||||
registry.addInterceptor(deviceAuthInterceptor)
|
||||
.addPathPatterns("/api/device/**", "/api/photo/**")
|
||||
.excludePathPatterns(
|
||||
"/api/device/register", // 注册:设备未入库,用请求内公钥做 PoP 验签
|
||||
"/api/photo/{photoId}/decrypt", // Web 用户端 Bearer 鉴权,非设备签名
|
||||
"/api/photo/{photoId}/decrypt/stream"
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -4,17 +4,15 @@ 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.config.DeviceAuthInterceptor;
|
||||
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;
|
||||
@@ -39,34 +37,39 @@ import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* 设备安全 API 控制器(前后端分离版)
|
||||
* 设备安全 API 控制器(前后端分离版,方案 A:Header 设备签名认证,零 challenge 往返)。
|
||||
*
|
||||
* ┌────────────────────────────────────────────────────────────────┐
|
||||
* │ 接口分组(模拟真实场景) │
|
||||
* ├────────────────────────────────────────────────────────────────┤
|
||||
* │ 公开: │
|
||||
* │ 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 我的设备列表(新增) │
|
||||
* └────────────────────────────────────────────────────────────────┘
|
||||
* <p>设备签名认证改为「请求头 + 时间戳/nonce 自校验」:设备用 TEE 私钥对
|
||||
* {@code METHOD|path|ts|nonce} 签名放入 {@code Authorization: Device-Sig ...} 头,
|
||||
* 由 {@link DeviceAuthInterceptor} 统一验签后把设备放入 request attribute。</p>
|
||||
*
|
||||
* 鉴权优先级(用户端接口):Bearer Token > X-User-Id Header(Android
|
||||
* demo 兼容)> body.userId(集成测试兼容)。生产环境只保留 Bearer Token。
|
||||
* <p>接口分组:</p>
|
||||
* <pre>
|
||||
* 公开(无需设备认证):
|
||||
* POST /api/auth/register 用户注册
|
||||
* POST /api/auth/login 用户登录
|
||||
* POST /api/auth/logout 登出
|
||||
* POST /api/device/register 设备注册(PoP:请求内公钥验签,公开)
|
||||
* GET /api/health 健康检查
|
||||
*
|
||||
* 所有响应统一为 ApiResponse{code, message, data},前端只依赖该契约。
|
||||
* 设备端(拦截器自动认证,无需业务层重复验签):
|
||||
* POST /api/device/status 查询注册/绑定状态(App 启动复用)
|
||||
* POST /api/device/bind 绑定设备
|
||||
* POST /api/device/sms/send 发送短信验证码
|
||||
* POST /api/device/recover 恢复授权(短信 + SN 双因子)
|
||||
* POST /api/device/photos 本设备绑定用户的照片列表
|
||||
* POST /api/device/photos/metadata 本设备绑定用户的照片元数据
|
||||
* POST /api/device/photo/{id}/local 设备本地下载解密单张照片
|
||||
* POST /api/photo/upload 上传加密照片
|
||||
* POST /api/photo/recover 恢复后获取照片 DEK
|
||||
*
|
||||
* 用户端(需要 Authorization: Bearer <token>):
|
||||
* GET /api/photo/{id}/decrypt 下载并解密照片
|
||||
* GET /api/photo/{id}/decrypt/stream 下载并解密照片(流式)
|
||||
* GET /api/user/photos 我的照片列表
|
||||
* GET /api/user/photos/metadata 我的照片元数据
|
||||
* GET /api/user/devices 我的设备列表
|
||||
* </pre>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api")
|
||||
@@ -78,7 +81,6 @@ public class DeviceController {
|
||||
private final DeviceBindingService deviceBindingService;
|
||||
private final EncryptedPhotoRepository photoRepository;
|
||||
private final TokenService tokenService;
|
||||
private final TransportKeyService transportKeyService;
|
||||
|
||||
// 密文文件落盘根目录(来自配置 app.upload.dir,默认 ./uploads)
|
||||
private final Path uploadRoot;
|
||||
@@ -90,14 +92,12 @@ public class DeviceController {
|
||||
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);
|
||||
// 启动时确保上传目录存在
|
||||
@@ -115,25 +115,16 @@ public class DeviceController {
|
||||
return ApiResponse.ok(Map.of("status", "ok", "service", "secure-device-demo"));
|
||||
}
|
||||
|
||||
// ==================== 1. 设备注册(设备端,无登录,PoP 验签) ====================
|
||||
// ==================== 1. 设备注册(公开,PoP 验签) ====================
|
||||
|
||||
/**
|
||||
* 设备首次启动 / 恢复出厂后调用
|
||||
* 设备首次启动 / 恢复出厂后调用。
|
||||
*
|
||||
* 安全要求(修复 P0-1):注册必须携带 PoP 证明,防止攻击者用自己公钥冒名注册受害者 SN。
|
||||
* 流程:
|
||||
* 1. 设备先 GET /api/device/challenge 获取一次性挑战值
|
||||
* 2. 设备用 TEE 私钥对 challenge 签名
|
||||
* 3. 本接口用「请求内上传的公钥」验签(Proof of Possession),证明私钥持有者确实拥有该公钥
|
||||
* 4. 验签通过后才允许注册/停旧换新
|
||||
* <p>注册接口保持公开(设备尚未入库),用请求内携带的公钥做 PoP 验签:
|
||||
* 设备先本地生成 {@code challenge = ts:nonce},用 TEE 私钥签名后上报,
|
||||
* 服务端用「请求内上传的公钥」验签,证明私钥持有者确实拥有该公钥,防止冒名注册。</p>
|
||||
*
|
||||
* Request: {
|
||||
* "sn": "SN-DEMO-001",
|
||||
* "publicKeyBase64": "...",
|
||||
* "challenge": "1730000000000:nonce123",
|
||||
* "signature": "..."
|
||||
* }
|
||||
* Response: data = { "deviceId": "...", "sn": "SN-DEMO-001", "message": "..." }
|
||||
* Request: { "sn", "publicKeyBase64", "challenge": "ts:nonce", "signature" }
|
||||
*/
|
||||
@PostMapping("/device/register")
|
||||
public ApiResponse<Map<String, String>> registerDevice(@RequestBody Map<String, String> req) {
|
||||
@@ -157,85 +148,51 @@ public class DeviceController {
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备状态查询用的挑战值(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);
|
||||
}
|
||||
// ==================== 2. 设备状态查询(拦截器认证) ====================
|
||||
|
||||
/**
|
||||
* 查询设备注册/绑定状态(设备端,无登录,App 启动时调用以复用已有状态)。
|
||||
* 本接口要求设备签名认证(Challenge-Response,基于 TEE 私钥)。
|
||||
* 查询设备注册/绑定状态(App 启动时调用以复用已有状态)。设备认证由拦截器完成。
|
||||
*
|
||||
* 认证流程:
|
||||
* 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 判断公钥是否轮换)
|
||||
* }
|
||||
* Response: data = { registered, bound, active, deviceId, userId, publicKeyBase64 }
|
||||
*/
|
||||
@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();
|
||||
public ApiResponse<Map<String, Object>> deviceStatus(HttpServletRequest httpRequest) {
|
||||
Device device = authenticatedDevice(httpRequest);
|
||||
boolean registered = true;
|
||||
boolean bound = device.getUserId() != null && !device.getUserId().isBlank();
|
||||
boolean active = device.isActive();
|
||||
|
||||
return ApiResponse.ok(Map.of(
|
||||
"registered", registered,
|
||||
"bound", bound,
|
||||
"active", active,
|
||||
"deviceId", registered ? device.getDeviceId() : "",
|
||||
"deviceId", device.getDeviceId(),
|
||||
"userId", bound ? device.getUserId() : "",
|
||||
"publicKeyBase64", registered ? device.getPublicKeyBase64() : ""
|
||||
"publicKeyBase64", device.getPublicKeyBase64()
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 2. 绑定设备(设备签名认证) ====================
|
||||
// ==================== 3. 绑定设备(拦截器认证 / Bearer) ====================
|
||||
|
||||
/**
|
||||
* 绑定设备(零信任:设备签名认证 + 归属校验)。
|
||||
* 绑定设备(零信任)。
|
||||
*
|
||||
* 认证方式(二选一):
|
||||
* - Android 设备端:请求体携带 { sn, challenge, signature, userId },服务端用该 SN 对应
|
||||
* 设备公钥验签(Challenge-Response),证明请求方持有该设备 TEE 私钥;绑定目标 userId 取自 body。
|
||||
* - Web 用户端:Authorization: Bearer <token>,绑定目标为 Token 对应用户。
|
||||
* <p>认证方式:</p>
|
||||
* <ul>
|
||||
* <li>Android 设备端:请求头 Device-Sig(拦截器认证),绑定目标 userId 取自 body;</li>
|
||||
* <li>Web 用户端:Authorization: Bearer <token>,绑定目标为 Token 对应用户。</li>
|
||||
* </ul>
|
||||
*
|
||||
* 不再信任 X-User-Id / body.userId 自报身份(零信任)。
|
||||
* Request: { "sn": "...", "challenge": "...", "signature": "...", "userId": "user-001", "phone": "..." }
|
||||
* Response: data = { "message": "..." }
|
||||
* Request: { "userId": "user-001", "phone": "..." }
|
||||
*/
|
||||
@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 ")) {
|
||||
// Web 用户端
|
||||
userId = tokenService.getUserId(auth.substring(7).trim());
|
||||
if (userId == null) {
|
||||
throw new UnauthorizedException("Invalid or expired token");
|
||||
@@ -249,8 +206,8 @@ public class DeviceController {
|
||||
throw new ApiException(404, "Device not registered for SN: " + sn);
|
||||
}
|
||||
} else {
|
||||
// 2) Android 设备端:设备签名认证(SN + challenge + signature)
|
||||
device = authenticateDeviceBySignature(req);
|
||||
// Android 设备端:拦截器已完成设备签名认证
|
||||
device = authenticatedDevice(httpRequest);
|
||||
userId = req.get("userId");
|
||||
if (userId == null || userId.isBlank()) {
|
||||
throw new IllegalArgumentException("userId required for device-signature bind");
|
||||
@@ -267,21 +224,17 @@ public class DeviceController {
|
||||
return ApiResponse.ok(Map.of("message", "Device bound to user successfully"));
|
||||
}
|
||||
|
||||
// ==================== 3. 短信验证码(设备签名认证) ====================
|
||||
// ==================== 4. 发送短信验证码(拦截器认证) ====================
|
||||
|
||||
/**
|
||||
* 发送短信验证码(设备签名认证,防短信轰炸)。
|
||||
* 发送短信验证码(拦截器认证设备身份,防短信轰炸)。
|
||||
*
|
||||
* Android 设备端携带 { sn, challenge, signature, phone },服务端验签确认设备身份后,
|
||||
* 校验该设备已绑定用户,再发送短信。不再依赖 X-User-Id 自报身份。
|
||||
* Request: { "sn": "...", "challenge": "...", "signature": "...", "phone": "13800138000" }
|
||||
* Response: data = { "message": "..." }
|
||||
* Request: { "phone": "13800138000" }
|
||||
*/
|
||||
@PostMapping("/device/sms/send")
|
||||
public ApiResponse<Map<String, String>> sendSms(@RequestBody Map<String, String> req,
|
||||
HttpServletRequest httpRequest) {
|
||||
// 设备签名认证(防轰炸 + 设备身份校验)
|
||||
Device device = authenticateDeviceBySignature(req);
|
||||
Device device = authenticatedDevice(httpRequest);
|
||||
if (device.getUserId() == null || device.getUserId().isBlank()) {
|
||||
throw new SecurityException("Device not bound to any user");
|
||||
}
|
||||
@@ -293,19 +246,16 @@ public class DeviceController {
|
||||
return ApiResponse.ok(Map.of("message", "SMS code sent (check server logs for demo code)"));
|
||||
}
|
||||
|
||||
// ==================== 6c. 设备照片列表(设备签名认证) ====================
|
||||
// ==================== 5. 设备照片列表 / 元数据(拦截器认证) ====================
|
||||
|
||||
/**
|
||||
* 设备端拉取「本设备绑定用户」的照片 ID 列表(零信任,设备签名认证)。
|
||||
* 设备端拉取「本设备绑定用户」的照片 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);
|
||||
public ApiResponse<Map<String, Object>> devicePhotos(HttpServletRequest httpRequest) {
|
||||
Device device = authenticatedDevice(httpRequest);
|
||||
String userId = device.getUserId();
|
||||
if (userId == null || userId.isBlank()) {
|
||||
throw new SecurityException("Device not bound to any user");
|
||||
@@ -315,21 +265,22 @@ public class DeviceController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备照片元数据列表(零信任:设备签名认证,供 Android 展示)。
|
||||
* 设备照片元数据列表(拦截器认证,供 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);
|
||||
public ApiResponse<Map<String, Object>> devicePhotosMetadata(HttpServletRequest httpRequest) {
|
||||
Device device = authenticatedDevice(httpRequest);
|
||||
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<>();
|
||||
// 恢复出厂+重新注册后,历史照片记录的 deviceId 是已停用的旧设备,但仍可被
|
||||
// 新设备(active=true,已恢复授权)解密。因此「是否可解密」按该用户是否存在
|
||||
// active 设备判断,而非照片上传时那个 deviceId 是否仍 active。
|
||||
boolean userHasActiveDevice = deviceBindingService.hasActiveDeviceForUser(userId);
|
||||
for (String photoId : deviceBindingService.getUserPhotoIds(userId)) {
|
||||
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
|
||||
if (photo == null) continue;
|
||||
@@ -339,61 +290,37 @@ public class DeviceController {
|
||||
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());
|
||||
item.put("activeDevice", userHasActiveDevice);
|
||||
list.add(item);
|
||||
}
|
||||
return ApiResponse.ok(Map.of("photos", list));
|
||||
}
|
||||
|
||||
// ==================== 3b. 获取服务端传输公钥(设备上传 DEK 加密用) ====================
|
||||
// ==================== 6. 上传加密照片(拦截器认证) ====================
|
||||
|
||||
/**
|
||||
* 下发服务端传输公钥(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. 上传加密照片(设备端,无登录) ====================
|
||||
|
||||
/**
|
||||
* 设备上传加密照片(信封加密,设备私钥签名防伪)
|
||||
* <p>DEK 由设备端随机生成并随包上传明文(demo 无 HTTPS 时为纵深防御的取舍,
|
||||
* 生产环境应启用 HTTPS 或恢复传输公钥加密)。</p>
|
||||
*
|
||||
* 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。
|
||||
* Request: { "sn", "photoId", "ciphertextBase64", "ivBase64", "dekBase64", "metadataSignature", "metadata" }
|
||||
*/
|
||||
@PostMapping("/photo/upload")
|
||||
public ApiResponse<Map<String, String>> uploadPhoto(@RequestBody Map<String, String> req) {
|
||||
String sn = req.get("sn");
|
||||
public ApiResponse<Map<String, String>> uploadPhoto(@RequestBody Map<String, String> req,
|
||||
HttpServletRequest httpRequest) {
|
||||
Device device = authenticatedDevice(httpRequest);
|
||||
String sn = device.getSn();
|
||||
// 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");
|
||||
// DEK:设备端随机生成,明文随包上传(生产需 HTTPS / 传输公钥加密)
|
||||
String dekBase64 = req.get("dekBase64");
|
||||
String metadataSignature = req.get("metadataSignature");
|
||||
String metadata = req.get("metadata");
|
||||
|
||||
// 0. 输入大小限制(防 DoS):
|
||||
// - 密文 Base64 长度换算回字节后不得超过 maxUploadBytes
|
||||
// - 其他元数据字段也限长,防止超大 body 消耗内存/存储
|
||||
// 0. 输入大小限制(防 DoS)
|
||||
if (ciphertextBase64 == null || ciphertextBase64.isBlank()) {
|
||||
throw new IllegalArgumentException("ciphertextBase64 required");
|
||||
}
|
||||
@@ -405,9 +332,8 @@ public class DeviceController {
|
||||
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 (dekBase64 == null || dekBase64.isBlank() || dekBase64.length() > 1024) {
|
||||
throw new IllegalArgumentException("dekBase64 missing or too long");
|
||||
}
|
||||
if (metadataSignature != null && metadataSignature.length() > 1024) {
|
||||
throw new IllegalArgumentException("metadataSignature too long");
|
||||
@@ -416,11 +342,7 @@ public class DeviceController {
|
||||
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);
|
||||
}
|
||||
// 1. 设备状态校验(拦截器已认证,此处再校验归属)
|
||||
if (!device.isActive()) {
|
||||
throw new SecurityException("Device not active. Please complete recovery.");
|
||||
}
|
||||
@@ -429,11 +351,10 @@ public class DeviceController {
|
||||
throw new SecurityException("Device not bound to any user");
|
||||
}
|
||||
|
||||
// 2. 验签:用设备公钥验证 metadataSignature(SHA256withRSA),防伪/防篡改
|
||||
// 2. 验签:用设备公钥验证 metadataSignature(RSA-PSS),防伪/防篡改
|
||||
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");
|
||||
}
|
||||
@@ -442,14 +363,7 @@ public class DeviceController {
|
||||
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);
|
||||
// 3. 用 UK 加密 DEK 存储(服务端永远只存 "UK 加密后的 DEK")
|
||||
String encryptedDek = keyManagementService.wrapDEK(dekBase64, userId);
|
||||
|
||||
// 4. 密文落盘
|
||||
@@ -476,19 +390,12 @@ public class DeviceController {
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 5. 恢复授权(设备签名认证) ====================
|
||||
// ==================== 7. 恢复授权(拦截器认证 / Bearer) ====================
|
||||
|
||||
/**
|
||||
* 恢复出厂后重新绑定 + 授权(零信任:设备签名认证)。
|
||||
* 恢复出厂后重新绑定 + 授权(短信 + SN 双因子)。
|
||||
*
|
||||
* 认证方式:
|
||||
* - 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": "..." }
|
||||
* Request: { "sn", "smsCode", "newPublicKeyBase64" }
|
||||
*/
|
||||
@PostMapping("/device/recover")
|
||||
public ApiResponse<Map<String, String>> recoverDevice(@RequestBody Map<String, String> req,
|
||||
@@ -504,8 +411,8 @@ public class DeviceController {
|
||||
}
|
||||
sn = req.get("sn");
|
||||
} else {
|
||||
// Android 设备端:设备签名认证
|
||||
Device device = authenticateDeviceBySignature(req);
|
||||
// Android 设备端:拦截器已完成设备签名认证
|
||||
Device device = authenticatedDevice(httpRequest);
|
||||
sn = device.getSn();
|
||||
userId = device.getUserId();
|
||||
}
|
||||
@@ -529,17 +436,17 @@ public class DeviceController {
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 6. 恢复后获取照片 DEK(设备端,无登录) ====================
|
||||
// ==================== 8. 恢复后获取照片 DEK(拦截器认证) ====================
|
||||
|
||||
/**
|
||||
* 设备用 Recovery Token 获取该用户所有照片的 DEK
|
||||
* 服务端用设备当前公钥逐一加密 DEK 后下发
|
||||
* 设备用 Recovery Token 获取该用户所有照片的 DEK,服务端用设备当前公钥逐一加密下发。
|
||||
*
|
||||
* Request: { "deviceId": "...", "recoveryToken": "...", "userId": "user-001" }
|
||||
* Response: data = { "deviceId": "...", "photoCount": 3, "deks": [{"photoId","encryptedDekBase64"}] }
|
||||
* Request: { "deviceId", "recoveryToken", "userId" }
|
||||
*/
|
||||
@PostMapping("/photo/recover")
|
||||
public ApiResponse<Map<String, Object>> recoverPhotos(@RequestBody Map<String, String> req) {
|
||||
public ApiResponse<Map<String, Object>> recoverPhotos(@RequestBody Map<String, String> req,
|
||||
HttpServletRequest httpRequest) {
|
||||
Device authenticated = authenticatedDevice(httpRequest);
|
||||
String deviceId = req.get("deviceId");
|
||||
String recoveryToken = req.get("recoveryToken");
|
||||
String userId = req.get("userId");
|
||||
@@ -547,17 +454,19 @@ public class DeviceController {
|
||||
throw new IllegalArgumentException("deviceId, recoveryToken, userId all required");
|
||||
}
|
||||
|
||||
// 1. 验证设备
|
||||
// 1. 验证设备(必须与拦截器认证设备一致)
|
||||
Device device = deviceBindingService.getDevice(deviceId);
|
||||
if (device == null || !device.isActive()) {
|
||||
throw new SecurityException("Device not active");
|
||||
}
|
||||
if (!deviceId.equals(authenticated.getDeviceId())) {
|
||||
throw new SecurityException("deviceId does not match authenticated device");
|
||||
}
|
||||
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");
|
||||
}
|
||||
@@ -571,13 +480,9 @@ public class DeviceController {
|
||||
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()
|
||||
@@ -602,49 +507,29 @@ public class DeviceController {
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 6b. 设备本地下载解密单张照片(设备端,Challenge-Response 签名认证) ====================
|
||||
// ==================== 8b. 设备本地下载解密单张照片(拦截器认证) ====================
|
||||
|
||||
/**
|
||||
* 设备端本地解密:服务端仅下发「密文 + 设备公钥加密的 DEK + IV」,由设备在 TEE 内用私钥
|
||||
* 设备端本地解密:服务端仅下发「密文 + 设备公钥加密的 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" }
|
||||
* Request: { "sn" }(可选,主要从拦截器认证取设备)+ path photoId
|
||||
*/
|
||||
@PostMapping("/device/photo/{photoId}/local")
|
||||
public ApiResponse<Map<String, String>> deviceDownloadLocal(
|
||||
@PathVariable String photoId,
|
||||
@RequestBody DeviceLocalPhotoRequest req) {
|
||||
HttpServletRequest httpRequest) {
|
||||
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)");
|
||||
}
|
||||
// 设备认证由拦截器完成
|
||||
Device device = authenticatedDevice(httpRequest);
|
||||
|
||||
// 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");
|
||||
}
|
||||
@@ -652,7 +537,7 @@ public class DeviceController {
|
||||
throw new SecurityException("Photo does not belong to this device's user");
|
||||
}
|
||||
|
||||
// 4. 用 UK 解出 DEK → 用设备公钥加密 DEK → 连同密文 + IV 下发(设备本地解密)
|
||||
// 用 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);
|
||||
@@ -674,18 +559,12 @@ public class DeviceController {
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 7. 下载解密照片(用户端,登录) ====================
|
||||
// ==================== 9. 下载解密照片(用户端,登录) ====================
|
||||
|
||||
/**
|
||||
* 用户端(已登录)下载并解密照片
|
||||
*
|
||||
* 流程:鉴权(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);
|
||||
String userId = resolveUserId(httpRequest);
|
||||
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
|
||||
if (photo == null) {
|
||||
throw new ApiException(404, "Photo not found: " + photoId);
|
||||
@@ -695,49 +574,30 @@ public class DeviceController {
|
||||
}
|
||||
|
||||
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. 下载解密照片(流式,用户端,登录) ====================
|
||||
// ==================== 9b. 下载解密照片(流式,用户端,登录) ====================
|
||||
|
||||
/**
|
||||
* 用户端(已登录)下载并解密照片 —— 流式传输版。
|
||||
*
|
||||
* 与 {@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);
|
||||
String userId = resolveUserId(httpRequest);
|
||||
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
|
||||
if (photo == null) {
|
||||
throw new ApiException(404, "Photo not found: " + photoId);
|
||||
@@ -746,11 +606,8 @@ public class DeviceController {
|
||||
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()
|
||||
@@ -763,14 +620,10 @@ public class DeviceController {
|
||||
|
||||
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");
|
||||
}
|
||||
@@ -783,32 +636,22 @@ public class DeviceController {
|
||||
.body(stream);
|
||||
}
|
||||
|
||||
// ==================== 8. 我的照片列表(用户端,登录) ====================
|
||||
// ==================== 10. 我的照片/设备列表(用户端,登录) ====================
|
||||
|
||||
/**
|
||||
* 当前登录用户的照片列表(仅 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);
|
||||
String userId = resolveUserId(httpRequest);
|
||||
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);
|
||||
String userId = resolveUserId(httpRequest);
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
// 与 devicePhotosMetadata 同理:恢复后历史照片由用户当前 active 设备解密,
|
||||
// 因此按「该用户是否存在 active 设备」判断是否可解密。
|
||||
boolean userHasActiveDevice = deviceBindingService.hasActiveDeviceForUser(userId);
|
||||
for (String photoId : deviceBindingService.getUserPhotoIds(userId)) {
|
||||
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
|
||||
if (photo == null) continue;
|
||||
@@ -816,25 +659,17 @@ public class DeviceController {
|
||||
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());
|
||||
item.put("activeDevice", userHasActiveDevice);
|
||||
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);
|
||||
String userId = resolveUserId(httpRequest);
|
||||
List<Device> devices = deviceBindingService.getDevicesByUser(userId);
|
||||
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
@@ -853,19 +688,9 @@ public class DeviceController {
|
||||
// ==================== 鉴权辅助 ====================
|
||||
|
||||
/**
|
||||
* 解析当前请求的用户 ID。
|
||||
*
|
||||
* 优先级:
|
||||
* 1. Authorization: Bearer <token>(Web 用户端登录后携带,真实场景唯一方式)
|
||||
* 2. X-User-Id Header(Android 设备端 demo 兼容,AuthInterceptor 自动注入)
|
||||
* 3. body.userId(集成测试 / 旧调用兼容)
|
||||
*
|
||||
* 均无 → 401 Unauthorized
|
||||
* 从请求头解析用户 ID(仅允许 Bearer Token)。
|
||||
*/
|
||||
private String resolveUserId(HttpServletRequest request, Map<String, String> body) {
|
||||
// 仅允许 Bearer Token(Web 用户端)。
|
||||
// 零信任原则:不再信任 X-User-Id / body.userId 自报身份(可伪造)。
|
||||
// Android 设备端一律走 authenticateDeviceBySignature() 设备签名认证。
|
||||
private String resolveUserId(HttpServletRequest request) {
|
||||
String auth = request.getHeader("Authorization");
|
||||
if (auth != null && auth.startsWith("Bearer ")) {
|
||||
String userId = tokenService.getUserId(auth.substring(7).trim());
|
||||
@@ -878,37 +703,18 @@ public class DeviceController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备签名认证(Challenge-Response):用请求体中的 { sn, challenge, signature },
|
||||
* 服务端以该 SN 对应设备公钥验签,证明请求方持有该设备的 TEE 私钥(SN ↔ TEE 绑定)。
|
||||
*
|
||||
* @return 认证通过后对应的设备
|
||||
* 从 request attribute 取出拦截器认证通过后的设备。
|
||||
*/
|
||||
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);
|
||||
private Device authenticatedDevice(HttpServletRequest httpRequest) {
|
||||
Device device = (Device) httpRequest.getAttribute(DeviceAuthInterceptor.ATTR_DEVICE);
|
||||
if (device == null) {
|
||||
throw new SecurityException("Device not registered for SN: " + sn);
|
||||
throw new UnauthorizedException("Device authentication required");
|
||||
}
|
||||
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");
|
||||
@@ -917,11 +723,6 @@ public class DeviceController {
|
||||
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("-", "");
|
||||
@@ -933,10 +734,6 @@ public class DeviceController {
|
||||
return UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 uploadRoot 内解析目标路径(纵深防御):解析后必须仍位于 uploadRoot 之下,
|
||||
* 否则视为越界拒绝。防止 photoId(或其拼接结果)通过符号链接 / .. / 绝对路径逃出目录。
|
||||
*/
|
||||
private Path resolveWithinUploadRoot(String relativeName) {
|
||||
Path rootAbs = uploadRoot.toAbsolutePath().normalize();
|
||||
Path target = rootAbs.resolve(relativeName).normalize();
|
||||
@@ -946,11 +743,6 @@ public class DeviceController {
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从落盘文件读取密文并转为 Base64 字符串(供 AesGcmUtil.decrypt 使用)。
|
||||
*
|
||||
* 安全:读取前校验文件必须位于上传根目录内(纵深防御,防止存储路径被篡改后读取目录外文件)。
|
||||
*/
|
||||
private String readCiphertextFromFile(String filePath) throws IOException {
|
||||
Path p = Paths.get(filePath).toAbsolutePath().normalize();
|
||||
Path rootAbs = uploadRoot.toAbsolutePath().normalize();
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
package com.secure.demo.controller.model;
|
||||
|
||||
/**
|
||||
* 设备本地下载解密照片请求体。
|
||||
*
|
||||
* 设备端本地解密(端到端加密路径)时,用 Challenge-Response 设备签名做认证:
|
||||
* 服务端仅下发「密文 + 用设备公钥加密的 DEK + IV」,由设备在 TEE 内用私钥本地解密,
|
||||
* 服务端全程不接触明文照片,满足零知识目标。
|
||||
*
|
||||
* 字段:
|
||||
* - sn:设备序列号(用于确定设备、其 TEE 公钥及照片归属用户)
|
||||
* - challenge:服务端 GET /api/device/challenge 下发的一次性挑战值
|
||||
* - signature:设备用 TEE 私钥对 challenge 的签名(Base64),证明请求方持有该设备私钥
|
||||
*/
|
||||
public class DeviceLocalPhotoRequest {
|
||||
private String sn;
|
||||
private String challenge; // 服务端下发的挑战值,格式 <timestampMillis>:<nonce>
|
||||
private String signature; // 设备使用 TEE 私钥对 challenge 的签名(Base64)
|
||||
|
||||
public String getSn() { return sn; }
|
||||
public void setSn(String sn) { this.sn = sn; }
|
||||
|
||||
public String getChallenge() { return challenge; }
|
||||
public void setChallenge(String challenge) { this.challenge = challenge; }
|
||||
|
||||
public String getSignature() { return signature; }
|
||||
public void setSignature(String signature) { this.signature = signature; }
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package com.secure.demo.controller.model;
|
||||
|
||||
/**
|
||||
* 设备状态查询(带设备签名认证)请求体
|
||||
*/
|
||||
public class StatusChallengeRequest {
|
||||
private String sn;
|
||||
private String challenge; // 服务端下发的挑战值,格式 <timestampMillis>:<nonce>
|
||||
private String signature; // 设备使用 TEE 私钥对 challenge 的签名(Base64)
|
||||
|
||||
public String getSn() { return sn; }
|
||||
public void setSn(String sn) { this.sn = sn; }
|
||||
|
||||
public String getChallenge() { return challenge; }
|
||||
public void setChallenge(String challenge) { this.challenge = challenge; }
|
||||
|
||||
public String getSignature() { return signature; }
|
||||
public void setSignature(String signature) { this.signature = signature; }
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
package com.secure.demo.crypto;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.Cipher;
|
||||
import javax.crypto.spec.OAEPParameterSpec;
|
||||
import javax.crypto.spec.PSource;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.PublicKey;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.spec.MGF1ParameterSpec;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* 服务端传输密钥对(Transport Key Pair)管理。
|
||||
*
|
||||
* <p>用途:解决「设备上传时 DEK 明文随包传输」的问题。
|
||||
* 设备端在传输层(HTTPS 之外)对 DEK 再加密:
|
||||
* - 设备先 GET /api/device/transport-key 获取本服务端传输公钥;
|
||||
* - 设备用该公钥(RSA-OAEP)加密 DEK,上传 {@code encryptedDekBase64}(不再上传明文 DEK);
|
||||
* - 服务端用本类持有的传输私钥解出明文 DEK,再交给 KeyManagementService.wrapDEK 用 UK 包裹存储。
|
||||
*
|
||||
* <p>这样即使传输层被中间人截获,攻击者拿到的也只是「被服务端私钥保护的加密 DEK」,
|
||||
* 无法还原明文 DEK,也就无法解密照片(满足零知识 / 纵深防御)。</p>
|
||||
*
|
||||
* <p>私钥来源优先级(从上到下):
|
||||
* 1. 内联 Base64 私钥:环境变量 {@code TRANSPORT_PRIVATE_KEY} 或配置 {@code app.transport.private-key}
|
||||
* (PKCS#8 私钥的 Base64,适用于密钥较短场景,但 RSA-3072 会很长,不推荐硬编码);
|
||||
* 2. 私钥文件:环境变量 {@code TRANSPORT_PRIVATE_KEY_FILE} 或配置 {@code app.transport.private-key-file},
|
||||
* 指向一个私钥文件,支持 PEM(OpenSSL 默认,含 {@code -----BEGIN PRIVATE KEY-----} 头)或 DER 格式
|
||||
* —— 推荐生产方式,私钥文件单独保存并限权,不进入代码仓库;
|
||||
* 3. 若皆缺失,本次启动随机生成(仅本地联调,重启后旧密钥加密的数据无法解密)。</p>
|
||||
*/
|
||||
@Service
|
||||
public class TransportKeyService {
|
||||
|
||||
private static final String KEY_ALGO = "RSA";
|
||||
private static final int KEY_BITS = 3072;
|
||||
private static final String TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding";
|
||||
|
||||
/** 匹配 PEM 中任意 PRIVATE KEY 块(含 -----BEGIN PRIVATE KEY----- 等) */
|
||||
private static final Pattern PEM_PRIVATE_KEY = Pattern.compile(
|
||||
"-----BEGIN (RSA )?PRIVATE KEY-----([A-Za-z0-9+/=\\s]+?)-----END (RSA )?PRIVATE KEY-----",
|
||||
Pattern.DOTALL);
|
||||
|
||||
private final PrivateKey privateKey;
|
||||
private final PublicKey publicKey;
|
||||
|
||||
public TransportKeyService(
|
||||
@Value("${app.transport.private-key:}") String inlineKey,
|
||||
@Value("${TRANSPORT_PRIVATE_KEY:}") String inlineEnvKey,
|
||||
@Value("${app.transport.private-key-file:}") String keyFile,
|
||||
@Value("${TRANSPORT_PRIVATE_KEY_FILE:}") String keyFileEnv) {
|
||||
|
||||
// 优先级:内联 Base64 > 私钥文件 > 临时生成
|
||||
String inlineRaw = (inlineEnvKey != null && !inlineEnvKey.isBlank()) ? inlineEnvKey : inlineKey;
|
||||
String filePath = (keyFileEnv != null && !keyFileEnv.isBlank()) ? keyFileEnv : keyFile;
|
||||
|
||||
if (inlineRaw != null && !inlineRaw.isBlank()) {
|
||||
// 方式一:内联 Base64 私钥
|
||||
PrivateKey loaded = parseBase64(inlineRaw);
|
||||
if (loaded != null) {
|
||||
this.privateKey = loaded;
|
||||
this.publicKey = derivePublicKey(loaded);
|
||||
return;
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"Invalid app.transport.private-key / TRANSPORT_PRIVATE_KEY (must be Base64 PKCS#8 RSA private key)");
|
||||
}
|
||||
|
||||
if (filePath != null && !filePath.isBlank()) {
|
||||
// 方式二:私钥文件(PEM 或 DER)
|
||||
PrivateKey loaded = loadFromFile(filePath);
|
||||
this.privateKey = loaded;
|
||||
this.publicKey = derivePublicKey(loaded);
|
||||
return;
|
||||
}
|
||||
|
||||
// 方式三:临时生成(仅本地联调)
|
||||
try {
|
||||
KeyPairGenerator kpg = KeyPairGenerator.getInstance(KEY_ALGO);
|
||||
kpg.initialize(KEY_BITS, new SecureRandom());
|
||||
KeyPair pair = kpg.generateKeyPair();
|
||||
this.privateKey = pair.getPrivate();
|
||||
this.publicKey = pair.getPublic();
|
||||
System.err.println(
|
||||
"[SECURITY WARNING] TRANSPORT_PRIVATE_KEY(FILE) / app.transport.private-key(-file) not set. " +
|
||||
"Generated an ephemeral transport key pair. Use app.transport.private-key-file=<pem path> to persist.");
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to generate ephemeral transport key pair", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 从内联 Base64(PKCS#8 DER 的 Base64)解析私钥 */
|
||||
private PrivateKey parseBase64(String base64) {
|
||||
try {
|
||||
byte[] encoded = Base64.getDecoder().decode(base64.trim());
|
||||
return generatePrivate(encoded);
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 从文件加载私钥:自动识别 PEM 与 DER 格式 */
|
||||
private PrivateKey loadFromFile(String pathStr) {
|
||||
Path path = Paths.get(pathStr);
|
||||
if (!Files.exists(path)) {
|
||||
throw new IllegalStateException("Transport private key file not found: " + pathStr);
|
||||
}
|
||||
try {
|
||||
byte[] bytes = Files.readAllBytes(path);
|
||||
// 尝试按 PEM 解析(含 -----BEGIN xxx PRIVATE KEY----- 头)
|
||||
String text = new String(bytes, StandardCharsets.US_ASCII);
|
||||
if (text.contains("BEGIN")) {
|
||||
Matcher m = PEM_PRIVATE_KEY.matcher(text);
|
||||
if (!m.find()) {
|
||||
throw new IllegalStateException("No PRIVATE KEY block found in PEM file: " + pathStr);
|
||||
}
|
||||
String b64 = m.group(2).replaceAll("\\s", "");
|
||||
return generatePrivate(Base64.getDecoder().decode(b64));
|
||||
}
|
||||
// 否则按 DER 原始字节解析
|
||||
return generatePrivate(bytes);
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException("Failed to read transport private key file: " + pathStr, e);
|
||||
}
|
||||
}
|
||||
|
||||
private PrivateKey generatePrivate(byte[] pkcs8Der) {
|
||||
try {
|
||||
KeyFactory kf = KeyFactory.getInstance(KEY_ALGO);
|
||||
return kf.generatePrivate(new PKCS8EncodedKeySpec(pkcs8Der));
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException("Invalid PKCS#8 RSA private key", e);
|
||||
}
|
||||
}
|
||||
|
||||
private PublicKey derivePublicKey(PrivateKey privateKey) {
|
||||
try {
|
||||
java.security.spec.RSAPrivateCrtKeySpec crt = KeyFactory.getInstance(KEY_ALGO)
|
||||
.getKeySpec(privateKey, java.security.spec.RSAPrivateCrtKeySpec.class);
|
||||
java.math.BigInteger mod = crt.getModulus();
|
||||
java.math.BigInteger exp = crt.getPublicExponent();
|
||||
java.security.spec.RSAPublicKeySpec pubSpec =
|
||||
new java.security.spec.RSAPublicKeySpec(mod, exp);
|
||||
return KeyFactory.getInstance(KEY_ALGO).generatePublic(pubSpec);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to derive public key from transport private key", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** 服务端传输公钥(Base64),下发给设备端用于加密 DEK。 */
|
||||
public String getPublicKeyBase64() {
|
||||
return Base64.getEncoder().encodeToString(publicKey.getEncoded());
|
||||
}
|
||||
|
||||
/**
|
||||
* 用服务端传输私钥解密「设备用传输公钥加密的 DEK」。
|
||||
*
|
||||
* @param encryptedDekBase64 设备上传的、经传输公钥 RSA-OAEP 加密的 DEK 密文(Base64)
|
||||
* @return 明文 DEK 字节(AES-256 原始密钥,32 字节)
|
||||
*/
|
||||
public byte[] decryptWithPrivateKey(String encryptedDekBase64) {
|
||||
try {
|
||||
byte[] data = Base64.getDecoder().decode(encryptedDekBase64);
|
||||
Cipher cipher = Cipher.getInstance(TRANSFORMATION);
|
||||
// 显式指定 OAEP 参数(消息摘要 SHA-256 + MGF1-SHA256),与 Android 端严格一致,
|
||||
// 消除 Android(Conscrypt) 与 OpenJDK 对 OAEP/MGF1 默认哈希解释不一致导致的解密失败。
|
||||
OAEPParameterSpec oaepSpec = new OAEPParameterSpec(
|
||||
"SHA-256", "MGF1", MGF1ParameterSpec.SHA256, PSource.PSpecified.DEFAULT);
|
||||
cipher.init(Cipher.DECRYPT_MODE, privateKey, oaepSpec);
|
||||
return cipher.doFinal(data);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Decrypt DEK with transport private key failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,26 @@ public class DeviceBindingService {
|
||||
return deviceRepository.findById(deviceId).orElse(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断用户是否仍持有「可本地解密」的激活设备。
|
||||
*
|
||||
* 恢复出厂 + 重新注册后,历史照片记录的 deviceId 仍是已停用的旧设备,
|
||||
* 但新设备(active=true)已通过恢复授权接替并仍能解密这些照片。
|
||||
* 因此判断「照片当前是否可解密」应以「该用户是否存在 active 设备」为准,
|
||||
* 而非照片记录时那个 deviceId 是否仍 active(否则恢复后会被误判为不可解密)。
|
||||
*/
|
||||
public boolean hasActiveDeviceForUser(String userId) {
|
||||
if (userId == null || userId.isBlank()) {
|
||||
return false;
|
||||
}
|
||||
for (Device d : deviceRepository.findAll()) {
|
||||
if (userId.equals(d.getUserId()) && d.isActive()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ==================== 3. 短信验证码(模拟) ====================
|
||||
|
||||
/**
|
||||
@@ -292,30 +312,18 @@ public class DeviceBindingService {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 7. 设备注册/状态查询认证(Challenge-Response + PoP) ====================
|
||||
|
||||
/**
|
||||
* 生成一个用于设备注册认证的一次性挑战值(PoP,Proof of Possession)。
|
||||
* 格式:<timestampMillis>:<nonce>
|
||||
* - timestampMillis 用于时效校验(CHALLENGE_VALID_WINDOW_MS 内有效)
|
||||
* - nonce 用于防重放(一次性消费)
|
||||
*
|
||||
* <p>与 {@link #generateStatusChallenge()} 语义一致,但用于注册前证明
|
||||
* 「请求方确实拥有所上传公钥对应的 TEE 私钥」,避免攻击者用自己公钥冒名注册。</p>
|
||||
*/
|
||||
public String generateRegisterChallenge() {
|
||||
return generateStatusChallenge();
|
||||
}
|
||||
// ==================== 7. 设备注册认证(PoP) ====================
|
||||
|
||||
/**
|
||||
* 校验注册请求的 PoP 签名:用<b>请求内上传的公钥</b>验证设备对 challenge 的签名。
|
||||
*
|
||||
* <p>与 {@link #verifyStatusChallenge} 的区别:注册时设备可能尚未入库,因此不能
|
||||
* 按 SN 查库取公钥,而是直接用请求体携带的 {@code publicKeyBase64} 验签。
|
||||
* 验签通过即证明「该公钥的私钥持有者」发起了本次注册,从而绑定 SN ↔ TEE 私钥。</p>
|
||||
* <p>注册时设备尚未入库,因此不能按 SN 查库取公钥,而是直接用请求体携带的
|
||||
* {@code publicKeyBase64} 验签。challenge 由设备本地生成(格式 {@code ts:nonce},
|
||||
* 用 TEE 私钥签名后上报)。验签通过即证明「该公钥的私钥持有者」发起了本次注册,
|
||||
* 从而绑定 SN ↔ TEE 私钥,防止攻击者用自己公钥冒名注册受害者 SN。</p>
|
||||
*
|
||||
* @param publicKeyBase64 请求上传的设备公钥
|
||||
* @param challenge 服务端下发的挑战值,格式 <timestampMillis>:<nonce>
|
||||
* @param challenge 设备本地生成的挑战值,格式 <timestampMillis>:<nonce>
|
||||
* @param signature 设备用 TEE 私钥对 challenge 的签名(Base64)
|
||||
*/
|
||||
public void verifyRegisterChallenge(String publicKeyBase64, String challenge, String signature) {
|
||||
@@ -359,57 +367,46 @@ public class DeviceBindingService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成一个用于设备状态查询认证的一次性挑战值。
|
||||
* 格式:<timestampMillis>:<nonce>
|
||||
* - timestampMillis 用于时效校验(CHALLENGE_VALID_WINDOW_MS 内有效)
|
||||
* - nonce 用于防重放(一次性消费)
|
||||
*/
|
||||
public String generateStatusChallenge() {
|
||||
String timestamp = String.valueOf(System.currentTimeMillis());
|
||||
String nonce = UUID.randomUUID().toString().replace("-", "");
|
||||
return timestamp + ":" + nonce;
|
||||
}
|
||||
// ==================== 8. Header 设备签名认证(方案 A,替代 Challenge-Response) ====================
|
||||
|
||||
/**
|
||||
* 校验设备对挑战值的签名是否合法。
|
||||
* 通过请求头承载的设备签名完成认证(零额外往返)。
|
||||
*
|
||||
* 安全逻辑:
|
||||
* - challenge 必须解析为 <timestamp>:<nonce>,且 timestamp 在有效期内
|
||||
* - nonce 必须是首次使用(一次性,防重放)
|
||||
* - 使用设备已注册公钥验签(RSA,对应设备 TEE 私钥签名)
|
||||
* <p>设备本地生成时间戳 {@code ts} 与随机 {@code nonce},用 TEE 私钥对
|
||||
* {@code canonical}(METHOD|path|ts|nonce)签名;本方法:</p>
|
||||
* <ol>
|
||||
* <li>校验 {@code ts} 在 {@link #CHALLENGE_VALID_WINDOW_MS} 时效窗口内;</li>
|
||||
* <li>校验 {@code nonce} 一次性(防重放,复用 status nonce 防重放池);</li>
|
||||
* <li>按 {@code sn} 查库取设备并校验 active;</li>
|
||||
* <li>用设备公钥对 {@code canonical} 验签。</li>
|
||||
* </ol>
|
||||
*
|
||||
* @return true 表示认证通过;否则抛出 SecurityException
|
||||
* @return 认证通过后的设备
|
||||
*/
|
||||
public boolean verifyStatusChallenge(String sn, String challenge, String signature) {
|
||||
if (sn == null || sn.isBlank() || challenge == null || challenge.isBlank()
|
||||
|| signature == null || signature.isBlank()) {
|
||||
throw new SecurityException("sn, challenge, signature required");
|
||||
public Device authenticateByHeader(String sn, String ts, String nonce, String sig, String canonical) {
|
||||
if (sn == null || sn.isBlank() || ts == null || nonce == null
|
||||
|| nonce.isBlank() || sig == null || sig.isBlank()) {
|
||||
throw new SecurityException("sn, ts, nonce, sig required");
|
||||
}
|
||||
|
||||
// 1. 解析并校验时效
|
||||
String[] parts = challenge.split(":", 2);
|
||||
if (parts.length != 2) {
|
||||
throw new SecurityException("malformed challenge");
|
||||
}
|
||||
// 1. 时效校验
|
||||
long issuedAt;
|
||||
try {
|
||||
issuedAt = Long.parseLong(parts[0]);
|
||||
issuedAt = Long.parseLong(ts);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new SecurityException("malformed challenge timestamp");
|
||||
throw new SecurityException("malformed ts");
|
||||
}
|
||||
long age = System.currentTimeMillis() - issuedAt;
|
||||
if (age < 0 || age > CHALLENGE_VALID_WINDOW_MS) {
|
||||
throw new SecurityException("challenge expired");
|
||||
throw new SecurityException("signature timestamp expired");
|
||||
}
|
||||
|
||||
// 2. 校验 nonce 一次性(防重放)
|
||||
String nonce = parts[1];
|
||||
// 2. nonce 一次性(防重放,与注册/状态共用同一防重放池)
|
||||
if (!usedStatusNonces.add(nonce)) {
|
||||
throw new SecurityException("challenge nonce already used (replay)");
|
||||
throw new SecurityException("nonce already used (replay)");
|
||||
}
|
||||
|
||||
// 3. 取出设备公钥并执行验签
|
||||
// 3. 查设备 + active 校验
|
||||
Device device = findDeviceBySn(sn);
|
||||
if (device == null) {
|
||||
throw new SecurityException("device not registered");
|
||||
@@ -417,16 +414,19 @@ public class DeviceBindingService {
|
||||
if (!device.isActive()) {
|
||||
throw new SecurityException("device not active");
|
||||
}
|
||||
|
||||
// 4. 验签(RSA-PSS,与设备端 signMetadata 参数一致)
|
||||
try {
|
||||
PublicKey pubKey = RsaUtil.publicKeyFromBase64(device.getPublicKeyBase64());
|
||||
if (!RsaUtil.verifySignature(challenge, signature, pubKey)) {
|
||||
if (!RsaUtil.verifySignature(canonical, sig, pubKey)) {
|
||||
throw new SecurityException("signature verification failed");
|
||||
}
|
||||
} catch (SecurityException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
if (e instanceof SecurityException) throw e;
|
||||
throw new SecurityException("signature verification error");
|
||||
}
|
||||
return true;
|
||||
return device;
|
||||
}
|
||||
|
||||
// ==================== 响应模型 ====================
|
||||
|
||||
@@ -39,7 +39,6 @@ app.upload.max-size-mb=20
|
||||
# 取值为 Base64 编码的 32 字节(AES-256)。生成示例:
|
||||
# python3 -c "import os,base64;print(base64.b64encode(os.urandom(32)).decode())"
|
||||
app.master-key=Vc1CDGX1M8TSXZ64NTTO5zj3VtcWV3/XPa9k0vsdQ0U=
|
||||
app.transport.private-key-file=transport_private_key.pem
|
||||
|
||||
# CORS 白名单(允许跨域访问的前端域名,逗号分隔)。
|
||||
# 不设置时默认只允许本地开发域名(localhost/127.0.0.1 常见端口)。
|
||||
|
||||
@@ -17,6 +17,8 @@ import java.security.KeyPair;
|
||||
import java.security.KeyPairGenerator;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.MGF1ParameterSpec;
|
||||
import java.security.spec.PSSParameterSpec;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
@@ -25,14 +27,18 @@ import java.util.Map;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* 端到端集成测试
|
||||
*
|
||||
* 端到端集成测试(方案 A:Header 设备签名认证,零 challenge 往返)。
|
||||
*
|
||||
* 模拟完整流程:
|
||||
* 1. 设备生成 TEE 密钥对(RSA-2048)→ 注册
|
||||
* 2. 用户绑定设备
|
||||
* 3. 设备拍照 → 信封加密 → 元数据签名 → 上传(服务端验签)
|
||||
* 4. 用户下载并解密照片
|
||||
* 5. 恢复出厂 → 新密钥对 → 短信验证 → 恢复 → Token 验证 → 取回 DEK
|
||||
* 1. 用户注册(Bearer Token)+ 设备生成 TEE 密钥对 → 注册(PoP,自签名 challenge)
|
||||
* 2. 用户绑定设备(设备签名 Header)
|
||||
* 3. 设备拍照 → 信封加密 → 元数据签名 → 上传(设备签名 Header)
|
||||
* 4. 用户下载并解密照片(Bearer Token)
|
||||
* 5. 恢复出厂 → 新密钥对 → 短信验证 → 恢复 → Token 验证 → 取回 DEK(设备签名 Header)
|
||||
*
|
||||
* 设备签名认证一律通过 {@link #buildDeviceAuthHeader} 生成
|
||||
* {@code Authorization: Device-Sig sn=...,ts=...,nonce=...,sig=...}(RSA-PSS,与服务端
|
||||
* {@code DeviceAuthInterceptor} / {@code RsaUtil.verifySignature} 完全一致)。
|
||||
*/
|
||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
||||
@TestPropertySource(locations = "classpath:application.properties")
|
||||
@@ -49,17 +55,14 @@ public class IntegrationTest {
|
||||
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
/** 生成 RSA-2048 密钥对(与 Android Keystore TEE 密钥一致,支持 OAEP 加密 + SHA256withRSA 签名) */
|
||||
/** 生成 RSA-2048 密钥对(与 Android Keystore TEE 密钥一致) */
|
||||
private KeyPair generateDeviceKeyPair() throws Exception {
|
||||
KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
|
||||
kpg.initialize(2048);
|
||||
return kpg.generateKeyPair();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从统一响应 ApiResponse{code,message,data} 中取出 data。
|
||||
* 前后端分离后所有接口都走该包装结构,断言 code==0 成功。
|
||||
*/
|
||||
/** 从统一响应 ApiResponse{code,message,data} 中取出 data,断言 code==0 成功 */
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> unwrapData(ResponseEntity<Map> resp) {
|
||||
assertEquals(HttpStatus.OK, resp.getStatusCode());
|
||||
@@ -72,30 +75,44 @@ public class IntegrationTest {
|
||||
return Base64.getEncoder().encodeToString(kp.getPublic().getEncoded());
|
||||
}
|
||||
|
||||
/** 设备用 TEE 私钥签名元数据(SHA256withRSA,对应 Android DeviceCrypto.signMetadata) */
|
||||
private String signMetadata(String metadata, PrivateKey privateKey) throws Exception {
|
||||
Signature sig = Signature.getInstance("SHA256withRSA");
|
||||
/** 设备用 TEE 私钥做 RSA-PSS 签名(与 Android DeviceCrypto.signMetadata / 服务端 RsaUtil 一致) */
|
||||
private String signPss(String data, PrivateKey privateKey) throws Exception {
|
||||
Signature sig = Signature.getInstance("RSASSA-PSS");
|
||||
sig.setParameter(new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1));
|
||||
sig.initSign(privateKey);
|
||||
sig.update(metadata.getBytes(StandardCharsets.UTF_8));
|
||||
sig.update(data.getBytes(StandardCharsets.UTF_8));
|
||||
return Base64.getEncoder().encodeToString(sig.sign());
|
||||
}
|
||||
|
||||
/**
|
||||
* 走 PoP 流程注册设备(修复 P0-1):
|
||||
* 1. GET /api/device/challenge 获取一次性挑战值
|
||||
* 2. 用设备 TEE 私钥对 challenge 签名
|
||||
* 3. POST /api/device/register 携带 {sn, publicKeyBase64, challenge, signature}
|
||||
* 生成设备签名认证请求头(方案 A):本地生成 ts:nonce,TEE 私钥对
|
||||
* {@code METHOD|path|ts|nonce} 做 RSA-PSS 签名。
|
||||
*/
|
||||
private ResponseEntity<Map> registerWithPop(String sn, String publicKeyBase64, PrivateKey privateKey) throws Exception {
|
||||
// 1. 取挑战值
|
||||
ResponseEntity<Map> chResp = restTemplate.getForEntity("/api/device/challenge", Map.class);
|
||||
Map<String, Object> chData = unwrapData(chResp);
|
||||
String challenge = (String) chData.get("challenge");
|
||||
private HttpHeaders buildDeviceAuthHeader(String sn, KeyPair kp, String method, String path) throws Exception {
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
String nonce = "nonce-" + System.nanoTime();
|
||||
String canonical = method + "|" + path + "|" + ts + "|" + nonce;
|
||||
String sig = signPss(canonical, kp.getPrivate());
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.APPLICATION_JSON);
|
||||
headers.set("Authorization",
|
||||
"Device-Sig sn=" + sn + ",ts=" + ts + ",nonce=" + nonce + ",sig=" + sig);
|
||||
return headers;
|
||||
}
|
||||
|
||||
// 2. 用 TEE 私钥签名 challenge(PoP)
|
||||
String signature = signMetadata(challenge, privateKey);
|
||||
/** 发送带设备签名认证头的 POST 请求 */
|
||||
private ResponseEntity<Map> devicePost(String url, String sn, KeyPair kp, Object body) throws Exception {
|
||||
HttpEntity<?> entity = new HttpEntity<>(body, buildDeviceAuthHeader(sn, kp, "POST", url));
|
||||
return restTemplate.exchange(url, HttpMethod.POST, entity, Map.class);
|
||||
}
|
||||
|
||||
/** 设备注册(PoP,自签名 challenge,challenge 格式 ts:nonce) */
|
||||
private ResponseEntity<Map> registerWithPop(String sn, String publicKeyBase64, KeyPair kp) throws Exception {
|
||||
String ts = String.valueOf(System.currentTimeMillis());
|
||||
String nonce = "nonce-" + System.nanoTime();
|
||||
String challenge = ts + ":" + nonce;
|
||||
String signature = signPss(challenge, kp.getPrivate());
|
||||
|
||||
// 3. 注册
|
||||
Map<String, String> req = new HashMap<>();
|
||||
req.put("sn", sn);
|
||||
req.put("publicKeyBase64", publicKeyBase64);
|
||||
@@ -109,42 +126,54 @@ public class IntegrationTest {
|
||||
@Test
|
||||
public void testFullFlow_DeviceRegister_Bind_Upload_Recover() throws Exception {
|
||||
|
||||
// ===== 准备:生成设备密钥对(模拟 Android Keystore) =====
|
||||
// ===== 准备:用户注册(Bearer Token)+ 生成设备密钥对 =====
|
||||
// 每次运行使用唯一 ID,避免测试间/重复运行对共享 MySQL 的污染
|
||||
String runId = String.valueOf(System.currentTimeMillis());
|
||||
String sn = "SN-TEST-" + runId;
|
||||
String userId = "user-" + runId;
|
||||
String phone = "138" + (runId.substring(runId.length() - 8));
|
||||
String password = "123456";
|
||||
|
||||
// 注册用户 → 获取用户 Bearer Token(用于 Web 用户端接口)
|
||||
Map<String, String> userReg = new HashMap<>();
|
||||
userReg.put("userId", userId);
|
||||
userReg.put("phone", phone);
|
||||
userReg.put("password", password);
|
||||
ResponseEntity<Map> userResp = restTemplate.postForEntity("/api/auth/register", userReg, Map.class);
|
||||
Map<String, Object> userData = unwrapData(userResp);
|
||||
String userToken = (String) userData.get("token");
|
||||
assertNotNull(userToken);
|
||||
System.out.println("[Prep] User registered, bearer token obtained");
|
||||
|
||||
KeyPair deviceKeyPair = generateDeviceKeyPair();
|
||||
String publicKeyBase64 = pubKeyToBase64(deviceKeyPair);
|
||||
String sn = "SN-TEST-001";
|
||||
String userId = "user-001";
|
||||
String phone = "13800138000";
|
||||
|
||||
// ===== Step 1: 设备注册(PoP 验签) =====
|
||||
ResponseEntity<Map> resp = registerWithPop(sn, publicKeyBase64, deviceKeyPair.getPrivate());
|
||||
ResponseEntity<Map> resp = registerWithPop(sn, publicKeyBase64, deviceKeyPair);
|
||||
Map<String, Object> data = unwrapData(resp);
|
||||
String deviceId = (String) data.get("deviceId");
|
||||
assertNotNull(deviceId);
|
||||
System.out.println("[Step 1] Device registered (PoP): " + deviceId);
|
||||
|
||||
// ===== Step 2: 用户绑定设备 =====
|
||||
// ===== Step 2: 用户绑定设备(设备签名 Header) =====
|
||||
Map<String, String> bindReq = new HashMap<>();
|
||||
bindReq.put("userId", userId);
|
||||
bindReq.put("sn", sn);
|
||||
bindReq.put("phone", phone);
|
||||
resp = devicePost("/api/device/bind", sn, deviceKeyPair, bindReq);
|
||||
unwrapData(resp);
|
||||
System.out.println("[Step 2] Device bound to user (device signature): " + userId);
|
||||
|
||||
resp = restTemplate.postForEntity("/api/device/bind", bindReq, Map.class);
|
||||
assertEquals(HttpStatus.OK, resp.getStatusCode());
|
||||
System.out.println("[Step 2] Device bound to user: " + userId);
|
||||
|
||||
// ===== Step 3: 设备拍照并信封加密 =====
|
||||
// ===== Step 3: 设备拍照并信封加密上传 =====
|
||||
byte[] photoBytes = "This is a secret photo taken by the device".getBytes();
|
||||
|
||||
// 3a. 生成随机 DEK
|
||||
SecretKey dek = AesGcmUtil.generateKey();
|
||||
|
||||
// 3b. AES-GCM 加密照片
|
||||
AesGcmUtil.EncryptedResult encResult = AesGcmUtil.encrypt(photoBytes, dek);
|
||||
|
||||
// 3c. 设备 TEE 私钥签名元数据(服务端将用设备公钥验签)
|
||||
|
||||
String metadata = sn + "|" + System.currentTimeMillis() + "|photo-001";
|
||||
String metadataSignature = signMetadata(metadata, deviceKeyPair.getPrivate());
|
||||
String metadataSignature = signPss(metadata, deviceKeyPair.getPrivate());
|
||||
// 本地自检:确保元数据签名可被同一公钥验签通过(排除测试侧 PSS 参数问题)
|
||||
assertTrue(RsaUtil.verifySignature(metadata, metadataSignature, deviceKeyPair.getPublic()),
|
||||
"metadata signature should verify locally with device public key");
|
||||
|
||||
Map<String, String> uploadReq = new HashMap<>();
|
||||
uploadReq.put("sn", sn);
|
||||
@@ -155,26 +184,26 @@ public class IntegrationTest {
|
||||
uploadReq.put("metadataSignature", metadataSignature);
|
||||
uploadReq.put("metadata", metadata);
|
||||
|
||||
resp = restTemplate.postForEntity("/api/photo/upload", uploadReq, Map.class);
|
||||
assertEquals(HttpStatus.OK, resp.getStatusCode(), "服务端应验签通过");
|
||||
resp = devicePost("/api/photo/upload", sn, deviceKeyPair, uploadReq);
|
||||
unwrapData(resp);
|
||||
System.out.println("[Step 3] Encrypted photo uploaded (server verified signature)");
|
||||
|
||||
// 3d. 篡改元数据签名应被拒绝(验签生效验证)
|
||||
Map<String, String> tamperedReq = new HashMap<>(uploadReq);
|
||||
tamperedReq.put("metadata", sn + "|" + System.currentTimeMillis() + "|photo-tampered");
|
||||
resp = restTemplate.postForEntity("/api/photo/upload", tamperedReq, Map.class);
|
||||
HttpEntity<?> tamperedEntity = new HttpEntity<>(tamperedReq,
|
||||
buildDeviceAuthHeader(sn, deviceKeyPair, "POST", "/api/photo/upload"));
|
||||
resp = restTemplate.exchange("/api/photo/upload", HttpMethod.POST, tamperedEntity, Map.class);
|
||||
assertEquals(HttpStatus.FORBIDDEN, resp.getStatusCode(), "签名不匹配的请求应被拒绝");
|
||||
System.out.println("[Step 3-tamper] Tampered request rejected as expected");
|
||||
|
||||
// ===== Step 4: 用户下载并解密照片 =====
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.set("X-User-Id", userId);
|
||||
HttpEntity<?> entity = new HttpEntity<>(headers);
|
||||
|
||||
// ===== Step 4: 用户下载并解密照片(Bearer Token) =====
|
||||
HttpHeaders bearerHeaders = new HttpHeaders();
|
||||
bearerHeaders.set("Authorization", "Bearer " + userToken);
|
||||
ResponseEntity<Map> photoResp = restTemplate.exchange(
|
||||
"/api/photo/photo-001/decrypt", HttpMethod.GET, entity, Map.class);
|
||||
"/api/photo/photo-001/decrypt", HttpMethod.GET,
|
||||
new HttpEntity<>(bearerHeaders), Map.class);
|
||||
Map<String, Object> photoData = unwrapData(photoResp);
|
||||
|
||||
String plaintextBase64 = (String) photoData.get("plaintextBase64");
|
||||
byte[] decryptedPhoto = Base64.getDecoder().decode(plaintextBase64);
|
||||
assertArrayEquals(photoBytes, decryptedPhoto);
|
||||
@@ -188,30 +217,25 @@ public class IntegrationTest {
|
||||
String newPublicKeyBase64 = pubKeyToBase64(newDeviceKeyPair);
|
||||
|
||||
// 5b. 新设备注册(PoP 验签;同 SN → 旧设备自动停用)
|
||||
resp = registerWithPop(sn, newPublicKeyBase64, newDeviceKeyPair.getPrivate());
|
||||
resp = registerWithPop(sn, newPublicKeyBase64, newDeviceKeyPair);
|
||||
data = unwrapData(resp);
|
||||
String newDeviceId = (String) data.get("deviceId");
|
||||
assertNotEquals(deviceId, newDeviceId);
|
||||
System.out.println("[Step 5a] New device registered after factory reset (PoP): " + newDeviceId);
|
||||
|
||||
// 5c. 发送短信验证码(demo 固定 000000;用户端接口需登录态)
|
||||
// 5c. 发送短信验证码(设备签名 Header;demo 固定 000000)
|
||||
Map<String, String> smsReq = new HashMap<>();
|
||||
smsReq.put("phone", phone);
|
||||
HttpHeaders smsHeaders = new HttpHeaders();
|
||||
smsHeaders.set("X-User-Id", userId);
|
||||
resp = restTemplate.exchange("/api/device/sms/send", HttpMethod.POST,
|
||||
new HttpEntity<>(smsReq, smsHeaders), Map.class);
|
||||
assertEquals(HttpStatus.OK, resp.getStatusCode());
|
||||
resp = devicePost("/api/device/sms/send", sn, newDeviceKeyPair, smsReq);
|
||||
unwrapData(resp);
|
||||
System.out.println("[Step 5b] SMS code sent (demo code: 000000)");
|
||||
|
||||
// 5d. 恢复设备(短信 + SN 归属双因子)
|
||||
// 5d. 恢复设备(短信 + SN 归属双因子,设备签名 Header)
|
||||
Map<String, String> recoverReq = new HashMap<>();
|
||||
recoverReq.put("userId", userId);
|
||||
recoverReq.put("sn", sn);
|
||||
recoverReq.put("smsCode", "000000");
|
||||
recoverReq.put("newPublicKeyBase64", newPublicKeyBase64);
|
||||
|
||||
resp = restTemplate.postForEntity("/api/device/recover", recoverReq, Map.class);
|
||||
resp = devicePost("/api/device/recover", sn, newDeviceKeyPair, recoverReq);
|
||||
data = unwrapData(resp);
|
||||
String encryptedToken = (String) data.get("encryptedRecoveryToken");
|
||||
assertNotNull(encryptedToken);
|
||||
@@ -226,7 +250,7 @@ public class IntegrationTest {
|
||||
photoRecoverReq.put("deviceId", newDeviceId);
|
||||
photoRecoverReq.put("recoveryToken", recoveryToken);
|
||||
photoRecoverReq.put("userId", userId);
|
||||
resp = restTemplate.postForEntity("/api/photo/recover", photoRecoverReq, Map.class);
|
||||
resp = devicePost("/api/photo/recover", sn, newDeviceKeyPair, photoRecoverReq);
|
||||
data = unwrapData(resp);
|
||||
assertEquals(1, ((Number) data.get("photoCount")).intValue());
|
||||
System.out.println("[Step 5d] DEK list fetched with validated token");
|
||||
@@ -257,21 +281,26 @@ public class IntegrationTest {
|
||||
*/
|
||||
@Test
|
||||
public void testRecoveryServiceDirectly() throws Exception {
|
||||
// 每次运行使用唯一 ID,避免测试间/重复运行对共享 MySQL 的污染
|
||||
String runId = String.valueOf(System.currentTimeMillis());
|
||||
String userId = "user-" + runId;
|
||||
String phone = "139" + (runId.substring(runId.length() - 8));
|
||||
String sn = "SN-TEST-" + runId;
|
||||
// 准备用户
|
||||
keyManagementService.registerUser("user-002", "13900139000");
|
||||
keyManagementService.registerUser(userId, phone);
|
||||
|
||||
// 准备设备(注册 + 绑定 + 发短信,模拟真实时序)
|
||||
KeyPair kp0 = generateDeviceKeyPair();
|
||||
deviceBindingService.registerDevice("SN-TEST-002", pubKeyToBase64(kp0));
|
||||
deviceBindingService.bindDeviceToUser("user-002", "SN-TEST-002");
|
||||
deviceBindingService.sendSmsCode("13900139000");
|
||||
deviceBindingService.registerDevice(sn, pubKeyToBase64(kp0));
|
||||
deviceBindingService.bindDeviceToUser(userId, sn);
|
||||
deviceBindingService.sendSmsCode(phone);
|
||||
|
||||
// 恢复(恢复出厂 → 新密钥对)
|
||||
KeyPair kp1 = generateDeviceKeyPair();
|
||||
DeviceBindingService.RecoveryResponse recoveryResp =
|
||||
deviceBindingService.recoverDevice(
|
||||
"user-002",
|
||||
"SN-TEST-002",
|
||||
userId,
|
||||
sn,
|
||||
"000000", // demo 固定验证码
|
||||
pubKeyToBase64(kp1)
|
||||
);
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIG/QIBADANBgkqhkiG9w0BAQEFAASCBucwggbjAgEAAoIBgQDFB90sbvZvOTMH
|
||||
C0c09qiOYMTg8HcKyzTrIOOuFSvZIr7wKpiSb0KQG25PT/62UZCSlp/Ib9qfGOcl
|
||||
CW14N1prLMwYFvLGHKLitOzVGHZDTUiEDDvGmtedNFKBWqllG4XIfvNkLfFPkjst
|
||||
FssZMunU/cKLCdbwa5vW2TjS6W4iQ0ISiXFGaVObvkOh8/fMMIPxWTBeck604n7o
|
||||
pWVptAZfnf3cZvRe7qfzO80h12UCmOmntVPP5j0fLl/5Zi+DUlQExGD6JrgtXo3q
|
||||
KhZub2ZA1HhXhSn2f9haddU0rmPRj5sHzyHRtTev4LrAnHO63CSaegSbP6yGF57i
|
||||
IlfwTHw6htYsBMLiiL5ZdqhKrE1ZQa63WnbplWuFrvgDb3yuJSc2GwtkojCh/nH5
|
||||
8AN3ajhch3PxbUUOpKKhSQfG9cChxrdf6mjz8p+Pvo88kczoyYOyR9kdLTlKA0Bk
|
||||
HVZ0W5Uxcuzl1qbFqxXgKhqmDfZLGxd5VeVYjVpFFHgAGjEkQJMCAwEAAQKCAYAq
|
||||
GH4GqkZ4iO4ACUbTaAenM8GclYO5iKTrv4Elhlxx7dyBj3g9gQvloha1V1ACP/b1
|
||||
erz0pAE/kKCB5zu+PYVR9KY+V1jTPvcGHMWk2a8avf5KSBrVWevLKIygGnCqq3Cv
|
||||
33+83Zv69jEydvY5kgknengAIMANadBH3O0pErp3E4ugTkEnXAWC9umYRnmg5JSs
|
||||
EfQlmaJ7PxECP6QlK1NZRnYgitaXGcJEU3iGTHDGV0lEjZc0iepxKQVUUaLBGet9
|
||||
WUNQ7zfJAWSAcwCHlsoIj+Gp+mizCzqOkv23VSZer/YR0VvXNCce4/I/LAGVmOxW
|
||||
b3U7myZ5JZs9d+kfCnVSLYSEq9nMrWEzhPIc4VeXkbwYe/qQ1wTM+7uEeF+oY6Pg
|
||||
1SmwWk4HBGL/EfKn+oGS7BPIULE28zkbY4QLi5ZjFtRZk4hKSDzMrABVxDhQECKq
|
||||
TcDSQ0aJAGdnVHybSeYS5nJ7F7l5PYytN+gWxmouJOS+Pcx92nsiXUOgdB5SpZEC
|
||||
gcEA7xhKYsdo03IenaG3E/ljFn6xtfvhSOxPm5+HPHtJLOmJLM+IAX918/kWLHg3
|
||||
UjG/19MgVPKktJVGvq3nieXNLBo0OszuDeBjXrMgU1ni7lGpcJRTmESxRCtrc3J1
|
||||
1FPgzEIUHZa9IunL7ba50UPrELFCJ2zy5sqZHmhq3/j45LoZIERQB1l87LdeD0F0
|
||||
igbJSb2630QqntM2JYPBQFtpAQ8DCJmQQhMyl5vjQ/+GbhwWPsNyqcuM8ZhIC7MW
|
||||
6PYdAoHBANL2Mh8+GD3ty/Xqm6Pl/CgRy5Hr0UNHl+yNmwuQWnRD/D+2VBI/qQhh
|
||||
wNLrzOz9SlWwON6OOgSzTKMWH8EOcWTjWa8ZgmabvgVdhKRKQLcwv7k5jurBNjce
|
||||
i0doDb93UTp5qjRuwg2SU5B3ay571qY8F3RICS0SmqGeQW5KAmDX+Ru01gLuNIBG
|
||||
ivzj2hC4Xdnsde1jy6NFrgeIge/U/+SK3ivjhuah6JJMhQAo0f8qdz5mz6PqaLeP
|
||||
PQzxfcmSbwKBwDMcpDo9msEo8jaMbZDNjUsvxlm7AMwQCGyiS8y4Jkp9mh+ENfTs
|
||||
BJElPIJBKMJfdD11GsJOJLud9cOpdYfbImM9LtErIfDBeTyzWkO3QXXk6y3v53bz
|
||||
qFmEVrIVU+8SB0pjDd3NbZ1bEYc9urdrp4KoAhZfigWgZd9EPySmGr76sYheUiVg
|
||||
Ef6grHDic0FWdg1Xi+1SqzHMwRR/9/4EDIx3YxShj18wr24NmyXcKCa9xlugeJCn
|
||||
vPegsDYgENO4WQKBwHBrWhJkGK8HxaTqvL3+lP0VXpIIRJ/Byyf33iOvbUR/5jBd
|
||||
jTecTQt2bDb6CV5RLAe1vNh8mlZe5fwSkiFi/PJyZRx2T5M2c3CQgVq7Zvk4NTMT
|
||||
hSF8jNOap0YKISljABpVM2p1i1uIGpfly2wd+ijj5OvGZ31paJWvq9aGAfZxoQIu
|
||||
v80X+0pQTUiuc0pttTWoWL+EasQ7IZ5KFFQmAadciUCCIyVMKo+rz0RifGWpz5ml
|
||||
WAlVpTAMWNBI8Gs2aQKBwQCZU2yMbsPiqegxY6kKCEoT5u9ca36qwVLKoz8vqr07
|
||||
EfxhEitfvFVeq8e0JTZRr20cyK8ZUTmNG14EoOd488lwIjx93a8HUY0lUH1EHJxp
|
||||
T/J8c9TwVroGxVlZYbgL1dcGBLNMYn3IAKVAV/qr7RlI9hVUKIUV34U+ms8je+oq
|
||||
sP5N++dw5NDpAXoQ9s5p9rn8XZwqemL+5auC1j191dzp1KoRyteEHNViZ0oCawFi
|
||||
C4e53XOJVeLZwdS/FJIb8ks=
|
||||
-----END PRIVATE KEY-----
|
||||
Reference in New Issue
Block a user