fix(device): 设备注册绑定接口添加 PoP 验签,照片下载改为设备端本地解密

This commit is contained in:
TongTongStudio
2026-08-24 21:32:22 +08:00
parent 417c4989f7
commit dbb75658a6
38 changed files with 2300 additions and 761 deletions

View File

@@ -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>

View File

@@ -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
-- 已有旧数据(明文 UKBase64 且不含冒号)需重新用 SMK 包裹。
-- 纯 SQL 无法完成 AES-256-GCM(密钥来自环境变量 APP_MASTER_KEY)
-- 因此由应用侧一次性 Runner 完成:
--
-- 设置环境变量后启动应用一次:
-- export APP_MASTER_KEY=<Base64 的 32 字节>
-- export APP_RUN_MIGRATION=true
-- # 启动 Spring BootRunner 自动把明文 UK 重新包裹
-- # 成功后关闭unset APP_RUN_MIGRATION
--
-- 若数据库为演示数据、可整体重建,则无需迁移,直接:
-- TRUNCATE TABLE `app_user`;
-- TRUNCATE TABLE `encrypted_photo`;
-- TRUNCATE TABLE `device`;
-- 然后重新走注册流程即可(新账号 UK 默认即用 SMK 加密)。
-- 注:早期版本存在 user_photo 关联表(已废弃),照片归属统一由 encrypted_photo.user_id 承担。
-- ============================================================

View File

@@ -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
*

View File

@@ -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 已锁定返回剩余冷却毫秒数(&gt;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);
}
}

View File

@@ -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;
}
}

View File

@@ -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)

View File

@@ -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": "...", <-- 明文 DEKHTTPS 传输
* "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)} 功能一致,但不再把明文塞进
* JSONBase64 膨胀 ~33% 且需整段驻留内存),而是用 {@link StreamingResponseBody}
* 直接以原始二进制流写出,边解密边向网络写出,内存占用 O(分块) 而非 O(整张照片)。
* 大图 / 视频等场景收益明显。
*
* 鉴权(Bearer Token / X-User-Id) → 归属检查 → UK 解 DEK → DEK 流式解密 → 写出二进制。
* Content-Type: application/octet-stream建议前端按原文件扩展名消费。
*/
@GetMapping("/photo/{photoId}/decrypt/stream")
public ResponseEntity<StreamingResponseBody> downloadAndDecryptStreaming(
@PathVariable String photoId,
HttpServletRequest httpRequest) {
String userId = resolveUserId(httpRequest, null);
EncryptedPhoto photo = photoRepository.findById(photoId).orElse(null);
if (photo == null) {
throw new ApiException(404, "Photo not found: " + photoId);
}
if (!userId.equals(photo.getUserId())) {
throw new SecurityException("Not your photo");
}
// 1. UK 解密 DEKDEK 明文仅在解密循环内短暂可见)
SecretKey dek = keyManagementService.unwrapDEK(photo.getEncryptedDekBase64(), userId);
// 2. 密文落盘于 filePath与 downloadAndDecrypt 一致):读文件 -> Base64 字符串,
// 因 AesGcmUtil.decrypt 入参为 Base64 字符串。
final String ciphertextBase64;
try {
ciphertextBase64 = Base64.getEncoder()
.encodeToString(Files.readAllBytes(Paths.get(photo.getFilePath())));
} catch (IOException e) {
log.error("Failed to read ciphertext file for photo {}", photoId, e);
throw new ApiException(500, "Failed to read ciphertext file");
}
final String ivBase64 = photo.getIvBase64();
StreamingResponseBody stream = outputStream -> {
try (OutputStream out = outputStream) {
// GCM 为 AEAD需整段解密后由 AesGcmUtil.decrypt 返回完整明文,再写出;
// 此处 StreamingResponseBody 的价值在于「传输层」流式直出(不经 JSON/Base64 包装),
// 降低网络层内存峰值。若需「解密层」逐块流式,需改用 CTR/CFB 等分块模式。
byte[] plaintext = AesGcmUtil.decrypt(ciphertextBase64, ivBase64, dek);
out.write(plaintext);
out.flush();
} catch (Exception e) {
// 写入过程中异常:底层连接会断开,记录日志便于排查
log.error("Streaming decryption failed for photo {}", photoId, e);
throw new ApiException(500, "Streaming decryption failed");
}
};
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + photoId + ".bin\"")
.body(stream);
}
// ==================== 8. 我的照片列表(用户端,登录) ====================
/**
@@ -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 TokenWeb 用户端)。
// 零信任原则:不再信任 X-User-Id / body.userId 自报身份(可伪造)。
// Android 设备端一律走 authenticateDeviceBySignature() 设备签名认证。
String auth = request.getHeader("Authorization");
if (auth != null && auth.startsWith("Bearer ")) {
String userId = tokenService.getUserId(auth.substring(7).trim());
@@ -501,37 +874,90 @@ public class DeviceController {
}
throw new UnauthorizedException("Invalid or expired token");
}
// 2. X-User-Id HeaderAndroid 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);
}
}

View File

@@ -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; }
}

View File

@@ -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-1UK 明文落库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; // 已包裹,跳过
}
// 旧明文 UKBase64(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.");
}
}
}

View File

@@ -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 用于"信封加密"用户主密钥 UKUK 入库前用 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;
}
}

View File

@@ -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-PSSSHA256withRSA/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));

View File

@@ -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}
* 指向一个私钥文件,支持 PEMOpenSSL 默认,含 {@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);
}
}
/** 从内联 Base64PKCS#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);
}
}
}

View File

@@ -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 =====

View File

@@ -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; }
}

View File

@@ -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);
}

View File

@@ -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);
}

View File

@@ -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 ====================
/**
* 生成一个用于设备注册认证的一次性挑战值PoPProof 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;
}

View File

@@ -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));
}
}

View File

@@ -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

View File

@@ -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 私钥签名 challengePoP
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<>();

View 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-----