diff --git a/android-app/app/src/main/java/com/secure/demo/activity/main/MainViewModel.java b/android-app/app/src/main/java/com/secure/demo/activity/main/MainViewModel.java index 81afd38..8b35602 100644 --- a/android-app/app/src/main/java/com/secure/demo/activity/main/MainViewModel.java +++ b/android-app/app/src/main/java/com/secure/demo/activity/main/MainViewModel.java @@ -12,6 +12,8 @@ import com.secure.demo.network.ApiClient; import com.secure.demo.network.DeviceApi; import com.secure.demo.network.model.ApiResponse; 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.DownloadDecryptResponse; import com.secure.demo.network.model.EncryptedDek; @@ -215,9 +217,25 @@ public class MainViewModel extends BaseViewModel { * * 后端接口 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() { - Disposable d = ApiClient.deviceApi().deviceStatus(sn) + if (!requireCrypto()) return; + Disposable d = ApiClient.deviceApi().deviceStatusChallenge() .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()) .observeOn(AndroidSchedulers.mainThread()) .subscribe( @@ -249,9 +267,9 @@ public class MainViewModel extends BaseViewModel { ui.setBusy(false); }, e -> { - // 查询失败(如后端未启动):设备本地已就绪,回退到手动注册流程 + // 查询失败(后端未启动 / 认证失败 / 网络异常):设备本地已就绪,回退到手动注册流程 Log.w(TAG, "查询云端状态失败,回退到手动注册: " + e.getMessage()); - ui.appendLog("⚠️ 查询云端状态失败(后端未启动或网络异常),回退到手动流程"); + ui.appendLog("⚠️ 查询云端状态失败(后端未启动 / 设备认证未通过),回退到手动流程"); ui.appendLog("设备就绪,可开始操作:先点击「注册设备」"); ui.setCurrentStep(1); refreshStatus(); diff --git a/android-app/app/src/main/java/com/secure/demo/network/DeviceApi.java b/android-app/app/src/main/java/com/secure/demo/network/DeviceApi.java index b71dbe4..4866eff 100644 --- a/android-app/app/src/main/java/com/secure/demo/network/DeviceApi.java +++ b/android-app/app/src/main/java/com/secure/demo/network/DeviceApi.java @@ -2,7 +2,9 @@ package com.secure.demo.network; import com.secure.demo.network.model.ApiResponse; 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.DeviceStatusRequest; import com.secure.demo.network.model.DeviceStatusResponse; import com.secure.demo.network.model.HealthResponse; import com.secure.demo.network.model.MessageResponse; @@ -34,9 +36,13 @@ import retrofit2.http.Query; */ public interface DeviceApi { - /** 0. 查询设备注册/绑定状态(设备端无登录,App 启动时复用已有状态) */ - @GET("api/device/status") - Single> deviceStatus(@Query("sn") String sn); + /** 0a. 获取设备状态查询挑战值(Challenge-Response 第一步) */ + @GET("api/device/challenge") + Single> deviceStatusChallenge(); + + /** 0b. 查询设备注册/绑定状态(设备端无登录,需设备签名认证) */ + @POST("api/device/status") + Single> deviceStatus(@Body DeviceStatusRequest body); /** 1. 设备注册(SN + 公钥,设备端无登录) */ @POST("api/device/register") diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/ChallengeResponse.java b/android-app/app/src/main/java/com/secure/demo/network/model/ChallengeResponse.java new file mode 100644 index 0000000..d3cfa99 --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/model/ChallengeResponse.java @@ -0,0 +1,21 @@ +package com.secure.demo.network.model; + +import com.google.gson.annotations.SerializedName; + +/** + * 设备状态查询挑战值响应。 + * + * 对应后端 GET /api/device/challenge 的 data 字段: + * { challenge } + * + * challenge 格式::,由设备端使用 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; } +} diff --git a/android-app/app/src/main/java/com/secure/demo/network/model/DeviceStatusRequest.java b/android-app/app/src/main/java/com/secure/demo/network/model/DeviceStatusRequest.java new file mode 100644 index 0000000..a5c6193 --- /dev/null +++ b/android-app/app/src/main/java/com/secure/demo/network/model/DeviceStatusRequest.java @@ -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 下发,格式 : + * - 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; } +} diff --git a/springboot-server/src/main/java/com/secure/demo/controller/DeviceController.java b/springboot-server/src/main/java/com/secure/demo/controller/DeviceController.java index 4356447..00297cb 100644 --- a/springboot-server/src/main/java/com/secure/demo/controller/DeviceController.java +++ b/springboot-server/src/main/java/com/secure/demo/controller/DeviceController.java @@ -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> deviceStatusChallenge() { + String challenge = deviceBindingService.generateStatusChallenge(); + Map 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> deviceStatus(@RequestParam("sn") String sn) { - if (sn == null || sn.isBlank()) { + @PostMapping("/device/status") + public ApiResponse> 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(); diff --git a/springboot-server/src/main/java/com/secure/demo/controller/model/StatusChallengeRequest.java b/springboot-server/src/main/java/com/secure/demo/controller/model/StatusChallengeRequest.java new file mode 100644 index 0000000..17c6eb0 --- /dev/null +++ b/springboot-server/src/main/java/com/secure/demo/controller/model/StatusChallengeRequest.java @@ -0,0 +1,19 @@ +package com.secure.demo.controller.model; + +/** + * 设备状态查询(带设备签名认证)请求体 + */ +public class StatusChallengeRequest { + private String sn; + private String challenge; // 服务端下发的挑战值,格式 : + 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; } +} diff --git a/springboot-server/src/main/java/com/secure/demo/service/DeviceBindingService.java b/springboot-server/src/main/java/com/secure/demo/service/DeviceBindingService.java index 4a5d445..4f57c66 100644 --- a/springboot-server/src/main/java/com/secure/demo/service/DeviceBindingService.java +++ b/springboot-server/src/main/java/com/secure/demo/service/DeviceBindingService.java @@ -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 usedStatusNonces = ConcurrentHashMap.newKeySet(); + public DeviceBindingService(DeviceRepository deviceRepository, UserPhotoRepository userPhotoRepository, KeyManagementService keyManagementService) { @@ -293,6 +299,78 @@ public class DeviceBindingService { return result; } + // ==================== 7. 状态查询设备认证(Challenge-Response) ==================== + + /** + * 生成一个用于设备状态查询认证的一次性挑战值。 + * 格式:: + * - 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 必须是首次使用(一次性,防重放) + * - 使用设备已注册公钥验签(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; + } + // ==================== 响应模型 ==================== /**