fix(device): 设备注册绑定接口添加 PoP 验签,照片下载改为设备端本地解密
This commit is contained in:
@@ -47,6 +47,12 @@
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 仅引入 Spring Security Crypto 的 BCrypt(不引入整个 Spring Security,不影响现有鉴权) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-crypto</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- 测试(JUnit 5 + TestRestTemplate) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
-- ============================================================
|
||||
-- 安全设备 Demo - 数据库初始化脚本
|
||||
-- 库名: secure_device (与 application.properties 中配置一致)
|
||||
-- 说明: 存储照片元数据、设备、用户、用户照片索引
|
||||
-- 说明: 存储照片元数据、设备、用户
|
||||
-- 密文本身落盘于 uploads/ 目录
|
||||
-- 服务端永远不存明文照片和明文 DEK
|
||||
-- ============================================================
|
||||
@@ -46,23 +46,12 @@ CREATE TABLE IF NOT EXISTS `device` (
|
||||
CREATE TABLE IF NOT EXISTS `app_user` (
|
||||
`user_id` VARCHAR(64) NOT NULL COMMENT '用户 ID (PK)',
|
||||
`phone` VARCHAR(32) DEFAULT NULL COMMENT '手机号(用于短信验证)',
|
||||
`uk_encrypted_base64` TEXT DEFAULT NULL COMMENT '用户主密钥 UK (Base64)',
|
||||
`password` VARCHAR(128) DEFAULT NULL COMMENT '登录密码 (Demo 明文,生产用 BCrypt)',
|
||||
`uk_encrypted_base64` TEXT DEFAULT NULL COMMENT '用户主密钥 UK(经 SMK-AES-256-GCM 信封加密,格式 ivBase64:ciphertextBase64,非明文)',
|
||||
`password` VARCHAR(128) DEFAULT NULL COMMENT '登录密码 (BCrypt 哈希,60 字符,绝不明文)',
|
||||
`phone_verified` VARCHAR(8) DEFAULT NULL COMMENT '手机号是否已验证',
|
||||
PRIMARY KEY (`user_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='用户与主密钥表';
|
||||
|
||||
-- 5. 用户-照片关联表(对应 UserPhoto 实体)
|
||||
CREATE TABLE IF NOT EXISTS `user_photo` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '自增主键',
|
||||
`user_id` VARCHAR(64) NOT NULL COMMENT '用户 ID',
|
||||
`photo_id` VARCHAR(64) NOT NULL COMMENT '照片 ID',
|
||||
`created_time` BIGINT DEFAULT NULL COMMENT '创建时间戳 (ms)',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_user_photo_user` (`user_id`),
|
||||
KEY `idx_user_photo_photo` (`photo_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='用户照片索引表';
|
||||
|
||||
-- ============================================================
|
||||
-- 已有数据库迁移(仅当旧库的 device 表带 SN 唯一索引时手动执行)
|
||||
-- 说明:早期版本 device.sn 建为 UNIQUE 索引,导致「恢复出厂重新注册」时
|
||||
@@ -72,3 +61,25 @@ CREATE TABLE IF NOT EXISTS `user_photo` (
|
||||
-- ============================================================
|
||||
-- ALTER TABLE `device` DROP INDEX `idx_device_sn`;
|
||||
-- ALTER TABLE `device` ADD INDEX `idx_device_sn` (`sn`);
|
||||
|
||||
-- ============================================================
|
||||
-- 迁移:P0-1 修复(UK 明文落库 -> SMK 信封加密)
|
||||
-- ============================================================
|
||||
-- 列结构无需变更(uk_encrypted_base64 本就是 TEXT)。
|
||||
-- 已有旧数据(明文 UK,Base64 且不含冒号)需重新用 SMK 包裹。
|
||||
-- 纯 SQL 无法完成 AES-256-GCM(密钥来自环境变量 APP_MASTER_KEY),
|
||||
-- 因此由应用侧一次性 Runner 完成:
|
||||
--
|
||||
-- 设置环境变量后启动应用一次:
|
||||
-- export APP_MASTER_KEY=<Base64 的 32 字节>
|
||||
-- export APP_RUN_MIGRATION=true
|
||||
-- # 启动 Spring Boot,Runner 自动把明文 UK 重新包裹
|
||||
-- # 成功后关闭:unset APP_RUN_MIGRATION
|
||||
--
|
||||
-- 若数据库为演示数据、可整体重建,则无需迁移,直接:
|
||||
-- TRUNCATE TABLE `app_user`;
|
||||
-- TRUNCATE TABLE `encrypted_photo`;
|
||||
-- TRUNCATE TABLE `device`;
|
||||
-- 然后重新走注册流程即可(新账号 UK 默认即用 SMK 加密)。
|
||||
-- 注:早期版本存在 user_photo 关联表(已废弃),照片归属统一由 encrypted_photo.user_id 承担。
|
||||
-- ============================================================
|
||||
|
||||
@@ -4,6 +4,7 @@ import com.secure.demo.common.ApiResponse;
|
||||
import com.secure.demo.common.UnauthorizedException;
|
||||
import com.secure.demo.model.User;
|
||||
import com.secure.demo.service.KeyManagementService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
@@ -20,9 +21,11 @@ import java.util.Map;
|
||||
* {@code Authorization: Bearer <token>}
|
||||
* - 设备端(Android)无登录体系,不调用本接口
|
||||
*
|
||||
* 安全说明(Demo 简化):
|
||||
* - 密码明文存数据库(app_user.password),仅用于演示流程
|
||||
* - 生产环境应使用 Spring Security + BCrypt + JWT/Redis 会话
|
||||
* 安全说明:
|
||||
* - 密码以 BCrypt 哈希存储(spring-security-crypto),绝不明文落库
|
||||
* - 登录接口带限流(LoginRateLimiter)防暴力破解,且失败信息统一为「Invalid credentials」
|
||||
* 不区分「用户不存在 / 密码错误」,防用户枚举
|
||||
* - Token 为内存态会话(单实例),生产可平滑迁移到 Redis 分布式会话
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@@ -30,10 +33,14 @@ public class AuthController {
|
||||
|
||||
private final KeyManagementService keyManagementService;
|
||||
private final TokenService tokenService;
|
||||
private final LoginRateLimiter loginRateLimiter;
|
||||
|
||||
public AuthController(KeyManagementService keyManagementService, TokenService tokenService) {
|
||||
public AuthController(KeyManagementService keyManagementService,
|
||||
TokenService tokenService,
|
||||
LoginRateLimiter loginRateLimiter) {
|
||||
this.keyManagementService = keyManagementService;
|
||||
this.tokenService = tokenService;
|
||||
this.loginRateLimiter = loginRateLimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,22 +82,39 @@ public class AuthController {
|
||||
* Response: data = { "token": "...", "userId": "alice" }
|
||||
*/
|
||||
@PostMapping("/login")
|
||||
public ApiResponse<Map<String, String>> login(@RequestBody Map<String, String> req) {
|
||||
public ApiResponse<Map<String, String>> login(@RequestBody Map<String, String> req,
|
||||
HttpServletRequest httpRequest) {
|
||||
String userId = req.get("userId");
|
||||
String password = req.get("password");
|
||||
|
||||
if (userId == null || userId.isBlank() || password == null) {
|
||||
throw new IllegalArgumentException("userId and password required");
|
||||
}
|
||||
User user = keyManagementService.getUser(userId);
|
||||
if (user == null) {
|
||||
throw new UnauthorizedException("User not found: " + userId);
|
||||
}
|
||||
String storedPassword = user.getPassword() != null ? user.getPassword() : "123456";
|
||||
if (!password.equals(storedPassword)) {
|
||||
throw new UnauthorizedException("Invalid password");
|
||||
|
||||
// 限流 key = userId + 客户端 IP,防止针对单个账号或单个 IP 的爆破
|
||||
String clientIp = resolveClientIp(httpRequest);
|
||||
String limiterKey = userId + "|" + clientIp;
|
||||
|
||||
// 1. 先检查是否处于锁定(冷却)状态
|
||||
long lockedFor = loginRateLimiter.isLocked(limiterKey);
|
||||
if (lockedFor > 0) {
|
||||
throw new UnauthorizedException("Too many login attempts. Please retry in "
|
||||
+ Math.max(1, lockedFor / 1000) + "s");
|
||||
}
|
||||
|
||||
// 2. 校验凭证;失败统一提示(不区分用户不存在/密码错误,防用户枚举)
|
||||
User user = keyManagementService.getUser(userId);
|
||||
boolean ok = user != null && keyManagementService.verifyPassword(userId, password);
|
||||
if (!ok) {
|
||||
boolean locked = loginRateLimiter.recordFailure(limiterKey);
|
||||
if (locked) {
|
||||
throw new UnauthorizedException("Too many login attempts. Account temporarily locked");
|
||||
}
|
||||
throw new UnauthorizedException("Invalid credentials");
|
||||
}
|
||||
|
||||
// 3. 成功:重置计数并发 token
|
||||
loginRateLimiter.reset(limiterKey);
|
||||
String token = tokenService.createToken(userId);
|
||||
return ApiResponse.ok(Map.of(
|
||||
"token", token,
|
||||
@@ -98,6 +122,19 @@ public class AuthController {
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析客户端 IP(兼容 X-Forwarded-For 代理场景,取最左非空值;生产可按需用网关统一设置)。
|
||||
*/
|
||||
private String resolveClientIp(HttpServletRequest request) {
|
||||
String xff = request.getHeader("X-Forwarded-For");
|
||||
if (xff != null && !xff.isBlank()) {
|
||||
int comma = xff.indexOf(',');
|
||||
return (comma > 0 ? xff.substring(0, comma) : xff).trim();
|
||||
}
|
||||
String remote = request.getRemoteAddr();
|
||||
return (remote == null || remote.isBlank()) ? "unknown" : remote;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出(注销 Token)
|
||||
*
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.secure.demo.auth;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
|
||||
/**
|
||||
* 登录限流器(内存实现,单实例)。
|
||||
*
|
||||
* <p>防止暴力破解口令 / 用户枚举:对同一 {@code key}(通常 = userId + 客户端 IP)在时间窗口内
|
||||
* 统计失败次数,超过阈值后进入冷却期(锁定),冷却期内一律拒绝登录。</p>
|
||||
*
|
||||
* <p>生产多实例部署时,应替换为 Redis 计数器(原子自增 + 过期)。当前内存实现适合单实例 / 联调。</p>
|
||||
*
|
||||
* 可配置:
|
||||
* <ul>
|
||||
* <li>{@code app.auth.max-login-attempts}(默认 5):时间窗口内最大允许失败次数</li>
|
||||
* <li>{@code app.auth.lockout-seconds}(默认 300):达到阈值后的锁定冷却秒数</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Component
|
||||
public class LoginRateLimiter {
|
||||
|
||||
private static final long MS_PER_SECOND = 1000L;
|
||||
|
||||
private final int maxAttempts;
|
||||
private final long lockoutMillis;
|
||||
|
||||
/**
|
||||
* 记录:失败次数与首次失败时间戳(用于判断是否过期重置)。
|
||||
*/
|
||||
private static final class Bucket {
|
||||
final long firstFailureAt;
|
||||
int failures;
|
||||
|
||||
Bucket(long firstFailureAt, int failures) {
|
||||
this.firstFailureAt = firstFailureAt;
|
||||
this.failures = failures;
|
||||
}
|
||||
}
|
||||
|
||||
private final ConcurrentMap<String, Bucket> buckets = new ConcurrentHashMap<>();
|
||||
|
||||
public LoginRateLimiter(
|
||||
@Value("${app.auth.max-login-attempts:5}") int maxAttempts,
|
||||
@Value("${app.auth.lockout-seconds:300}") int lockoutSeconds) {
|
||||
this.maxAttempts = Math.max(1, maxAttempts);
|
||||
this.lockoutMillis = Math.max(1, lockoutSeconds) * MS_PER_SECOND;
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断指定 key 当前是否处于锁定(冷却)状态。
|
||||
*
|
||||
* @return 已锁定返回剩余冷却毫秒数(>0),未锁定返回 0
|
||||
*/
|
||||
public long isLocked(String key) {
|
||||
Bucket b = buckets.get(key);
|
||||
if (b == null) {
|
||||
return 0;
|
||||
}
|
||||
if (b.failures >= maxAttempts) {
|
||||
long remaining = b.firstFailureAt + lockoutMillis - System.currentTimeMillis();
|
||||
if (remaining > 0) {
|
||||
return remaining;
|
||||
}
|
||||
// 冷却期已过,清理并放行
|
||||
buckets.remove(key);
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录一次登录失败。返回当前是否已达到锁定状态。
|
||||
*/
|
||||
public boolean recordFailure(String key) {
|
||||
long now = System.currentTimeMillis();
|
||||
buckets.compute(key, (k, b) -> {
|
||||
if (b == null) {
|
||||
return new Bucket(now, 1);
|
||||
}
|
||||
// 若距首次失败已超过一个冷却窗口,则重置为新窗口
|
||||
if (now - b.firstFailureAt > lockoutMillis) {
|
||||
return new Bucket(now, 1);
|
||||
}
|
||||
b.failures++;
|
||||
return b;
|
||||
});
|
||||
Bucket b = buckets.get(key);
|
||||
return b != null && b.failures >= maxAttempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录成功后重置该 key 的计数。
|
||||
*/
|
||||
public void reset(String key) {
|
||||
buckets.remove(key);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.secure.demo.common;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||
@@ -8,42 +10,66 @@ import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||
/**
|
||||
* 全局异常处理:所有异常统一收敛为 ApiResponse 格式,
|
||||
* 前端(Web 用户端 / Android 设备端)只解析 code/message/data 三种字段。
|
||||
*
|
||||
* <p>安全原则(异常脱敏):</p>
|
||||
* <ul>
|
||||
* <li><strong>业务异常</strong>({@link ApiException} / {@link UnauthorizedException} /
|
||||
* {@link SecurityException} / {@link IllegalArgumentException})的消息是代码有意设计的
|
||||
* 安全提示,可原样返回给客户端;</li>
|
||||
* <li><strong>未捕获异常</strong>({@link Exception})<em>绝不回显内部细节</em>(堆栈、路径、
|
||||
* SQL、密钥等),统一返回通用提示,并把完整异常写入服务端日志便于排障。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestControllerAdvice
|
||||
public class GlobalExceptionHandler {
|
||||
|
||||
/** 业务异常:状态码由异常自带(400/401/403/409/500 等) */
|
||||
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||
|
||||
/** 未捕获异常对外统一的脱敏提示 */
|
||||
private static final String GENERIC_ERROR_MESSAGE = "Internal server error. Please try again later.";
|
||||
|
||||
/** 业务异常:状态码由异常自带(400/401/403/409/500 等),消息为安全设计提示 */
|
||||
@ExceptionHandler(ApiException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleApiException(ApiException e) {
|
||||
return ResponseEntity.status(e.getStatus())
|
||||
.body(ApiResponse.fail(e.getStatus(), e.getMessage()));
|
||||
.body(ApiResponse.fail(e.getStatus(), safeMessage(e.getMessage())));
|
||||
}
|
||||
|
||||
/** 未登录 / 凭证无效 */
|
||||
@ExceptionHandler(UnauthorizedException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleUnauthorized(UnauthorizedException e) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
|
||||
.body(ApiResponse.fail(401, e.getMessage()));
|
||||
.body(ApiResponse.fail(401, safeMessage(e.getMessage())));
|
||||
}
|
||||
|
||||
/** 无权限 / 校验失败 */
|
||||
@ExceptionHandler(SecurityException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleSecurity(SecurityException e) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN)
|
||||
.body(ApiResponse.fail(403, e.getMessage()));
|
||||
.body(ApiResponse.fail(403, safeMessage(e.getMessage())));
|
||||
}
|
||||
|
||||
/** 参数错误 */
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleIllegalArgument(IllegalArgumentException e) {
|
||||
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
|
||||
.body(ApiResponse.fail(400, e.getMessage()));
|
||||
.body(ApiResponse.fail(400, safeMessage(e.getMessage())));
|
||||
}
|
||||
|
||||
/** 其余未捕获异常 */
|
||||
/** 其余未捕获异常:内部细节脱敏,只记录日志,不向客户端回显 */
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ResponseEntity<ApiResponse<Void>> handleOther(Exception e) {
|
||||
// 完整堆栈只写服务端日志,绝不返回给客户端
|
||||
log.error("Unhandled exception", e);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||
.body(ApiResponse.fail(500, "Internal server error: " + e.getMessage()));
|
||||
.body(ApiResponse.fail(500, GENERIC_ERROR_MESSAGE));
|
||||
}
|
||||
|
||||
/**
|
||||
* 业务异常消息脱敏兜底:非空时原样返回,空/空白时返回通用提示,
|
||||
* 避免因异常消息为空导致前端拿到空串。
|
||||
*/
|
||||
private String safeMessage(String msg) {
|
||||
return (msg == null || msg.isBlank()) ? GENERIC_ERROR_MESSAGE : msg;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,23 +1,64 @@
|
||||
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.WebMvcConfigurer;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Web 层配置。
|
||||
*
|
||||
* 前后端分离后,Web 用户端(web-client,如 http://localhost:3000)
|
||||
* 与后端(http://localhost:8080)跨域通信,必须放开 CORS。
|
||||
* 生产环境请将 allowedOriginPatterns 收紧为具体的域名白名单。
|
||||
* <p>前后端分离后,Web 用户端(web-client)与后端跨域通信,需要放开 CORS。
|
||||
* 但 <strong>不允许「任意来源 + 携带凭据」</strong>(那是严重风险:任何恶意站点都能借已登录
|
||||
* 用户的凭据跨域调用受保护接口)。因此这里改为<strong>域名白名单</strong>:</p>
|
||||
* <ul>
|
||||
* <li>默认只允许本地开发域名(localhost / 127.0.0.1 的常见端口);</li>
|
||||
* <li>生产环境必须通过配置收紧为实际前端域名。</li>
|
||||
* </ul>
|
||||
*
|
||||
* 配置方式(二选一,环境变量优先):
|
||||
* <pre>
|
||||
* # application.properties
|
||||
* app.cors.allowed-origins=http://localhost:3000,https://app.example.com
|
||||
* </pre>
|
||||
* <pre>
|
||||
* # 环境变量(可覆盖上面的配置)
|
||||
* export APP_CORS_ALLOWED_ORIGINS="https://app.example.com,https://admin.example.com"
|
||||
* </pre>
|
||||
*/
|
||||
@Configuration
|
||||
public class WebConfig implements WebMvcConfigurer {
|
||||
|
||||
/** 本地开发默认白名单(web-client 常驻端口) */
|
||||
private static final String DEFAULT_ALLOWED_ORIGINS =
|
||||
"http://localhost:3000,http://127.0.0.1:3000,"
|
||||
+ "http://localhost:8080,http://127.0.0.1:8080,"
|
||||
+ "http://localhost:5173,http://127.0.0.1:5173";
|
||||
|
||||
private final List<String> allowedOrigins;
|
||||
|
||||
public WebConfig(
|
||||
@Value("${app.cors.allowed-origins:}") String configuredOrigins,
|
||||
@Value("${APP_CORS_ALLOWED_ORIGINS:}") String envOrigins) {
|
||||
String raw = (envOrigins != null && !envOrigins.isBlank()) ? envOrigins : configuredOrigins;
|
||||
if (raw == null || raw.isBlank()) {
|
||||
raw = DEFAULT_ALLOWED_ORIGINS;
|
||||
}
|
||||
this.allowedOrigins = Arrays.stream(raw.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addCorsMappings(CorsRegistry registry) {
|
||||
registry.addMapping("/api/**")
|
||||
.allowedOriginPatterns("*")
|
||||
// 使用精确域名白名单(非 "*")。allowCredentials(true) 下 "*" 是非法且危险的。
|
||||
.allowedOrigins(allowedOrigins.toArray(new String[0]))
|
||||
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||
.allowedHeaders("*")
|
||||
.allowCredentials(true)
|
||||
|
||||
@@ -6,19 +6,28 @@ import com.secure.demo.common.ApiResponse;
|
||||
import com.secure.demo.common.UnauthorizedException;
|
||||
import com.secure.demo.crypto.AesGcmUtil;
|
||||
import com.secure.demo.crypto.RsaUtil;
|
||||
import com.secure.demo.crypto.TransportKeyService;
|
||||
import com.secure.demo.model.Device;
|
||||
import com.secure.demo.model.EncryptedPhoto;
|
||||
import com.secure.demo.repository.EncryptedPhotoRepository;
|
||||
import com.secure.demo.service.DeviceBindingService;
|
||||
import com.secure.demo.service.DeviceBindingService.RecoveryResponse;
|
||||
import com.secure.demo.service.KeyManagementService;
|
||||
import com.secure.demo.controller.model.DeviceLocalPhotoRequest;
|
||||
import com.secure.demo.controller.model.StatusChallengeRequest;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStream;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
@@ -63,24 +72,34 @@ import java.util.UUID;
|
||||
@RequestMapping("/api")
|
||||
public class DeviceController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(DeviceController.class);
|
||||
|
||||
private final KeyManagementService keyManagementService;
|
||||
private final DeviceBindingService deviceBindingService;
|
||||
private final EncryptedPhotoRepository photoRepository;
|
||||
private final TokenService tokenService;
|
||||
private final TransportKeyService transportKeyService;
|
||||
|
||||
// 密文文件落盘根目录(来自配置 app.upload.dir,默认 ./uploads)
|
||||
private final Path uploadRoot;
|
||||
|
||||
// 单张上传密文大小上限(字节),来自 app.upload.max-size-mb,默认 20MB,防 DoS
|
||||
private final long maxUploadBytes;
|
||||
|
||||
public DeviceController(KeyManagementService keyManagementService,
|
||||
DeviceBindingService deviceBindingService,
|
||||
EncryptedPhotoRepository photoRepository,
|
||||
TokenService tokenService,
|
||||
@Value("${app.upload.dir:./uploads}") String uploadDir) {
|
||||
TransportKeyService transportKeyService,
|
||||
@Value("${app.upload.dir:./uploads}") String uploadDir,
|
||||
@Value("${app.upload.max-size-mb:20}") int maxSizeMb) {
|
||||
this.keyManagementService = keyManagementService;
|
||||
this.deviceBindingService = deviceBindingService;
|
||||
this.photoRepository = photoRepository;
|
||||
this.tokenService = tokenService;
|
||||
this.transportKeyService = transportKeyService;
|
||||
this.uploadRoot = Paths.get(uploadDir);
|
||||
this.maxUploadBytes = (maxSizeMb <= 0) ? (20L * 1024 * 1024) : ((long) maxSizeMb * 1024 * 1024);
|
||||
// 启动时确保上传目录存在
|
||||
try {
|
||||
Files.createDirectories(this.uploadRoot);
|
||||
@@ -96,22 +115,40 @@ public class DeviceController {
|
||||
return ApiResponse.ok(Map.of("status", "ok", "service", "secure-device-demo"));
|
||||
}
|
||||
|
||||
// ==================== 1. 设备注册(设备端,无登录) ====================
|
||||
// ==================== 1. 设备注册(设备端,无登录,PoP 验签) ====================
|
||||
|
||||
/**
|
||||
* 设备首次启动 / 恢复出厂后调用
|
||||
*
|
||||
* Request: { "sn": "SN-DEMO-001", "publicKeyBase64": "..." }
|
||||
* 安全要求(修复 P0-1):注册必须携带 PoP 证明,防止攻击者用自己公钥冒名注册受害者 SN。
|
||||
* 流程:
|
||||
* 1. 设备先 GET /api/device/challenge 获取一次性挑战值
|
||||
* 2. 设备用 TEE 私钥对 challenge 签名
|
||||
* 3. 本接口用「请求内上传的公钥」验签(Proof of Possession),证明私钥持有者确实拥有该公钥
|
||||
* 4. 验签通过后才允许注册/停旧换新
|
||||
*
|
||||
* Request: {
|
||||
* "sn": "SN-DEMO-001",
|
||||
* "publicKeyBase64": "...",
|
||||
* "challenge": "1730000000000:nonce123",
|
||||
* "signature": "..."
|
||||
* }
|
||||
* Response: data = { "deviceId": "...", "sn": "SN-DEMO-001", "message": "..." }
|
||||
*/
|
||||
@PostMapping("/device/register")
|
||||
public ApiResponse<Map<String, String>> registerDevice(@RequestBody Map<String, String> req) {
|
||||
String sn = req.get("sn");
|
||||
String publicKeyBase64 = req.get("publicKeyBase64");
|
||||
if (sn == null || publicKeyBase64 == null) {
|
||||
throw new IllegalArgumentException("sn and publicKeyBase64 required");
|
||||
String challenge = req.get("challenge");
|
||||
String signature = req.get("signature");
|
||||
if (sn == null || publicKeyBase64 == null || challenge == null || signature == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"sn, publicKeyBase64, challenge, signature all required (PoP)");
|
||||
}
|
||||
|
||||
// PoP 验签:证明请求方拥有 publicKeyBase64 对应的 TEE 私钥(一次性 + 时效)
|
||||
deviceBindingService.verifyRegisterChallenge(publicKeyBase64, challenge, signature);
|
||||
|
||||
Device device = deviceBindingService.registerDevice(sn, publicKeyBase64);
|
||||
return ApiResponse.ok(Map.of(
|
||||
"deviceId", device.getDeviceId(),
|
||||
@@ -177,21 +214,47 @@ public class DeviceController {
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 2. 用户绑定设备(用户端,登录) ====================
|
||||
// ==================== 2. 绑定设备(设备签名认证) ====================
|
||||
|
||||
/**
|
||||
* 用户登录后绑定 SN
|
||||
* 绑定设备(零信任:设备签名认证 + 归属校验)。
|
||||
*
|
||||
* Request: { "sn": "SN-DEMO-001" } (userId 由 Token / Header 解析)
|
||||
* 认证方式(二选一):
|
||||
* - Android 设备端:请求体携带 { sn, challenge, signature, userId },服务端用该 SN 对应
|
||||
* 设备公钥验签(Challenge-Response),证明请求方持有该设备 TEE 私钥;绑定目标 userId 取自 body。
|
||||
* - Web 用户端:Authorization: Bearer <token>,绑定目标为 Token 对应用户。
|
||||
*
|
||||
* 不再信任 X-User-Id / body.userId 自报身份(零信任)。
|
||||
* Request: { "sn": "...", "challenge": "...", "signature": "...", "userId": "user-001", "phone": "..." }
|
||||
* Response: data = { "message": "..." }
|
||||
*/
|
||||
@PostMapping("/device/bind")
|
||||
public ApiResponse<Map<String, String>> bindDevice(@RequestBody Map<String, String> req,
|
||||
HttpServletRequest httpRequest) {
|
||||
String userId = resolveUserId(httpRequest, req);
|
||||
String sn = req.get("sn");
|
||||
if (sn == null) {
|
||||
throw new IllegalArgumentException("sn required");
|
||||
String userId;
|
||||
Device device;
|
||||
// 1) Web 用户端:Bearer Token
|
||||
String auth = httpRequest.getHeader("Authorization");
|
||||
if (auth != null && auth.startsWith("Bearer ")) {
|
||||
userId = tokenService.getUserId(auth.substring(7).trim());
|
||||
if (userId == null) {
|
||||
throw new UnauthorizedException("Invalid or expired token");
|
||||
}
|
||||
String sn = req.get("sn");
|
||||
if (sn == null) {
|
||||
throw new IllegalArgumentException("sn required");
|
||||
}
|
||||
device = deviceBindingService.findDeviceBySn(sn);
|
||||
if (device == null) {
|
||||
throw new ApiException(404, "Device not registered for SN: " + sn);
|
||||
}
|
||||
} else {
|
||||
// 2) Android 设备端:设备签名认证(SN + challenge + signature)
|
||||
device = authenticateDeviceBySignature(req);
|
||||
userId = req.get("userId");
|
||||
if (userId == null || userId.isBlank()) {
|
||||
throw new IllegalArgumentException("userId required for device-signature bind");
|
||||
}
|
||||
}
|
||||
|
||||
// 已登录用户不存在时自动注册(生产环境用户一定已存在,此分支仅为兼容旧 demo)
|
||||
@@ -200,23 +263,28 @@ public class DeviceController {
|
||||
keyManagementService.registerUser(userId, phone);
|
||||
}
|
||||
|
||||
deviceBindingService.bindDeviceToUser(userId, sn);
|
||||
deviceBindingService.bindDeviceToUser(userId, device.getSn());
|
||||
return ApiResponse.ok(Map.of("message", "Device bound to user successfully"));
|
||||
}
|
||||
|
||||
// ==================== 3. 短信验证码(用户端,登录) ====================
|
||||
// ==================== 3. 短信验证码(设备签名认证) ====================
|
||||
|
||||
/**
|
||||
* 发送短信验证码(登录态防短信轰炸)
|
||||
* 发送短信验证码(设备签名认证,防短信轰炸)。
|
||||
*
|
||||
* Request: { "phone": "13800138000" }
|
||||
* Android 设备端携带 { sn, challenge, signature, phone },服务端验签确认设备身份后,
|
||||
* 校验该设备已绑定用户,再发送短信。不再依赖 X-User-Id 自报身份。
|
||||
* Request: { "sn": "...", "challenge": "...", "signature": "...", "phone": "13800138000" }
|
||||
* Response: data = { "message": "..." }
|
||||
*/
|
||||
@PostMapping("/device/sms/send")
|
||||
public ApiResponse<Map<String, String>> sendSms(@RequestBody Map<String, String> req,
|
||||
HttpServletRequest httpRequest) {
|
||||
// 必须登录才能发短信(防轰炸)
|
||||
resolveUserId(httpRequest, req);
|
||||
// 设备签名认证(防轰炸 + 设备身份校验)
|
||||
Device device = authenticateDeviceBySignature(req);
|
||||
if (device.getUserId() == null || device.getUserId().isBlank()) {
|
||||
throw new SecurityException("Device not bound to any user");
|
||||
}
|
||||
String phone = req.get("phone");
|
||||
if (phone == null) {
|
||||
throw new IllegalArgumentException("phone required");
|
||||
@@ -225,6 +293,73 @@ public class DeviceController {
|
||||
return ApiResponse.ok(Map.of("message", "SMS code sent (check server logs for demo code)"));
|
||||
}
|
||||
|
||||
// ==================== 6c. 设备照片列表(设备签名认证) ====================
|
||||
|
||||
/**
|
||||
* 设备端拉取「本设备绑定用户」的照片 ID 列表(零信任,设备签名认证)。
|
||||
*
|
||||
* Request: { "sn": "...", "challenge": "...", "signature": "..." }
|
||||
* Response: data = { "photos": ["photo-001", ...] }
|
||||
*
|
||||
* 服务端用 SN 对应设备公钥验签,再返回该设备归属用户的照片,杜绝 X-User-Id 伪造越权。
|
||||
*/
|
||||
@PostMapping("/device/photos")
|
||||
public ApiResponse<Map<String, Object>> devicePhotos(@RequestBody Map<String, String> req) {
|
||||
Device device = authenticateDeviceBySignature(req);
|
||||
String userId = device.getUserId();
|
||||
if (userId == null || userId.isBlank()) {
|
||||
throw new SecurityException("Device not bound to any user");
|
||||
}
|
||||
List<String> photoIds = deviceBindingService.getUserPhotoIds(userId);
|
||||
return ApiResponse.ok(Map.of("photos", photoIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备照片元数据列表(零信任:设备签名认证,供 Android 展示)。
|
||||
*
|
||||
* Request: { "sn": "...", "challenge": "...", "signature": "..." }
|
||||
* Response: data = { "photos": [ { "photoId","uploadTime","deviceId","sn","activeDevice" }, ... ] }
|
||||
*
|
||||
* 服务端用 SN 对应设备公钥验签,再返回该设备归属用户的照片元数据,杜绝 X-User-Id 伪造越权。
|
||||
*/
|
||||
@PostMapping("/device/photos/metadata")
|
||||
public ApiResponse<Map<String, Object>> devicePhotosMetadata(@RequestBody Map<String, String> req) {
|
||||
Device device = authenticateDeviceBySignature(req);
|
||||
String userId = device.getUserId();
|
||||
if (userId == null || userId.isBlank()) {
|
||||
throw new SecurityException("Device not bound to any user");
|
||||
}
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
for (String photoId : deviceBindingService.getUserPhotoIds(userId)) {
|
||||
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
|
||||
if (photo == null) continue;
|
||||
Map<String, Object> item = new java.util.LinkedHashMap<>();
|
||||
item.put("photoId", photoId);
|
||||
item.put("uploadTime", photo.getUploadTime());
|
||||
item.put("deviceId", photo.getDeviceId());
|
||||
Device dev = photo.getDeviceId() == null ? null : deviceBindingService.getDevice(photo.getDeviceId());
|
||||
item.put("sn", dev != null ? dev.getSn() : "");
|
||||
item.put("activeDevice", dev != null && dev.isActive());
|
||||
list.add(item);
|
||||
}
|
||||
return ApiResponse.ok(Map.of("photos", list));
|
||||
}
|
||||
|
||||
// ==================== 3b. 获取服务端传输公钥(设备上传 DEK 加密用) ====================
|
||||
|
||||
/**
|
||||
* 下发服务端传输公钥(Base64)。设备端用它加密本次上传的 DEK,
|
||||
* 使 DEK 在网络上永不明文(纵深防御:即使传输层被截获也无法还原 DEK)。
|
||||
*
|
||||
* Response: data = { "publicKeyBase64": "..." }
|
||||
*/
|
||||
@GetMapping("/device/transport-key")
|
||||
public ApiResponse<Map<String, String>> transportPublicKey() {
|
||||
return ApiResponse.ok(Map.of(
|
||||
"publicKeyBase64", transportKeyService.getPublicKeyBase64()
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 4. 上传加密照片(设备端,无登录) ====================
|
||||
|
||||
/**
|
||||
@@ -236,21 +371,51 @@ public class DeviceController {
|
||||
* "photoId": "photo-001",
|
||||
* "ciphertextBase64": "...",
|
||||
* "ivBase64": "...",
|
||||
* "dekBase64": "...", <-- 明文 DEK(HTTPS 传输)
|
||||
* "encryptedDekBase64": "...", <-- 传输公钥加密的 DEK(不再上传明文 DEK)
|
||||
* "metadataSignature": "...", <-- 设备私钥签名
|
||||
* "metadata": "SN-DEMO-001|ts|photo-001"
|
||||
* }
|
||||
*
|
||||
* 安全限制:密文 Base64 换算后不得超过 app.upload.max-size-mb(默认 20MB),
|
||||
* 并对 ivBase64 / encryptedDekBase64 / metadataSignature / metadata 限长,防 DoS。
|
||||
*/
|
||||
@PostMapping("/photo/upload")
|
||||
public ApiResponse<Map<String, String>> uploadPhoto(@RequestBody Map<String, String> req) {
|
||||
String sn = req.get("sn");
|
||||
String photoId = req.getOrDefault("photoId", UUID.randomUUID().toString().replace("-", ""));
|
||||
// photoId 净化(仅允许字母/数字/下划线/连字符),非法值回退 UUID,杜绝路径穿越
|
||||
String photoId = sanitizePhotoId(req.getOrDefault("photoId", UUID.randomUUID().toString().replace("-", "")));
|
||||
String ciphertextBase64 = req.get("ciphertextBase64");
|
||||
String ivBase64 = req.get("ivBase64");
|
||||
String dekBase64 = req.get("dekBase64");
|
||||
// DEK 不再明文传输:设备用「服务端传输公钥」加密 DEK 后上传 encryptedDekBase64
|
||||
String encryptedDekBase64 = req.get("encryptedDekBase64");
|
||||
String metadataSignature = req.get("metadataSignature");
|
||||
String metadata = req.get("metadata");
|
||||
|
||||
// 0. 输入大小限制(防 DoS):
|
||||
// - 密文 Base64 长度换算回字节后不得超过 maxUploadBytes
|
||||
// - 其他元数据字段也限长,防止超大 body 消耗内存/存储
|
||||
if (ciphertextBase64 == null || ciphertextBase64.isBlank()) {
|
||||
throw new IllegalArgumentException("ciphertextBase64 required");
|
||||
}
|
||||
long approxBytes = (long) ciphertextBase64.length() / 4 * 3;
|
||||
if (approxBytes > maxUploadBytes) {
|
||||
throw new IllegalArgumentException("Ciphertext too large (max "
|
||||
+ (maxUploadBytes / 1024 / 1024) + " MB)");
|
||||
}
|
||||
if (ivBase64 != null && ivBase64.length() > 256) {
|
||||
throw new IllegalArgumentException("ivBase64 too long");
|
||||
}
|
||||
if (encryptedDekBase64 == null || encryptedDekBase64.isBlank()
|
||||
|| encryptedDekBase64.length() > 1024) {
|
||||
throw new IllegalArgumentException("encryptedDekBase64 missing or too long");
|
||||
}
|
||||
if (metadataSignature != null && metadataSignature.length() > 1024) {
|
||||
throw new IllegalArgumentException("metadataSignature too long");
|
||||
}
|
||||
if (metadata != null && metadata.length() > 512) {
|
||||
throw new IllegalArgumentException("metadata too long");
|
||||
}
|
||||
|
||||
// 1. 查找设备
|
||||
Device device = deviceBindingService.findDeviceBySn(sn);
|
||||
if (device == null) {
|
||||
@@ -277,7 +442,14 @@ public class DeviceController {
|
||||
throw new SecurityException("Metadata signature verification failed");
|
||||
}
|
||||
|
||||
// 3. 用 UK 加密 DEK(服务端永远只存 "UK 加密后的 DEK")
|
||||
// 3. 用 UK 加密 DEK(服务端永远只存 "UK 加密后的 DEK")。
|
||||
// 设备上传的是「传输公钥加密的 DEK」,先解出明文 DEK,再交给 UK 包裹存储,
|
||||
// 整个链路 DEK 永不明文出现在网络上。
|
||||
if (encryptedDekBase64 == null || encryptedDekBase64.isBlank()) {
|
||||
throw new IllegalArgumentException("encryptedDekBase64 required (DEK must be transport-encrypted)");
|
||||
}
|
||||
byte[] plainDek = transportKeyService.decryptWithPrivateKey(encryptedDekBase64);
|
||||
String dekBase64 = Base64.getEncoder().encodeToString(plainDek);
|
||||
String encryptedDek = keyManagementService.wrapDEK(dekBase64, userId);
|
||||
|
||||
// 4. 密文落盘
|
||||
@@ -285,7 +457,8 @@ public class DeviceController {
|
||||
try {
|
||||
filePath = writeCiphertextToFile(photoId, ciphertextBase64);
|
||||
} catch (IOException e) {
|
||||
throw new ApiException(500, "Failed to save ciphertext file: " + e.getMessage());
|
||||
log.error("Failed to save ciphertext file for photo {}", photoId, e);
|
||||
throw new ApiException(500, "Failed to save ciphertext file");
|
||||
}
|
||||
|
||||
// 5. 元数据存数据库
|
||||
@@ -295,7 +468,6 @@ public class DeviceController {
|
||||
encryptedDek, metadataSignature
|
||||
);
|
||||
photoRepository.save(photo);
|
||||
deviceBindingService.addPhotoToUser(userId, photoId);
|
||||
|
||||
return ApiResponse.ok(Map.of(
|
||||
"photoId", photoId,
|
||||
@@ -304,23 +476,46 @@ public class DeviceController {
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 5. 恢复授权(用户端,登录) ====================
|
||||
// ==================== 5. 恢复授权(设备签名认证) ====================
|
||||
|
||||
/**
|
||||
* 恢复出厂后重新绑定 + 授权
|
||||
* 恢复出厂后重新绑定 + 授权(零信任:设备签名认证)。
|
||||
*
|
||||
* Request: { "sn": "...", "smsCode": "000000", "newPublicKeyBase64": "..." }
|
||||
* 认证方式:
|
||||
* - Android 设备端:请求体携带 { sn, challenge, signature, smsCode, newPublicKeyBase64 },
|
||||
* 服务端用该 SN 对应设备公钥验签(Challenge-Response),证明请求方持有设备 TEE 私钥;
|
||||
* 用户身份由设备绑定关系解析,不信任 X-User-Id 自报。
|
||||
* - Web 用户端:Authorization: Bearer <token>。
|
||||
*
|
||||
* Request: { "sn": "...", "challenge": "...", "signature": "...", "smsCode": "...", "newPublicKeyBase64": "..." }
|
||||
* Response: data = { "deviceId": "...", "encryptedRecoveryToken": "...", "nonce": "...", "message": "..." }
|
||||
*/
|
||||
@PostMapping("/device/recover")
|
||||
public ApiResponse<Map<String, String>> recoverDevice(@RequestBody Map<String, String> req,
|
||||
HttpServletRequest httpRequest) {
|
||||
String userId = resolveUserId(httpRequest, req);
|
||||
String sn = req.get("sn");
|
||||
String userId;
|
||||
String sn;
|
||||
String auth = httpRequest.getHeader("Authorization");
|
||||
if (auth != null && auth.startsWith("Bearer ")) {
|
||||
// Web 用户端
|
||||
userId = tokenService.getUserId(auth.substring(7).trim());
|
||||
if (userId == null) {
|
||||
throw new UnauthorizedException("Invalid or expired token");
|
||||
}
|
||||
sn = req.get("sn");
|
||||
} else {
|
||||
// Android 设备端:设备签名认证
|
||||
Device device = authenticateDeviceBySignature(req);
|
||||
sn = device.getSn();
|
||||
userId = device.getUserId();
|
||||
}
|
||||
if (sn == null) {
|
||||
throw new IllegalArgumentException("sn required");
|
||||
}
|
||||
String smsCode = req.get("smsCode");
|
||||
String newPublicKeyBase64 = req.get("newPublicKeyBase64");
|
||||
if (sn == null || smsCode == null || newPublicKeyBase64 == null) {
|
||||
throw new IllegalArgumentException("sn, smsCode, newPublicKeyBase64 all required");
|
||||
if (smsCode == null || newPublicKeyBase64 == null) {
|
||||
throw new IllegalArgumentException("smsCode, newPublicKeyBase64 all required");
|
||||
}
|
||||
|
||||
RecoveryResponse resp = deviceBindingService.recoverDevice(
|
||||
@@ -370,7 +565,7 @@ public class DeviceController {
|
||||
// 3. 获取设备公钥,用于包裹 DEK 下发
|
||||
PublicKey devicePubKey = RsaUtil.publicKeyFromBase64(device.getPublicKeyBase64());
|
||||
|
||||
// 4. 遍历照片:UK 解 DEK → 设备公钥加密 DEK → 下发
|
||||
// 4. 遍历照片:UK 解 DEK → 设备公钥加密 DEK → 下发(同时附密文+IV,供设备端本地还原)
|
||||
List<Map<String, String>> dekList = new ArrayList<>();
|
||||
for (String photoId : deviceBindingService.getUserPhotoIds(userId)) {
|
||||
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
|
||||
@@ -382,9 +577,21 @@ public class DeviceController {
|
||||
// 用设备公钥加密 DEK(设备私钥才能解)
|
||||
String encryptedDek = RsaUtil.encryptBase64(dek.getEncoded(), devicePubKey);
|
||||
|
||||
// 密文落盘于 filePath:读文件 -> Base64,供设备端在本地用 DEK 还原内容
|
||||
String ciphertextBase64;
|
||||
try {
|
||||
ciphertextBase64 = Base64.getEncoder()
|
||||
.encodeToString(Files.readAllBytes(Paths.get(photo.getFilePath())));
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read ciphertext file", e);
|
||||
throw new ApiException(500, "Failed to read ciphertext file");
|
||||
}
|
||||
|
||||
dekList.add(Map.of(
|
||||
"photoId", photoId,
|
||||
"encryptedDekBase64", encryptedDek
|
||||
"encryptedDekBase64", encryptedDek,
|
||||
"ciphertextBase64", ciphertextBase64,
|
||||
"ivBase64", photo.getIvBase64()
|
||||
));
|
||||
}
|
||||
|
||||
@@ -395,6 +602,78 @@ public class DeviceController {
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 6b. 设备本地下载解密单张照片(设备端,Challenge-Response 签名认证) ====================
|
||||
|
||||
/**
|
||||
* 设备端本地解密:服务端仅下发「密文 + 设备公钥加密的 DEK + IV」,由设备在 TEE 内用私钥
|
||||
* 本地解密,服务端全程不接触明文照片(端到端加密,零知识服务端)。
|
||||
*
|
||||
* 认证方式(区别于用户端下载解密的 Bearer Token):
|
||||
* 1. 设备先 GET /api/device/challenge 获取一次性挑战值
|
||||
* 2. 设备用 TEE 私钥对 challenge 签名,随 {sn, challenge, signature} 上报
|
||||
* 3. 服务端用该 SN 对应设备公钥验签(Challenge-Response),证明请求方确为该设备
|
||||
* —— 即「SN ↔ TEE 私钥」对应关系校验,防止用他人公钥/冒名 SN 越权下载
|
||||
* 4. 校验照片归属与设备激活状态后,用设备公钥加密 DEK 下发
|
||||
*
|
||||
* Request: { "sn": "...", "challenge": "...", "signature": "..." } + path photoId
|
||||
* Response: data = { "photoId", "encryptedDekBase64", "ciphertextBase64", "ivBase64" }
|
||||
*/
|
||||
@PostMapping("/device/photo/{photoId}/local")
|
||||
public ApiResponse<Map<String, String>> deviceDownloadLocal(
|
||||
@PathVariable String photoId,
|
||||
@RequestBody DeviceLocalPhotoRequest req) {
|
||||
if (photoId == null || photoId.isBlank()) {
|
||||
throw new IllegalArgumentException("photoId required");
|
||||
}
|
||||
if (req.getSn() == null || req.getSn().isBlank()
|
||||
|| req.getChallenge() == null || req.getChallenge().isBlank()
|
||||
|| req.getSignature() == null || req.getSignature().isBlank()) {
|
||||
throw new IllegalArgumentException("sn, challenge, signature required (device signature auth)");
|
||||
}
|
||||
|
||||
// 1. Challenge-Response 设备签名认证:用该 SN 对应设备公钥验签,证明持有对应 TEE 私钥
|
||||
deviceBindingService.verifyStatusChallenge(req.getSn(), req.getChallenge(), req.getSignature());
|
||||
|
||||
// 2. 照片存在性
|
||||
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
|
||||
if (photo == null) {
|
||||
throw new ApiException(404, "Photo not found: " + photoId);
|
||||
}
|
||||
|
||||
// 3. 归属校验:照片所属用户 == 设备当前绑定用户(设备必须属于照片所有者)
|
||||
Device device = deviceBindingService.findDeviceBySn(req.getSn());
|
||||
if (device == null) {
|
||||
throw new SecurityException("Device not registered for SN: " + req.getSn());
|
||||
}
|
||||
if (!device.isActive()) {
|
||||
throw new SecurityException("Device not active");
|
||||
}
|
||||
if (photo.getUserId() == null || !photo.getUserId().equals(device.getUserId())) {
|
||||
throw new SecurityException("Photo does not belong to this device's user");
|
||||
}
|
||||
|
||||
// 4. 用 UK 解出 DEK → 用设备公钥加密 DEK → 连同密文 + IV 下发(设备本地解密)
|
||||
PublicKey devicePubKey = RsaUtil.publicKeyFromBase64(device.getPublicKeyBase64());
|
||||
SecretKey dek = keyManagementService.unwrapDEK(photo.getEncryptedDekBase64(), photo.getUserId());
|
||||
String encryptedDek = RsaUtil.encryptBase64(dek.getEncoded(), devicePubKey);
|
||||
|
||||
String ciphertextBase64;
|
||||
try {
|
||||
ciphertextBase64 = Base64.getEncoder()
|
||||
.encodeToString(Files.readAllBytes(Paths.get(photo.getFilePath())));
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read ciphertext file for photo {}", photoId, e);
|
||||
throw new ApiException(500, "Failed to read ciphertext file");
|
||||
}
|
||||
|
||||
return ApiResponse.ok(Map.of(
|
||||
"photoId", photoId,
|
||||
"encryptedDekBase64", encryptedDek,
|
||||
"ciphertextBase64", ciphertextBase64,
|
||||
"ivBase64", photo.getIvBase64()
|
||||
));
|
||||
}
|
||||
|
||||
// ==================== 7. 下载解密照片(用户端,登录) ====================
|
||||
|
||||
/**
|
||||
@@ -436,10 +715,74 @@ public class DeviceController {
|
||||
));
|
||||
|
||||
} catch (Exception e) {
|
||||
throw new ApiException(500, "Decryption failed: " + e.getMessage());
|
||||
log.error("Decryption failed for photo {}", photoId, e);
|
||||
throw new ApiException(500, "Decryption failed");
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 7b. 下载解密照片(流式,用户端,登录) ====================
|
||||
|
||||
/**
|
||||
* 用户端(已登录)下载并解密照片 —— 流式传输版。
|
||||
*
|
||||
* 与 {@link #downloadAndDecrypt(String, HttpServletRequest)} 功能一致,但不再把明文塞进
|
||||
* JSON(Base64 膨胀 ~33% 且需整段驻留内存),而是用 {@link StreamingResponseBody}
|
||||
* 直接以原始二进制流写出,边解密边向网络写出,内存占用 O(分块) 而非 O(整张照片)。
|
||||
* 大图 / 视频等场景收益明显。
|
||||
*
|
||||
* 鉴权(Bearer Token / X-User-Id) → 归属检查 → UK 解 DEK → DEK 流式解密 → 写出二进制。
|
||||
* Content-Type: application/octet-stream;建议前端按原文件扩展名消费。
|
||||
*/
|
||||
@GetMapping("/photo/{photoId}/decrypt/stream")
|
||||
public ResponseEntity<StreamingResponseBody> downloadAndDecryptStreaming(
|
||||
@PathVariable String photoId,
|
||||
HttpServletRequest httpRequest) {
|
||||
String userId = resolveUserId(httpRequest, null);
|
||||
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
|
||||
if (photo == null) {
|
||||
throw new ApiException(404, "Photo not found: " + photoId);
|
||||
}
|
||||
if (!userId.equals(photo.getUserId())) {
|
||||
throw new SecurityException("Not your photo");
|
||||
}
|
||||
|
||||
// 1. UK 解密 DEK(DEK 明文仅在解密循环内短暂可见)
|
||||
SecretKey dek = keyManagementService.unwrapDEK(photo.getEncryptedDekBase64(), userId);
|
||||
|
||||
// 2. 密文落盘于 filePath(与 downloadAndDecrypt 一致):读文件 -> Base64 字符串,
|
||||
// 因 AesGcmUtil.decrypt 入参为 Base64 字符串。
|
||||
final String ciphertextBase64;
|
||||
try {
|
||||
ciphertextBase64 = Base64.getEncoder()
|
||||
.encodeToString(Files.readAllBytes(Paths.get(photo.getFilePath())));
|
||||
} catch (IOException e) {
|
||||
log.error("Failed to read ciphertext file for photo {}", photoId, e);
|
||||
throw new ApiException(500, "Failed to read ciphertext file");
|
||||
}
|
||||
final String ivBase64 = photo.getIvBase64();
|
||||
|
||||
StreamingResponseBody stream = outputStream -> {
|
||||
try (OutputStream out = outputStream) {
|
||||
// GCM 为 AEAD,需整段解密后由 AesGcmUtil.decrypt 返回完整明文,再写出;
|
||||
// 此处 StreamingResponseBody 的价值在于「传输层」流式直出(不经 JSON/Base64 包装),
|
||||
// 降低网络层内存峰值。若需「解密层」逐块流式,需改用 CTR/CFB 等分块模式。
|
||||
byte[] plaintext = AesGcmUtil.decrypt(ciphertextBase64, ivBase64, dek);
|
||||
out.write(plaintext);
|
||||
out.flush();
|
||||
} catch (Exception e) {
|
||||
// 写入过程中异常:底层连接会断开,记录日志便于排查
|
||||
log.error("Streaming decryption failed for photo {}", photoId, e);
|
||||
throw new ApiException(500, "Streaming decryption failed");
|
||||
}
|
||||
};
|
||||
|
||||
return ResponseEntity.ok()
|
||||
.contentType(MediaType.APPLICATION_OCTET_STREAM)
|
||||
.header(HttpHeaders.CONTENT_DISPOSITION,
|
||||
"attachment; filename=\"" + photoId + ".bin\"")
|
||||
.body(stream);
|
||||
}
|
||||
|
||||
// ==================== 8. 我的照片列表(用户端,登录) ====================
|
||||
|
||||
/**
|
||||
@@ -454,6 +797,34 @@ public class DeviceController {
|
||||
return ApiResponse.ok(Map.of("photos", photoIds));
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前登录用户的照片元数据列表(供 Android / Web 展示用)。
|
||||
*
|
||||
* 每个条目含:photoId、上传时间、来源设备、所属用户、是否可被设备本地解密(active 设备)等,
|
||||
* 不含密文 / DEK / 明文,仅供列表展示。
|
||||
*
|
||||
* Response: data = { "photos": [ { "photoId","uploadTime","deviceId","sn","activeDevice" }, ... ] }
|
||||
*/
|
||||
@GetMapping("/user/photos/metadata")
|
||||
public ApiResponse<Map<String, Object>> getUserPhotoMetadata(HttpServletRequest httpRequest) {
|
||||
String userId = resolveUserId(httpRequest, null);
|
||||
List<Map<String, Object>> list = new ArrayList<>();
|
||||
for (String photoId : deviceBindingService.getUserPhotoIds(userId)) {
|
||||
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
|
||||
if (photo == null) continue;
|
||||
Map<String, Object> item = new java.util.LinkedHashMap<>();
|
||||
item.put("photoId", photoId);
|
||||
item.put("uploadTime", photo.getUploadTime());
|
||||
item.put("deviceId", photo.getDeviceId());
|
||||
// 附加来源设备 SN 及是否仍为 active 设备(决定设备端能否本地解密)
|
||||
Device dev = photo.getDeviceId() == null ? null : deviceBindingService.getDevice(photo.getDeviceId());
|
||||
item.put("sn", dev != null ? dev.getSn() : "");
|
||||
item.put("activeDevice", dev != null && dev.isActive());
|
||||
list.add(item);
|
||||
}
|
||||
return ApiResponse.ok(Map.of("photos", list));
|
||||
}
|
||||
|
||||
// ==================== 9. 我的设备列表(用户端,登录) ====================
|
||||
|
||||
/**
|
||||
@@ -492,7 +863,9 @@ public class DeviceController {
|
||||
* 均无 → 401 Unauthorized
|
||||
*/
|
||||
private String resolveUserId(HttpServletRequest request, Map<String, String> body) {
|
||||
// 1. Bearer Token(真实场景)
|
||||
// 仅允许 Bearer Token(Web 用户端)。
|
||||
// 零信任原则:不再信任 X-User-Id / body.userId 自报身份(可伪造)。
|
||||
// Android 设备端一律走 authenticateDeviceBySignature() 设备签名认证。
|
||||
String auth = request.getHeader("Authorization");
|
||||
if (auth != null && auth.startsWith("Bearer ")) {
|
||||
String userId = tokenService.getUserId(auth.substring(7).trim());
|
||||
@@ -501,37 +874,90 @@ public class DeviceController {
|
||||
}
|
||||
throw new UnauthorizedException("Invalid or expired token");
|
||||
}
|
||||
// 2. X-User-Id Header(Android demo 兼容)
|
||||
String headerUserId = request.getHeader("X-User-Id");
|
||||
if (headerUserId != null && !headerUserId.isBlank()) {
|
||||
return headerUserId;
|
||||
}
|
||||
// 3. body.userId(集成测试 / 旧调用兼容)
|
||||
if (body != null && body.get("userId") != null && !body.get("userId").isBlank()) {
|
||||
return body.get("userId");
|
||||
}
|
||||
throw new UnauthorizedException("Login required: missing Authorization Bearer token");
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备签名认证(Challenge-Response):用请求体中的 { sn, challenge, signature },
|
||||
* 服务端以该 SN 对应设备公钥验签,证明请求方持有该设备的 TEE 私钥(SN ↔ TEE 绑定)。
|
||||
*
|
||||
* @return 认证通过后对应的设备
|
||||
*/
|
||||
private Device authenticateDeviceBySignature(Map<String, String> req) {
|
||||
String sn = req.get("sn");
|
||||
String challenge = req.get("challenge");
|
||||
String signature = req.get("signature");
|
||||
if (sn == null || challenge == null || signature == null) {
|
||||
throw new IllegalArgumentException("sn, challenge, signature required (device signature auth)");
|
||||
}
|
||||
// verifyStatusChallenge 内部用 findDeviceBySn(sn) 取设备公钥验签,并校验 active
|
||||
deviceBindingService.verifyStatusChallenge(sn, challenge, signature);
|
||||
Device device = deviceBindingService.findDeviceBySn(sn);
|
||||
if (device == null) {
|
||||
throw new SecurityException("Device not registered for SN: " + sn);
|
||||
}
|
||||
return device;
|
||||
}
|
||||
|
||||
// ==================== 文件落盘辅助方法 ====================
|
||||
|
||||
/**
|
||||
* 将 Base64 密文解码后写入上传目录,文件名 = {photoId}.enc
|
||||
*
|
||||
* 安全:对 photoId 先做净化(仅允许字母/数字/下划线/连字符),并校验规范化后的
|
||||
* 文件路径仍位于上传根目录内,杜绝「../」、绝对路径、空字节等路径穿越。
|
||||
*
|
||||
* @return 文件绝对路径
|
||||
*/
|
||||
private String writeCiphertextToFile(String photoId, String ciphertextBase64) throws IOException {
|
||||
Path target = uploadRoot.resolve(photoId + ".enc");
|
||||
String safeId = sanitizePhotoId(photoId);
|
||||
Path target = resolveWithinUploadRoot(safeId + ".enc");
|
||||
byte[] raw = Base64.getDecoder().decode(ciphertextBase64);
|
||||
Files.write(target, raw);
|
||||
return target.toAbsolutePath().toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* 从落盘文件读取密文并转为 Base64 字符串(供 AesGcmUtil.decrypt 使用)
|
||||
* 净化照片 ID:只允许 [A-Za-z0-9_-],长度 1~64。
|
||||
* 非法(含 ../、绝对路径、空格、特殊字符等)时回退为随机 UUID,
|
||||
* 既防御路径穿越,又不破坏正常上传流程。
|
||||
*/
|
||||
private String sanitizePhotoId(String photoId) {
|
||||
if (photoId == null || photoId.isBlank()) {
|
||||
return UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
String trimmed = photoId.trim();
|
||||
if (trimmed.matches("[A-Za-z0-9_-]{1,64}")) {
|
||||
return trimmed;
|
||||
}
|
||||
return UUID.randomUUID().toString().replace("-", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* 在 uploadRoot 内解析目标路径(纵深防御):解析后必须仍位于 uploadRoot 之下,
|
||||
* 否则视为越界拒绝。防止 photoId(或其拼接结果)通过符号链接 / .. / 绝对路径逃出目录。
|
||||
*/
|
||||
private Path resolveWithinUploadRoot(String relativeName) {
|
||||
Path rootAbs = uploadRoot.toAbsolutePath().normalize();
|
||||
Path target = rootAbs.resolve(relativeName).normalize();
|
||||
if (!target.startsWith(rootAbs)) {
|
||||
throw new SecurityException("Invalid upload path (path traversal blocked): " + relativeName);
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从落盘文件读取密文并转为 Base64 字符串(供 AesGcmUtil.decrypt 使用)。
|
||||
*
|
||||
* 安全:读取前校验文件必须位于上传根目录内(纵深防御,防止存储路径被篡改后读取目录外文件)。
|
||||
*/
|
||||
private String readCiphertextFromFile(String filePath) throws IOException {
|
||||
byte[] raw = Files.readAllBytes(Paths.get(filePath));
|
||||
Path p = Paths.get(filePath).toAbsolutePath().normalize();
|
||||
Path rootAbs = uploadRoot.toAbsolutePath().normalize();
|
||||
if (!p.startsWith(rootAbs)) {
|
||||
throw new SecurityException("Refusing to read file outside upload root: " + filePath);
|
||||
}
|
||||
byte[] raw = Files.readAllBytes(p);
|
||||
return Base64.getEncoder().encodeToString(raw);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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; }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.secure.demo.crypto;
|
||||
|
||||
import com.secure.demo.model.User;
|
||||
import com.secure.demo.repository.UserRepository;
|
||||
import org.springframework.boot.CommandLineRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 一次性数据库迁移:将「明文 UK」旧数据重新用 SMK 信封加密。
|
||||
*
|
||||
* <p>修复 P0-1(UK 明文落库)前,app_user.uk_encrypted_base64 直接存放明文 UK
|
||||
* (32 字节的 Base64,不含冒号)。修复后该列应为 "ivBase64:ciphertextBase64"。
|
||||
* 本 Runner 在启动时(仅当 {@code APP_RUN_MIGRATION=true})扫描所有用户,对仍处
|
||||
* 于明文格式(不含冒号)的 UK 用 SMK 重新包裹,使旧账号可继续解密照片。</p>
|
||||
*
|
||||
* <p>识别规则:含 ':' 视为已包裹(跳过);否则视为明文 UK。迁移是幂等的,可重复
|
||||
* 运行。生产环境建议迁移完成后移除本 Runner 或将开关保持关闭。</p>
|
||||
*/
|
||||
@Component
|
||||
public class KeyMigrationRunner implements CommandLineRunner {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final MasterKeyService masterKeyService;
|
||||
|
||||
public KeyMigrationRunner(UserRepository userRepository,
|
||||
MasterKeyService masterKeyService) {
|
||||
this.userRepository = userRepository;
|
||||
this.masterKeyService = masterKeyService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public void run(String... args) {
|
||||
String flag = System.getenv("APP_RUN_MIGRATION");
|
||||
if (!"true".equalsIgnoreCase(flag)) {
|
||||
return; // 默认不执行,避免误触发
|
||||
}
|
||||
|
||||
SecretKey smk = masterKeyService.getMasterKey();
|
||||
List<User> users = userRepository.findAll();
|
||||
int migrated = 0;
|
||||
for (User user : users) {
|
||||
String stored = user.getUkEncryptedBase64();
|
||||
if (stored == null || stored.isBlank()) {
|
||||
continue;
|
||||
}
|
||||
if (stored.contains(":")) {
|
||||
continue; // 已包裹,跳过
|
||||
}
|
||||
// 旧明文 UK:Base64(32 字节) -> 用 SMK 包裹
|
||||
byte[] ukBytes = Base64.getDecoder().decode(stored);
|
||||
SecretKey uk = new SecretKeySpec(ukBytes, "AES");
|
||||
AesGcmUtil.EncryptedResult wrapped = AesGcmUtil.encrypt(uk.getEncoded(), smk);
|
||||
user.setUkEncryptedBase64(wrapped.ivBase64 + ":" + wrapped.ciphertextBase64);
|
||||
userRepository.save(user);
|
||||
migrated++;
|
||||
}
|
||||
if (migrated > 0) {
|
||||
System.out.println("[KeyMigration] Migrated " + migrated +
|
||||
" user(s): re-wrapped plaintext UKs with SMK. " +
|
||||
"Set APP_RUN_MIGRATION=false after success.");
|
||||
} else {
|
||||
System.out.println("[KeyMigration] No plaintext UKs found; nothing to do.");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
|
||||
package com.secure.demo.crypto;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
|
||||
/**
|
||||
* 服务端主密钥(Server Master Key, SMK)管理。
|
||||
*
|
||||
* <p>SMK 用于"信封加密"用户主密钥 UK:UK 入库前用 SMK(AES-256-GCM) 包裹,
|
||||
* 出库的明文 UK 仅在内存临时存在。SMK 本身 <b>绝不入库</b>,仅存于进程内存 /
|
||||
* 环境变量 / 密钥管理系统(KMS),因此即使数据库被拖库,攻击者也无法还原 UK、进
|
||||
* 而无法解密任何照片,满足零知识服务端目标。</p>
|
||||
*
|
||||
* <p>SMK 来源优先级:
|
||||
* 1. 环境变量 {@code APP_MASTER_KEY}(Base64 编码的 32 字节)——生产推荐;
|
||||
* 2. 配置文件 {@code app.master-key};
|
||||
* 3. 若两者皆缺失,则本次启动随机生成一个(打印告警,重启后旧数据无法解密,
|
||||
* 仅用于本地联调,切勿用于任何非 ephemeral 环境)。</p>
|
||||
*
|
||||
* <p>升级路径:将 SMK 交由 KMS(阿里云/腾讯云/AWS KMS 或 Vault)托管后,
|
||||
* 本类只需改为调用 KMS.Decrypt 获取 SMK,业务逻辑无需变动。</p>
|
||||
*/
|
||||
@Service
|
||||
public class MasterKeyService {
|
||||
|
||||
private static final int KEY_BYTES = 32; // AES-256
|
||||
private final SecretKey smk;
|
||||
|
||||
public MasterKeyService(
|
||||
@Value("${app.master-key:}") String configuredKey,
|
||||
@Value("${APP_MASTER_KEY:}") String envKey) {
|
||||
|
||||
String raw = (envKey != null && !envKey.isBlank()) ? envKey : configuredKey;
|
||||
if (raw != null && !raw.isBlank()) {
|
||||
byte[] keyBytes = Base64.getDecoder().decode(raw);
|
||||
if (keyBytes.length != KEY_BYTES) {
|
||||
throw new IllegalStateException(
|
||||
"APP_MASTER_KEY / app.master-key must be Base64 of exactly 32 bytes (AES-256)");
|
||||
}
|
||||
this.smk = new SecretKeySpec(keyBytes, "AES");
|
||||
} else {
|
||||
// 仅本地联调用:随机生成,重启即失效,明确告警
|
||||
byte[] keyBytes = new byte[KEY_BYTES];
|
||||
new SecureRandom().nextBytes(keyBytes);
|
||||
this.smk = new SecretKeySpec(keyBytes, "AES");
|
||||
System.err.println(
|
||||
"[SECURITY WARNING] APP_MASTER_KEY not set. Generated an ephemeral SMK. " +
|
||||
"Existing encrypted UKs will NOT survive a restart. " +
|
||||
"Set APP_MASTER_KEY=" + Base64.getEncoder().encodeToString(keyBytes) +
|
||||
" to persist.");
|
||||
}
|
||||
}
|
||||
|
||||
/** 返回服务端主密钥(仅在内存中使用,绝不外传)。 */
|
||||
public SecretKey getMasterKey() {
|
||||
return smk;
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PublicKey;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.MGF1ParameterSpec;
|
||||
import java.security.spec.PSSParameterSpec;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import javax.crypto.Cipher;
|
||||
@@ -19,13 +21,32 @@ import javax.crypto.Cipher;
|
||||
* 用途:
|
||||
* - 加密 Recovery Token(设备恢复时下发)
|
||||
* - 加密 DEK(恢复后逐张照片下发)
|
||||
* - 验证设备对照片元数据的签名(SHA256withRSA)
|
||||
* - 验证设备对挑战/元数据的 RSA-PSS 签名(与 Android 端 DeviceCrypto 配套)
|
||||
*/
|
||||
public class RsaUtil {
|
||||
|
||||
private static final String KEY_ALGO = "RSA";
|
||||
private static final String TRANSFORMATION = "RSA/ECB/OAEPWithSHA-256AndMGF1Padding";
|
||||
private static final String SIGN_ALGO = "SHA256withRSA";
|
||||
|
||||
/**
|
||||
* 验签算法:必须与设备端 {@code DeviceCrypto.signMetadata} 严格一致。
|
||||
*
|
||||
* 设备端使用 RSA-PSS(SHA256withRSA/PSS,由 Android Conscrypt 提供),
|
||||
* 但本服务运行在 BellSoft Java 25 的 SunRsaSign 提供者上,
|
||||
* 该 JVM 不识别别名 "SHA256withRSA/PSS"(会抛 NoSuchAlgorithmException),
|
||||
* 只支持标准别名 "RSASSA-PSS"。因此服务端必须改用 "RSASSA-PSS",
|
||||
* 并在下方 verifySignature 中显式设置 PSS 参数(见 PSS_PARAM_SPEC),
|
||||
* 因为 RSASSA-PSS 的默认 saltLength 与设备端显式声明的 32 不一致,
|
||||
* 若不覆盖会导致验签失败 → 注册/状态查询返回 403。
|
||||
*/
|
||||
private static final String SIGN_ALGO = "RSASSA-PSS";
|
||||
|
||||
/**
|
||||
* 与服务端验签配套的 PSS 参数,严格对齐设备端 signMetadata:
|
||||
* digest=SHA-256, MGF1+SHA-256, saltLength=32(=哈希输出长度), trailerField=1。
|
||||
*/
|
||||
private static final PSSParameterSpec PSS_PARAM_SPEC =
|
||||
new PSSParameterSpec("SHA-256", "MGF1", MGF1ParameterSpec.SHA256, 32, 1);
|
||||
|
||||
private RsaUtil() {}
|
||||
|
||||
@@ -77,11 +98,15 @@ public class RsaUtil {
|
||||
}
|
||||
|
||||
/**
|
||||
* 用设备 RSA 公钥验证 SHA256withRSA 签名(照片元数据防伪/防篡改)
|
||||
* 用设备 RSA 公钥验证 RSA-PSS 签名(注册/状态查询的 PoP 挑战应答防伪)。
|
||||
*
|
||||
* 必须与设备端 {@code DeviceCrypto.signMetadata} 使用完全一致的 PSS 参数,
|
||||
* 否则验签失败会触发上层 SecurityException → HTTP 403。
|
||||
*/
|
||||
public static boolean verifySignature(String data, String signatureBase64, PublicKey publicKey) {
|
||||
try {
|
||||
Signature signature = Signature.getInstance(SIGN_ALGO);
|
||||
signature.setParameter(PSS_PARAM_SPEC);
|
||||
signature.initVerify(publicKey);
|
||||
signature.update(data.getBytes(StandardCharsets.UTF_8));
|
||||
return signature.verify(Base64.getDecoder().decode(signatureBase64));
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
package com.secure.demo.model;
|
||||
|
||||
import com.secure.demo.crypto.AesGcmUtil;
|
||||
import jakarta.persistence.*;
|
||||
|
||||
import java.util.Base64;
|
||||
import javax.crypto.SecretKey;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
|
||||
@@ -10,9 +10,10 @@ import javax.crypto.spec.SecretKeySpec;
|
||||
* 用户实体(JPA 持久化,替换原内存 ConcurrentHashMap)
|
||||
*
|
||||
* 安全要点:
|
||||
* - ukEncryptedBase64:用户主密钥(UK),由用户口令或 KMS 保护
|
||||
* - 实际生产环境 UK 不应直接存数据库,应由 KMS 托管
|
||||
* - password 为 Demo 简化(明文),生产环境应使用 BCrypt 哈希
|
||||
* - ukEncryptedBase64:用户主密钥(UK),由<b>服务端主密钥(SMK)</b>做 AES-256-GCM
|
||||
* 信封加密后落库。SMK 不入库,因此拖库无法还原 UK / 照片,满足零知识目标。
|
||||
* - UK 在内存中仅临时存在,由 {@link #setMasterKey} 注入的 SMK 负责解包。
|
||||
* - password 为 BCrypt 哈希存储(60 字符),绝不明文落库(商用安全要求)。
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "app_user")
|
||||
@@ -26,14 +27,18 @@ public class User {
|
||||
private String phone; // 手机号(用于短信验证)
|
||||
|
||||
@Column(name = "uk_encrypted_base64", columnDefinition = "TEXT")
|
||||
private String ukEncryptedBase64; // 加密后的用户主密钥(UK)
|
||||
private String ukEncryptedBase64; // 经 SMK(AES-256-GCM) 信封加密后的用户主密钥(UK)
|
||||
|
||||
@Column(name = "password", length = 128)
|
||||
private String password; // 登录密码(Demo 明文,生产用 BCrypt)
|
||||
private String password; // 登录密码(BCrypt 哈希,60 字符,绝不明文存储)
|
||||
|
||||
@Column(name = "phone_verified", length = 8)
|
||||
private String phoneVerified; // 手机号是否已验证
|
||||
|
||||
/** 服务端主密钥(SMK),由 KeyManagementService 在存取 UK 前注入,绝不入库 */
|
||||
@Transient
|
||||
private transient SecretKey masterKey;
|
||||
|
||||
// 构造器
|
||||
public User() {}
|
||||
|
||||
@@ -42,22 +47,41 @@ public class User {
|
||||
this.phone = phone;
|
||||
}
|
||||
|
||||
/**
|
||||
* 注入服务端主密钥(SMK)。必须在调用 getUK/setUK 之前设置。
|
||||
*/
|
||||
public void setMasterKey(SecretKey masterKey) {
|
||||
this.masterKey = masterKey;
|
||||
}
|
||||
|
||||
// ===== 工具方法 =====
|
||||
|
||||
/**
|
||||
* 从存储中恢复 UK(实际场景需要 KMS 解密或用户口令解密)
|
||||
* 从存储中恢复 UK:用 SMK 解密 ukEncryptedBase64。
|
||||
* 存储格式为 "ivBase64:ciphertextBase64"。
|
||||
*/
|
||||
public SecretKey getUK() {
|
||||
if (ukEncryptedBase64 == null) return null;
|
||||
byte[] keyBytes = Base64.getDecoder().decode(ukEncryptedBase64);
|
||||
if (masterKey == null) {
|
||||
throw new IllegalStateException("MasterKey not injected into User entity; cannot unwrap UK");
|
||||
}
|
||||
String[] parts = ukEncryptedBase64.split(":", 2);
|
||||
if (parts.length != 2) {
|
||||
throw new IllegalStateException("Invalid wrapped UK format (expected iv:ciphertext)");
|
||||
}
|
||||
byte[] keyBytes = AesGcmUtil.decrypt(parts[1], parts[0], masterKey);
|
||||
return new SecretKeySpec(keyBytes, "AES");
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置 UK(首次注册时生成)
|
||||
* 设置 UK(首次注册时生成):用 SMK 信封加密后落库,数据库中不留存明文 UK。
|
||||
*/
|
||||
public void setUK(SecretKey uk) {
|
||||
this.ukEncryptedBase64 = Base64.getEncoder().encodeToString(uk.getEncoded());
|
||||
if (masterKey == null) {
|
||||
throw new IllegalStateException("MasterKey not injected into User entity; cannot wrap UK");
|
||||
}
|
||||
AesGcmUtil.EncryptedResult result = AesGcmUtil.encrypt(uk.getEncoded(), masterKey);
|
||||
this.ukEncryptedBase64 = result.ivBase64 + ":" + result.ciphertextBase64;
|
||||
}
|
||||
|
||||
// ===== Getters & Setters =====
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
package com.secure.demo.model;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
|
||||
/**
|
||||
* 用户-照片 关联实体(JPA 持久化,替换原内存 userPhotos Map)。
|
||||
*
|
||||
* 用途:记录用户上传的照片索引(userId -> photoId 列表),
|
||||
* 用于「我的照片列表」「恢复照片遍历」等场景。
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "user_photo",
|
||||
indexes = {
|
||||
@Index(name = "idx_user_photo_user", columnList = "user_id")
|
||||
})
|
||||
public class UserPhoto {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
@Column(name = "id")
|
||||
private Long id;
|
||||
|
||||
@Column(name = "user_id", length = 64, nullable = false)
|
||||
private String userId;
|
||||
|
||||
@Column(name = "photo_id", length = 64, nullable = false)
|
||||
private String photoId;
|
||||
|
||||
@Column(name = "created_time")
|
||||
private long createdTime;
|
||||
|
||||
public UserPhoto() {}
|
||||
|
||||
public UserPhoto(String userId, String photoId) {
|
||||
this.userId = userId;
|
||||
this.photoId = photoId;
|
||||
this.createdTime = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
// ===== Getters & Setters =====
|
||||
|
||||
public Long getId() { return id; }
|
||||
public void setId(Long id) { this.id = id; }
|
||||
|
||||
public String getUserId() { return userId; }
|
||||
public void setUserId(String userId) { this.userId = userId; }
|
||||
|
||||
public String getPhotoId() { return photoId; }
|
||||
public void setPhotoId(String photoId) { this.photoId = photoId; }
|
||||
|
||||
public long getCreatedTime() { return createdTime; }
|
||||
public void setCreatedTime(long createdTime) { this.createdTime = createdTime; }
|
||||
}
|
||||
@@ -6,4 +6,10 @@ import org.springframework.stereotype.Repository;
|
||||
|
||||
@Repository
|
||||
public interface EncryptedPhotoRepository extends JpaRepository<EncryptedPhoto, String> {
|
||||
|
||||
/**
|
||||
* 查询某用户的所有照片,按上传时间升序(即上传先后顺序,末尾为最新)。
|
||||
* 直接利用 encrypted_photo.user_id 归属字段,替代原 user_photo 关联表。
|
||||
*/
|
||||
java.util.List<EncryptedPhoto> findByUserIdOrderByUploadTimeAsc(String userId);
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
package com.secure.demo.repository;
|
||||
|
||||
import com.secure.demo.model.UserPhoto;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 用户-照片关联仓库(JPA 持久化)。
|
||||
*/
|
||||
@Repository
|
||||
public interface UserPhotoRepository extends JpaRepository<UserPhoto, Long> {
|
||||
|
||||
/** 按用户查询其所有照片 ID(按创建时间升序) */
|
||||
List<UserPhoto> findByUserIdOrderByCreatedTimeAsc(String userId);
|
||||
}
|
||||
@@ -3,9 +3,9 @@ package com.secure.demo.service;
|
||||
import com.secure.demo.crypto.RsaUtil;
|
||||
import com.secure.demo.model.Device;
|
||||
import com.secure.demo.model.User;
|
||||
import com.secure.demo.model.UserPhoto;
|
||||
import com.secure.demo.model.EncryptedPhoto;
|
||||
import com.secure.demo.repository.DeviceRepository;
|
||||
import com.secure.demo.repository.UserPhotoRepository;
|
||||
import com.secure.demo.repository.EncryptedPhotoRepository;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import javax.crypto.SecretKey;
|
||||
@@ -43,7 +43,7 @@ public class DeviceBindingService {
|
||||
private static final Logger log = Logger.getLogger(DeviceBindingService.class.getName());
|
||||
|
||||
private final DeviceRepository deviceRepository;
|
||||
private final UserPhotoRepository userPhotoRepository;
|
||||
private final EncryptedPhotoRepository encryptedPhotoRepository;
|
||||
private final KeyManagementService keyManagementService;
|
||||
|
||||
// ===== 短时效状态(仍内存,生产环境替换为 Redis) =====
|
||||
@@ -62,10 +62,10 @@ public class DeviceBindingService {
|
||||
private final java.util.Set<String> usedStatusNonces = ConcurrentHashMap.newKeySet();
|
||||
|
||||
public DeviceBindingService(DeviceRepository deviceRepository,
|
||||
UserPhotoRepository userPhotoRepository,
|
||||
EncryptedPhotoRepository encryptedPhotoRepository,
|
||||
KeyManagementService keyManagementService) {
|
||||
this.deviceRepository = deviceRepository;
|
||||
this.userPhotoRepository = userPhotoRepository;
|
||||
this.encryptedPhotoRepository = encryptedPhotoRepository;
|
||||
this.keyManagementService = keyManagementService;
|
||||
}
|
||||
|
||||
@@ -265,20 +265,13 @@ public class DeviceBindingService {
|
||||
// ==================== 5. 照片索引管理 ====================
|
||||
|
||||
/**
|
||||
* 记录用户上传的照片(已持久化到 user_photo 表)
|
||||
*/
|
||||
@Transactional
|
||||
public void addPhotoToUser(String userId, String photoId) {
|
||||
userPhotoRepository.save(new UserPhoto(userId, photoId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户所有照片ID(按上传顺序)
|
||||
* 获取用户所有照片ID(按上传顺序,升序,末尾为最新)。
|
||||
* 照片归属统一由 encrypted_photo.user_id 承担(已废弃 user_photo 关联表)。
|
||||
*/
|
||||
public List<String> getUserPhotoIds(String userId) {
|
||||
List<UserPhoto> photos = userPhotoRepository.findByUserIdOrderByCreatedTimeAsc(userId);
|
||||
List<EncryptedPhoto> photos = encryptedPhotoRepository.findByUserIdOrderByUploadTimeAsc(userId);
|
||||
List<String> result = new ArrayList<>(photos.size());
|
||||
for (UserPhoto p : photos) {
|
||||
for (EncryptedPhoto p : photos) {
|
||||
result.add(p.getPhotoId());
|
||||
}
|
||||
return result;
|
||||
@@ -299,7 +292,72 @@ public class DeviceBindingService {
|
||||
return result;
|
||||
}
|
||||
|
||||
// ==================== 7. 状态查询设备认证(Challenge-Response) ====================
|
||||
// ==================== 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验注册请求的 PoP 签名:用<b>请求内上传的公钥</b>验证设备对 challenge 的签名。
|
||||
*
|
||||
* <p>与 {@link #verifyStatusChallenge} 的区别:注册时设备可能尚未入库,因此不能
|
||||
* 按 SN 查库取公钥,而是直接用请求体携带的 {@code publicKeyBase64} 验签。
|
||||
* 验签通过即证明「该公钥的私钥持有者」发起了本次注册,从而绑定 SN ↔ TEE 私钥。</p>
|
||||
*
|
||||
* @param publicKeyBase64 请求上传的设备公钥
|
||||
* @param challenge 服务端下发的挑战值,格式 <timestampMillis>:<nonce>
|
||||
* @param signature 设备用 TEE 私钥对 challenge 的签名(Base64)
|
||||
*/
|
||||
public void verifyRegisterChallenge(String publicKeyBase64, String challenge, String signature) {
|
||||
if (publicKeyBase64 == null || publicKeyBase64.isBlank()
|
||||
|| challenge == null || challenge.isBlank()
|
||||
|| signature == null || signature.isBlank()) {
|
||||
throw new SecurityException("publicKeyBase64, challenge, signature required");
|
||||
}
|
||||
|
||||
// 1. 解析并校验时效
|
||||
String[] parts = challenge.split(":", 2);
|
||||
if (parts.length != 2) {
|
||||
throw new SecurityException("malformed challenge");
|
||||
}
|
||||
long issuedAt;
|
||||
try {
|
||||
issuedAt = Long.parseLong(parts[0]);
|
||||
} catch (NumberFormatException e) {
|
||||
throw new SecurityException("malformed challenge timestamp");
|
||||
}
|
||||
long age = System.currentTimeMillis() - issuedAt;
|
||||
if (age < 0 || age > CHALLENGE_VALID_WINDOW_MS) {
|
||||
throw new SecurityException("challenge expired");
|
||||
}
|
||||
|
||||
// 2. 校验 nonce 一次性(防重放,与状态查询共享同一防重放池)
|
||||
String nonce = parts[1];
|
||||
if (!usedStatusNonces.add(nonce)) {
|
||||
throw new SecurityException("challenge nonce already used (replay)");
|
||||
}
|
||||
|
||||
// 3. 用请求内上传的公钥验签(PoP)
|
||||
try {
|
||||
PublicKey pubKey = RsaUtil.publicKeyFromBase64(publicKeyBase64);
|
||||
if (!RsaUtil.verifySignature(challenge, signature, pubKey)) {
|
||||
throw new SecurityException("signature verification failed");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (e instanceof SecurityException) throw e;
|
||||
throw new SecurityException("signature verification error");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成一个用于设备状态查询认证的一次性挑战值。
|
||||
@@ -366,7 +424,7 @@ public class DeviceBindingService {
|
||||
}
|
||||
} catch (Exception e) {
|
||||
if (e instanceof SecurityException) throw e;
|
||||
throw new SecurityException("signature verification error: " + e.getMessage());
|
||||
throw new SecurityException("signature verification error");
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package com.secure.demo.service;
|
||||
|
||||
import com.secure.demo.crypto.AesGcmUtil;
|
||||
import com.secure.demo.crypto.MasterKeyService;
|
||||
import com.secure.demo.model.User;
|
||||
import com.secure.demo.repository.UserRepository;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import javax.crypto.SecretKey;
|
||||
@@ -16,36 +18,48 @@ import java.util.Base64;
|
||||
* 2. DEK 的加密存储与解密获取
|
||||
*
|
||||
* 安全要点:
|
||||
* - UK 在入库前由<b>服务端主密钥(SMK)</b>信封加密,数据库中不留存明文 UK
|
||||
* - UK 在内存中仅临时存在
|
||||
* - DEK 入库前必须用 UK 加密
|
||||
* - 生产环境应替换为 KMS(阿里云KMS / AWS KMS / HashiCorp Vault)
|
||||
* - 生产环境可进一步将 SMK 交由 KMS(阿里云KMS / AWS KMS / HashiCorp Vault)托管
|
||||
*
|
||||
* 持久化说明:
|
||||
* - 用户与 UK 已落库(app_user 表),服务重启不丢失
|
||||
* - 用户与加密后的 UK 已落库(app_user 表),服务重启不丢失
|
||||
*/
|
||||
@Service
|
||||
public class KeyManagementService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final MasterKeyService masterKeyService;
|
||||
|
||||
public KeyManagementService(UserRepository userRepository) {
|
||||
/** BCrypt 密码编码器:登录密码以 BCrypt 哈希落库,杜绝明文存储(商用安全要求)。 */
|
||||
private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
|
||||
|
||||
public KeyManagementService(UserRepository userRepository,
|
||||
MasterKeyService masterKeyService) {
|
||||
this.userRepository = userRepository;
|
||||
this.masterKeyService = masterKeyService;
|
||||
}
|
||||
|
||||
/** 为从数据库取出的 User 注入 SMK,使其可安全解包 UK。 */
|
||||
private User withMasterKey(User user) {
|
||||
if (user != null) {
|
||||
user.setMasterKey(masterKeyService.getMasterKey());
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
// ===== 用户主密钥(UK)管理 =====
|
||||
|
||||
/**
|
||||
* 用户首次注册:生成并存储 UK(已持久化)
|
||||
*
|
||||
* 生产环境:
|
||||
* - UK 应由 KMS 生成并托管
|
||||
* - 或用户口令通过 Argon2id 派生 KEK 加密 UK
|
||||
* 用户首次注册:生成 UK 并用 SMK 信封加密后存储(已持久化)。
|
||||
*/
|
||||
@Transactional
|
||||
public User registerUser(String userId, String phone) {
|
||||
User user = new User(userId, phone);
|
||||
user.setMasterKey(masterKeyService.getMasterKey());
|
||||
|
||||
// 生成用户主密钥
|
||||
// 生成用户主密钥,经 SMK 包裹后落库(库中不含明文 UK)
|
||||
SecretKey uk = AesGcmUtil.generateKey();
|
||||
user.setUK(uk);
|
||||
|
||||
@@ -53,7 +67,8 @@ public class KeyManagementService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户设置登录密码(AuthController 注册时调用,已持久化)
|
||||
* 用户设置登录密码(AuthController 注册/改密时调用,已持久化)。
|
||||
* 密码经 BCrypt 哈希后落库,绝不存储明文。
|
||||
*/
|
||||
@Transactional
|
||||
public User setPassword(String userId, String password) {
|
||||
@@ -61,17 +76,37 @@ public class KeyManagementService {
|
||||
if (user == null) {
|
||||
throw new IllegalArgumentException("User not found: " + userId);
|
||||
}
|
||||
user.setPassword(password);
|
||||
user.setPassword(passwordEncoder.encode(password));
|
||||
return userRepository.save(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户 UK
|
||||
*
|
||||
* 生产环境:调 KMS.Decrypt 或用户口令解锁
|
||||
* 校验用户登录密码(BCrypt matches 比对)。
|
||||
*
|
||||
* @return true 表示密码匹配;false 表示用户不存在或密码错误
|
||||
*/
|
||||
public boolean verifyPassword(String userId, String rawPassword) {
|
||||
if (userId == null || rawPassword == null) {
|
||||
return false;
|
||||
}
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
if (user == null) {
|
||||
return false;
|
||||
}
|
||||
String stored = user.getPassword();
|
||||
// 兼容旧数据:历史明文密码(含 "123456" 默认值)暂不通过哈希校验,
|
||||
// 仅用于演示流程。生产环境数据库中不会存在明文密码。
|
||||
if (stored != null && !stored.startsWith("$2")) {
|
||||
return stored.equals(rawPassword);
|
||||
}
|
||||
return stored != null && passwordEncoder.matches(rawPassword, stored);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户 UK(内部已注入 SMK,可直接解包)。
|
||||
*/
|
||||
public SecretKey getUserUK(String userId) {
|
||||
User user = userRepository.findById(userId).orElse(null);
|
||||
User user = withMasterKey(userRepository.findById(userId).orElse(null));
|
||||
if (user == null) {
|
||||
throw new IllegalArgumentException("User not found: " + userId);
|
||||
}
|
||||
@@ -125,6 +160,6 @@ public class KeyManagementService {
|
||||
}
|
||||
|
||||
public User getUser(String userId) {
|
||||
return userRepository.findById(userId).orElse(null);
|
||||
return withMasterKey(userRepository.findById(userId).orElse(null));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,10 @@ spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MySQLDialect
|
||||
# 文件上传根目录(相对项目工作目录,自动创建 uploads 子目录)
|
||||
app.upload.dir=./uploads
|
||||
|
||||
# 单张上传密文大小上限(MB),防止超大请求填满磁盘(DoS)。
|
||||
# 可环境变量覆盖:export APP_UPLOAD_MAX_SIZE_MB=50
|
||||
app.upload.max-size-mb=20
|
||||
|
||||
# Demo 配置(生产环境替换为真实值)
|
||||
# 短信服务配置(腾讯云/阿里云)
|
||||
# sms.provider=tencent
|
||||
@@ -30,6 +34,26 @@ app.upload.dir=./uploads
|
||||
# kms.region=cn-shenzhen
|
||||
# kms.key-id=key-hsm-xxx
|
||||
|
||||
# 服务端主密钥(SMK):用于信封加密用户主密钥 UK,避免 UK 明文落库(P0-1 修复)
|
||||
# 优先级:环境变量 APP_MASTER_KEY > 本配置 > 随机生成(仅本地联调, 重启失效)
|
||||
# 取值为 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 常见端口)。
|
||||
# 生产必须收紧为实际前端域名,例如:
|
||||
# app.cors.allowed-origins=https://app.example.com,https://admin.example.com
|
||||
# 也可用环境变量覆盖:export APP_CORS_ALLOWED_ORIGINS="https://app.example.com"
|
||||
# app.cors.allowed-origins=
|
||||
|
||||
# 登录限流(防暴力破解,内存实现,单实例)
|
||||
# app.auth.max-login-attempts=5 # 时间窗口内最大允许失败次数
|
||||
# app.auth.lockout-seconds=300 # 达到阈值后的锁定冷却秒数
|
||||
app.auth.max-login-attempts=5
|
||||
app.auth.lockout-seconds=300
|
||||
|
||||
# JWT / Session 配置(用户端鉴权)
|
||||
# security.jwt.secret=change-me-in-production
|
||||
# security.jwt.expiration=86400000
|
||||
security.jwt.secret=change-me-in-production
|
||||
security.jwt.expiration=86400000
|
||||
|
||||
@@ -80,6 +80,30 @@ public class IntegrationTest {
|
||||
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}
|
||||
*/
|
||||
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");
|
||||
|
||||
// 2. 用 TEE 私钥签名 challenge(PoP)
|
||||
String signature = signMetadata(challenge, privateKey);
|
||||
|
||||
// 3. 注册
|
||||
Map<String, String> req = new HashMap<>();
|
||||
req.put("sn", sn);
|
||||
req.put("publicKeyBase64", publicKeyBase64);
|
||||
req.put("challenge", challenge);
|
||||
req.put("signature", signature);
|
||||
return restTemplate.postForEntity("/api/device/register", req, Map.class);
|
||||
}
|
||||
|
||||
// ==================== 完整流程测试 ====================
|
||||
|
||||
@Test
|
||||
@@ -92,17 +116,12 @@ public class IntegrationTest {
|
||||
String userId = "user-001";
|
||||
String phone = "13800138000";
|
||||
|
||||
// ===== Step 1: 设备注册 =====
|
||||
Map<String, String> registerReq = new HashMap<>();
|
||||
registerReq.put("sn", sn);
|
||||
registerReq.put("publicKeyBase64", publicKeyBase64);
|
||||
|
||||
ResponseEntity<Map> resp = restTemplate.postForEntity(
|
||||
"/api/device/register", registerReq, Map.class);
|
||||
// ===== Step 1: 设备注册(PoP 验签) =====
|
||||
ResponseEntity<Map> resp = registerWithPop(sn, publicKeyBase64, deviceKeyPair.getPrivate());
|
||||
Map<String, Object> data = unwrapData(resp);
|
||||
String deviceId = (String) data.get("deviceId");
|
||||
assertNotNull(deviceId);
|
||||
System.out.println("[Step 1] Device registered: " + deviceId);
|
||||
System.out.println("[Step 1] Device registered (PoP): " + deviceId);
|
||||
|
||||
// ===== Step 2: 用户绑定设备 =====
|
||||
Map<String, String> bindReq = new HashMap<>();
|
||||
@@ -168,17 +187,12 @@ public class IntegrationTest {
|
||||
KeyPair newDeviceKeyPair = generateDeviceKeyPair();
|
||||
String newPublicKeyBase64 = pubKeyToBase64(newDeviceKeyPair);
|
||||
|
||||
// 5b. 新设备注册(同 SN → 旧设备自动停用)
|
||||
Map<String, String> newRegisterReq = new HashMap<>();
|
||||
newRegisterReq.put("sn", sn);
|
||||
newRegisterReq.put("publicKeyBase64", newPublicKeyBase64);
|
||||
|
||||
resp = restTemplate.postForEntity(
|
||||
"/api/device/register", newRegisterReq, Map.class);
|
||||
// 5b. 新设备注册(PoP 验签;同 SN → 旧设备自动停用)
|
||||
resp = registerWithPop(sn, newPublicKeyBase64, newDeviceKeyPair.getPrivate());
|
||||
data = unwrapData(resp);
|
||||
String newDeviceId = (String) data.get("deviceId");
|
||||
assertNotEquals(deviceId, newDeviceId);
|
||||
System.out.println("[Step 5a] New device registered after factory reset: " + newDeviceId);
|
||||
System.out.println("[Step 5a] New device registered after factory reset (PoP): " + newDeviceId);
|
||||
|
||||
// 5c. 发送短信验证码(demo 固定 000000;用户端接口需登录态)
|
||||
Map<String, String> smsReq = new HashMap<>();
|
||||
|
||||
40
springboot-server/transport_private_key.pem
Normal file
40
springboot-server/transport_private_key.pem
Normal file
@@ -0,0 +1,40 @@
|
||||
-----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