feat(device): 设备状态查询增加 Challenge-Response 签名认证
- Android 端先获取挑战值,用 TEE 私钥签名后查询设备状态 - 服务端新增挑战值接口,校验时效、防重放并验签后才返回状态
This commit is contained in:
@@ -12,6 +12,8 @@ import com.secure.demo.network.ApiClient;
|
|||||||
import com.secure.demo.network.DeviceApi;
|
import com.secure.demo.network.DeviceApi;
|
||||||
import com.secure.demo.network.model.ApiResponse;
|
import com.secure.demo.network.model.ApiResponse;
|
||||||
import com.secure.demo.network.model.BindRequest;
|
import com.secure.demo.network.model.BindRequest;
|
||||||
|
import com.secure.demo.network.model.ChallengeResponse;
|
||||||
|
import com.secure.demo.network.model.DeviceStatusRequest;
|
||||||
import com.secure.demo.network.model.DeviceStatusResponse;
|
import com.secure.demo.network.model.DeviceStatusResponse;
|
||||||
import com.secure.demo.network.model.DownloadDecryptResponse;
|
import com.secure.demo.network.model.DownloadDecryptResponse;
|
||||||
import com.secure.demo.network.model.EncryptedDek;
|
import com.secure.demo.network.model.EncryptedDek;
|
||||||
@@ -215,9 +217,25 @@ public class MainViewModel extends BaseViewModel {
|
|||||||
*
|
*
|
||||||
* 后端接口 GET /api/device/status?sn=xxx(设备端无登录)。
|
* 后端接口 GET /api/device/status?sn=xxx(设备端无登录)。
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* 查询云端设备状态(Challenge-Response 设备签名认证)。
|
||||||
|
*
|
||||||
|
* 流程:
|
||||||
|
* 1. GET /api/device/challenge 获取挑战值
|
||||||
|
* 2. 用 TEE 私钥对 challenge 签名
|
||||||
|
* 3. POST /api/device/status 带上 {sn, challenge, signature},验签通过后才返回状态
|
||||||
|
*/
|
||||||
private void queryDeviceStatus() {
|
private void queryDeviceStatus() {
|
||||||
Disposable d = ApiClient.deviceApi().deviceStatus(sn)
|
if (!requireCrypto()) return;
|
||||||
|
Disposable d = ApiClient.deviceApi().deviceStatusChallenge()
|
||||||
.map(ApiResponse::getData)
|
.map(ApiResponse::getData)
|
||||||
|
.flatMap(ch -> {
|
||||||
|
String challenge = ch.getChallenge();
|
||||||
|
// 用 TEE 私钥对挑战值签名(与上传照片同一签名算法)
|
||||||
|
String signature = crypto.signMetadata(challenge);
|
||||||
|
DeviceStatusRequest body = new DeviceStatusRequest(sn, challenge, signature);
|
||||||
|
return ApiClient.deviceApi().deviceStatus(body).map(ApiResponse::getData);
|
||||||
|
})
|
||||||
.subscribeOn(Schedulers.io())
|
.subscribeOn(Schedulers.io())
|
||||||
.observeOn(AndroidSchedulers.mainThread())
|
.observeOn(AndroidSchedulers.mainThread())
|
||||||
.subscribe(
|
.subscribe(
|
||||||
@@ -249,9 +267,9 @@ public class MainViewModel extends BaseViewModel {
|
|||||||
ui.setBusy(false);
|
ui.setBusy(false);
|
||||||
},
|
},
|
||||||
e -> {
|
e -> {
|
||||||
// 查询失败(如后端未启动):设备本地已就绪,回退到手动注册流程
|
// 查询失败(后端未启动 / 认证失败 / 网络异常):设备本地已就绪,回退到手动注册流程
|
||||||
Log.w(TAG, "查询云端状态失败,回退到手动注册: " + e.getMessage());
|
Log.w(TAG, "查询云端状态失败,回退到手动注册: " + e.getMessage());
|
||||||
ui.appendLog("⚠️ 查询云端状态失败(后端未启动或网络异常),回退到手动流程");
|
ui.appendLog("⚠️ 查询云端状态失败(后端未启动 / 设备认证未通过),回退到手动流程");
|
||||||
ui.appendLog("设备就绪,可开始操作:先点击「注册设备」");
|
ui.appendLog("设备就绪,可开始操作:先点击「注册设备」");
|
||||||
ui.setCurrentStep(1);
|
ui.setCurrentStep(1);
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ package com.secure.demo.network;
|
|||||||
|
|
||||||
import com.secure.demo.network.model.ApiResponse;
|
import com.secure.demo.network.model.ApiResponse;
|
||||||
import com.secure.demo.network.model.BindRequest;
|
import com.secure.demo.network.model.BindRequest;
|
||||||
|
import com.secure.demo.network.model.ChallengeResponse;
|
||||||
import com.secure.demo.network.model.DownloadDecryptResponse;
|
import com.secure.demo.network.model.DownloadDecryptResponse;
|
||||||
|
import com.secure.demo.network.model.DeviceStatusRequest;
|
||||||
import com.secure.demo.network.model.DeviceStatusResponse;
|
import com.secure.demo.network.model.DeviceStatusResponse;
|
||||||
import com.secure.demo.network.model.HealthResponse;
|
import com.secure.demo.network.model.HealthResponse;
|
||||||
import com.secure.demo.network.model.MessageResponse;
|
import com.secure.demo.network.model.MessageResponse;
|
||||||
@@ -34,9 +36,13 @@ import retrofit2.http.Query;
|
|||||||
*/
|
*/
|
||||||
public interface DeviceApi {
|
public interface DeviceApi {
|
||||||
|
|
||||||
/** 0. 查询设备注册/绑定状态(设备端无登录,App 启动时复用已有状态) */
|
/** 0a. 获取设备状态查询挑战值(Challenge-Response 第一步) */
|
||||||
@GET("api/device/status")
|
@GET("api/device/challenge")
|
||||||
Single<ApiResponse<DeviceStatusResponse>> deviceStatus(@Query("sn") String sn);
|
Single<ApiResponse<ChallengeResponse>> deviceStatusChallenge();
|
||||||
|
|
||||||
|
/** 0b. 查询设备注册/绑定状态(设备端无登录,需设备签名认证) */
|
||||||
|
@POST("api/device/status")
|
||||||
|
Single<ApiResponse<DeviceStatusResponse>> deviceStatus(@Body DeviceStatusRequest body);
|
||||||
|
|
||||||
/** 1. 设备注册(SN + 公钥,设备端无登录) */
|
/** 1. 设备注册(SN + 公钥,设备端无登录) */
|
||||||
@POST("api/device/register")
|
@POST("api/device/register")
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.secure.demo.network.model;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备状态查询挑战值响应。
|
||||||
|
*
|
||||||
|
* 对应后端 GET /api/device/challenge 的 data 字段:
|
||||||
|
* { challenge }
|
||||||
|
*
|
||||||
|
* challenge 格式:<timestampMillis>:<nonce>,由设备端使用 TEE 私钥签名后,
|
||||||
|
* 回传给 POST /api/device/status 完成 Challenge-Response 认证。
|
||||||
|
*/
|
||||||
|
public class ChallengeResponse {
|
||||||
|
|
||||||
|
@SerializedName("challenge")
|
||||||
|
private String challenge;
|
||||||
|
|
||||||
|
public String getChallenge() { return challenge; }
|
||||||
|
public void setChallenge(String challenge) { this.challenge = challenge; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.secure.demo.network.model;
|
||||||
|
|
||||||
|
import com.google.gson.annotations.SerializedName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设备状态查询(带设备签名认证)请求体。
|
||||||
|
*
|
||||||
|
* 对应后端 POST /api/device/status 的 body:
|
||||||
|
* { sn, challenge, signature }
|
||||||
|
*
|
||||||
|
* - challenge:由 GET /api/device/challenge 下发,格式 <timestampMillis>:<nonce>
|
||||||
|
* - signature:设备使用 TEE 私钥对 challenge 的签名(Base64)
|
||||||
|
*/
|
||||||
|
public class DeviceStatusRequest {
|
||||||
|
|
||||||
|
@SerializedName("sn")
|
||||||
|
private String sn;
|
||||||
|
|
||||||
|
@SerializedName("challenge")
|
||||||
|
private String challenge;
|
||||||
|
|
||||||
|
@SerializedName("signature")
|
||||||
|
private String signature;
|
||||||
|
|
||||||
|
public DeviceStatusRequest(String sn, String challenge, String signature) {
|
||||||
|
this.sn = sn;
|
||||||
|
this.challenge = challenge;
|
||||||
|
this.signature = signature;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getSn() { return sn; }
|
||||||
|
public String getChallenge() { return challenge; }
|
||||||
|
public String getSignature() { return signature; }
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import com.secure.demo.repository.EncryptedPhotoRepository;
|
|||||||
import com.secure.demo.service.DeviceBindingService;
|
import com.secure.demo.service.DeviceBindingService;
|
||||||
import com.secure.demo.service.DeviceBindingService.RecoveryResponse;
|
import com.secure.demo.service.DeviceBindingService.RecoveryResponse;
|
||||||
import com.secure.demo.service.KeyManagementService;
|
import com.secure.demo.service.KeyManagementService;
|
||||||
|
import com.secure.demo.controller.model.StatusChallengeRequest;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.web.bind.annotation.*;
|
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 启动时调用以复用已有状态)。
|
* 查询设备注册/绑定状态(设备端,无登录,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 = {
|
* Response: data = {
|
||||||
* "registered": true, // 该 SN 是否已注册
|
* "registered": true, // 该 SN 是否已注册
|
||||||
* "bound": true, // 是否已绑定用户
|
* "bound": true, // 是否已绑定用户
|
||||||
@@ -134,12 +154,15 @@ public class DeviceController {
|
|||||||
* "publicKeyBase64": "..." // 已注册时返回(供 App 判断公钥是否轮换)
|
* "publicKeyBase64": "..." // 已注册时返回(供 App 判断公钥是否轮换)
|
||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
@GetMapping("/device/status")
|
@PostMapping("/device/status")
|
||||||
public ApiResponse<Map<String, Object>> deviceStatus(@RequestParam("sn") String sn) {
|
public ApiResponse<Map<String, Object>> deviceStatus(@RequestBody StatusChallengeRequest req) {
|
||||||
if (sn == null || sn.isBlank()) {
|
if (req.getSn() == null || req.getSn().isBlank()) {
|
||||||
throw new IllegalArgumentException("sn required");
|
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 registered = device != null;
|
||||||
boolean bound = registered && device.getUserId() != null && !device.getUserId().isBlank();
|
boolean bound = registered && device.getUserId() != null && !device.getUserId().isBlank();
|
||||||
boolean active = registered && device.isActive();
|
boolean active = registered && device.isActive();
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -55,6 +55,12 @@ public class DeviceBindingService {
|
|||||||
/** Recovery Token 有效时间窗口(毫秒) */
|
/** Recovery Token 有效时间窗口(毫秒) */
|
||||||
private static final long TOKEN_VALID_WINDOW_MS = 5 * 60 * 1000L;
|
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,
|
public DeviceBindingService(DeviceRepository deviceRepository,
|
||||||
UserPhotoRepository userPhotoRepository,
|
UserPhotoRepository userPhotoRepository,
|
||||||
KeyManagementService keyManagementService) {
|
KeyManagementService keyManagementService) {
|
||||||
@@ -293,6 +299,78 @@ public class DeviceBindingService {
|
|||||||
return result;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== 响应模型 ====================
|
// ==================== 响应模型 ====================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user