feat(device): 设备状态查询增加 Challenge-Response 签名认证

- Android 端先获取挑战值,用 TEE 私钥签名后查询设备状态
- 服务端新增挑战值接口,校验时效、防重放并验签后才返回状态
This commit is contained in:
TongTongStudio
2026-08-23 14:01:20 +08:00
parent f0d4d4193f
commit 417c4989f7
7 changed files with 211 additions and 12 deletions

View File

@@ -12,6 +12,7 @@ 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.StatusChallengeRequest;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
@@ -119,12 +120,31 @@ public class DeviceController {
));
}
/**
* 获取设备状态查询用的挑战值Challenge-Response 第一步)。
*
* 设备端先调用本接口拿到挑战值,使用 TEE 私钥对 challenge 签名后,
* 再调用 POST /api/device/status 完成认证并返回状态。
*/
@GetMapping("/device/challenge")
public ApiResponse<Map<String, Object>> deviceStatusChallenge() {
String challenge = deviceBindingService.generateStatusChallenge();
Map<String, Object> body = new java.util.LinkedHashMap<>();
body.put("challenge", challenge);
return ApiResponse.ok(body);
}
/**
* 查询设备注册/绑定状态设备端无登录App 启动时调用以复用已有状态)。
* 本接口要求设备签名认证Challenge-Response基于 TEE 私钥)。
*
* 用途App 每次重启后,先查询「该 SN 是否已注册/已绑定」,避免重复注册与绑定。
* 认证流程:
* 1. 设备先 GET /api/device/challenge 获取挑战值
* 2. 设备用 TEE 私钥对 challenge 签名
* 3. 本接口验签通过后才返回状态(认证失败前不泄露任何绑定信息)
*
* Request: GET /api/device/status?sn=SN-DEMO-001
* Request: POST /api/device/status
* body = { "sn": "...", "challenge": "...", "signature": "..." }
* Response: data = {
* "registered": true, // 该 SN 是否已注册
* "bound": true, // 是否已绑定用户
@@ -134,12 +154,15 @@ public class DeviceController {
* "publicKeyBase64": "..." // 已注册时返回(供 App 判断公钥是否轮换)
* }
*/
@GetMapping("/device/status")
public ApiResponse<Map<String, Object>> deviceStatus(@RequestParam("sn") String sn) {
if (sn == null || sn.isBlank()) {
@PostMapping("/device/status")
public ApiResponse<Map<String, Object>> deviceStatus(@RequestBody StatusChallengeRequest req) {
if (req.getSn() == null || req.getSn().isBlank()) {
throw new IllegalArgumentException("sn required");
}
Device device = deviceBindingService.findDeviceBySn(sn);
// 设备签名认证(时效 + 防重放 + RSA 验签);失败直接抛 SecurityException
deviceBindingService.verifyStatusChallenge(req.getSn(), req.getChallenge(), req.getSignature());
Device device = deviceBindingService.findDeviceBySn(req.getSn());
boolean registered = device != null;
boolean bound = registered && device.getUserId() != null && !device.getUserId().isBlank();
boolean active = registered && device.isActive();

View File

@@ -0,0 +1,19 @@
package com.secure.demo.controller.model;
/**
* 设备状态查询(带设备签名认证)请求体
*/
public class StatusChallengeRequest {
private String sn;
private String challenge; // 服务端下发的挑战值,格式 <timestampMillis>:<nonce>
private String signature; // 设备使用 TEE 私钥对 challenge 的签名Base64
public String getSn() { return sn; }
public void setSn(String sn) { this.sn = sn; }
public String getChallenge() { return challenge; }
public void setChallenge(String challenge) { this.challenge = challenge; }
public String getSignature() { return signature; }
public void setSignature(String signature) { this.signature = signature; }
}

View File

@@ -55,6 +55,12 @@ public class DeviceBindingService {
/** Recovery Token 有效时间窗口(毫秒) */
private static final long TOKEN_VALID_WINDOW_MS = 5 * 60 * 1000L;
/** 设备状态查询挑战challenge有效时间窗口毫秒 */
private static final long CHALLENGE_VALID_WINDOW_MS = 5 * 60 * 1000L;
/** 已消费的 Status Challenge nonce防重放生产环境放 Redis 并设置过期 */
private final java.util.Set<String> usedStatusNonces = ConcurrentHashMap.newKeySet();
public DeviceBindingService(DeviceRepository deviceRepository,
UserPhotoRepository userPhotoRepository,
KeyManagementService keyManagementService) {
@@ -293,6 +299,78 @@ public class DeviceBindingService {
return result;
}
// ==================== 7. 状态查询设备认证Challenge-Response ====================
/**
* 生成一个用于设备状态查询认证的一次性挑战值。
* 格式:<timestampMillis>:<nonce>
* - timestampMillis 用于时效校验CHALLENGE_VALID_WINDOW_MS 内有效)
* - nonce 用于防重放(一次性消费)
*/
public String generateStatusChallenge() {
String timestamp = String.valueOf(System.currentTimeMillis());
String nonce = UUID.randomUUID().toString().replace("-", "");
return timestamp + ":" + nonce;
}
/**
* 校验设备对挑战值的签名是否合法。
*
* 安全逻辑:
* - challenge 必须解析为 <timestamp>:<nonce>,且 timestamp 在有效期内
* - nonce 必须是首次使用(一次性,防重放)
* - 使用设备已注册公钥验签RSA对应设备 TEE 私钥签名)
*
* @return true 表示认证通过;否则抛出 SecurityException
*/
public boolean verifyStatusChallenge(String sn, String challenge, String signature) {
if (sn == null || sn.isBlank() || challenge == null || challenge.isBlank()
|| signature == null || signature.isBlank()) {
throw new SecurityException("sn, challenge, signature required");
}
// 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. 取出设备公钥并执行验签
Device device = findDeviceBySn(sn);
if (device == null) {
throw new SecurityException("device not registered");
}
if (!device.isActive()) {
throw new SecurityException("device not active");
}
try {
PublicKey pubKey = RsaUtil.publicKeyFromBase64(device.getPublicKeyBase64());
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: " + e.getMessage());
}
return true;
}
// ==================== 响应模型 ====================
/**